简介:基于开源SWT组件库实现的图书管理系统图形化界面源码,面向具备Java基础、希望学习原生桌面开发或需要完成课程设计、毕业设计的开发者,提供了从界面布局到数据库操作的完整参考。相比常见以Swing为例的教学项目,SWT版本更贴近操作系统原生控件,便于对比理解两类GUI库的差异。资源压缩包约10.07MB,共259个文件,包括45个Java源文件、107个class编译类、80个ico图标、12张jpg图片,并附SQL脚本、jar依赖、项目配置、doc说明与可运行exe,适合导入IDE直接运行或体验演示效果。目前已有483人学习下载。源码覆盖图书新增、查询、借阅、归还和读者管理等核心业务,预览中的主窗口、图书数据访问类、读者数据访问类及多个业务操作类,清晰呈现了SWT常用组件、网格布局、对话框封装与数据库交互的实现路径;类间调用关系体现典型三层结构,配合脚本可快速建表造数,便于着重研读事件监听、资源加载与释放、表格数据展示等知识点,是一份适合课程设计复用和SWT入门进阶的完整素材,包内文档也有助于环境配置与问题定位。
1. 从选型到落地:SWT 图形化界面的图书管理系统在解决什么问题
前阵子帮一个内部资料室做图书登记工具,第一版用了 Swing,窗口一开就带着一股“2008 年”的味道,表格一多还拖不动。后来换成 SWT,界面瞬间变得和系统原生窗口一模一样,表格滚动也顺了。所谓“图书管理系统用 SWT 做的 JAVA 图形化界面”,本质就是用 Eclipse 出品的 SWT 控件库,把图书的增删改查、借阅归还这些 CRUD 操作,包装成一套能双击运行的桌面程序。这套方案解决的核心问题是“界面太丑、太笨重”:SWT 直接调用操作系统原生控件,所以按钮、表格、输入框的观感和系统设置保持一致,开发起来又比 JavaFX 那套 CSS 样式的学习成本低。适合正在做 Java 课程设计、内部小工具,或者想给老管理系统补一个维护窗体的同学。Swing 和 SWT 怎么选,也是 java 面试题里常被拿出来聊的经典对比,值得认真过一遍。下面从环境搭建开始,一直到打包交付,把完整路径走一遍。
2. 搭 SWT 开发环境:先把 JDK 位数和 SWT 版本对齐
SWT 和 Swing 最大的不同在于,Swing 的控件是纯 Java 画的,SWT 则是通过 JNI 包了一层系统的原生控件。这个设计带来一个直接后果:SWT 的 jar 是跟着操作系统走的,Windows 上的包名带 win32,Linux 上带 gtk.linux,macOS 上带 cocoa.macosx。如果你下载错了平台版本,代码写对了也起不来,所以第 2 章先把环境这件事说透。
2.1 确认 JDK 安装与位数:JAVA_HOME 别指错
先说你用什么 JDK 运行。SWT 对 Java 版本的要求不算高,用 JDK 8 或者 11 都很常见,关键是 JDK 位数必须和 SWT 包一致。机器上装了好几个 JDK 时,最容易翻车的是 JAVA_HOME 和 PATH 各指各的,编译没问题,一运行就报找不到主类或者版本不对。
在命令行里验证一下到底用的是哪一个:
echo %JAVA_HOME% java -version第一行看环境变量指向,第二行看当前 PATH 里的 java 版本。如果 PATH 里有别的 JDK 目录排在前面,java -version 出来的就不是你配置的那个。Windows 上还可以再加一句where java,直接列出解析到的 java.exe 完整路径,两边一对照就知道环境变量有没有生效。注意如果是 JDK 17 及以上,老教程里常用的java -d64 -version已经没有输出,直接看 java -version 里的版本号就行。
在这一步确认好,后面 SWT 的 jar 和 JVM 位数的匹配才有意义。很多人在 IDE 里能跑、命令行不能跑,绝大多数就是 JAVA_HOME 和 PATH 不一致导致的。把环境问题排在第一位处理,后面能省出一整个下午的排查时间。
2.2 拿到正确的 SWT jar:平台后缀不能抄错
SWT 的下载地址一般在 Eclipse 官网的 SWT 页面,那里会按平台打成一个 zip,包内是 org.eclipse.swt.win32.win32.x86_64_<版本号>.jar 这类文件名。Windows 64 位就找带 win32.win32.x86_64 的,Linux 找 gtk.linux.x86_64,macOS 找 cocoa.macosx.aarch64 或 x86_64。版本号对应 Eclipse 的 release 线,例如 4.x 系列,具体以官网当期发布为准。
常见的做法有两种。一是直接下载 zip,把 jar 复制到项目里的 lib 目录,然后在 IDE 里 Add to Build Path;二是用 Maven 的方式,依赖坐标里同样带平台后缀。我一般建议课程设计项目用第一种方式,简单直接,也不容易出现仓库里没有对应平台坐标的问题。用 Maven 的话要注意,SWT 的坐标不是单一的 org.eclipse.swt,同样要写全平台后缀,否则拉下来的包无法使用。这一点和普通 Java 库完全不同,很多从 Spring 转过来的人在这里栽跟头。
把 jar 放进项目后,在 Eclipse 或 IDEA 中右键 lib 下的 swt jar,Add to Build Path。然后检查一下依赖列表里能看到 org.eclipse.swt.widgets.Display,说明类路径加载成功。如果你看不到,多半是 build 目录没有刷新,重新构建一次项目再看。
2.3 写第一个 SWT 窗口:Display 与 Shell 的生命周期
类路径通了以后,用一个最小窗口验证环境是否真的可用。下面是完整的最小例子,直接跑通说明环境没问题。
import org.eclipse.swt.widgets.Display; import org.eclipse.swt.widgets.Shell; public class FirstSwtWindow { public static void main(String[] args) { Display display = new Display(); Shell shell = new Shell(display); shell.setText("图书管理系统 - SWT"); shell.setSize(960, 600); shell.open(); while (!shell.isDisposed()) { if (!display.readAndDispatch()) { display.sleep(); } } display.dispose(); } }逻辑说明:Display 是 SWT 与操作系统显示设备之间的桥梁,一个进程一般只建一个;Shell 是主窗口对象,对应系统里的一个原生窗口。readAndDispatch()从系统事件队列里取事件并分发,没有事件时就sleep()让出 CPU,这就是 SWT 的事件循环。最后窗口关闭后一定要display.dispose(),把 JNI 层占用的原生资源还给操作系统。
参数说明:shell.setSize 的两个参数是窗口宽高像素;setText 设置的是任务栏和标题栏上的文字。如果你误写成 setTitle,编译期不会报错但窗口标题不会生效,因为 Shell 用的是 setText,这是 SWT 和 Swing 命名习惯的一个显著差异。跑这个最小程序时如果抛出 ClassNotFoundException,说明 jar 没有真正进入 classpath;如果抛出其它异常,去看 5.5 节列出的启动崩溃场景。
2.4 在启动类里顺手打开一个 GridLayout 布局
既然目标是图书管理系统,窗口里早晚要放多个控件,这里提前引入布局的概念。SWT 的 GridLayout 是最灵活的布局管理器,类似 Swing 的 GridBagLayout,但参数更直白。在 2.3 的 Shell 上放一个主容器,并指定列数:
import org.eclipse.swt.layout.GridLayout; import org.eclipse.swt.widgets.Composite; Composite root = new Composite(shell, SWT.NONE); root.setLayout(new GridLayout(2, false)); root.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true));说明:这里创建了一个 root 容器,里面的布局是两列,第二参数 false 表示不强制每列等宽。GridData 四个参数分别是水平对齐方式、垂直对齐方式、宽度是否随父容器伸展、高度是否随父容器伸展,SWT.FILL 表示尽量填满。如果不写 setLayoutData,控件会缩在自己的 preferred size,不会自动撑满窗口,这也是后面很多布局问题的源头之一。
注意,Shell 本身并不直接使用 GridLayout,而是先放一层 Composite 再做布局,这样后续拆分头部、表格、底部状态栏时会非常方便。到这里环境已经跑通,下一章开始搭图书管理系统的三块界面骨架。
3. 图书管理系统界面骨架:表格、表单与弹窗布局
SWT 没有专门的 Form 布局,但用 GridLayout 完全可以撑起一个管理系统。常见的布局是顶部放工具栏,中部放表格,底部放表单区,再加上一个借阅弹窗。这一章把界面骨架拆开讲,每一块代码都可以直接抄进自己的项目。
3.1 主窗口分区:用 Composite 搭出三行结构
先用三个 Composite 把主窗口切成“工具栏 → 书籍表格 → 底部操作区”三段。这个分区结构是整个界面的地基,后面往每个区域里塞控件就行。
Composite root = new Composite(shell, SWT.NONE); root.setLayout(new GridLayout(1, false)); root.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); Composite topBar = new Composite(root, SWT.NONE); topBar.setLayout(new GridLayout(4, false)); topBar.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false)); Composite tableArea = new Composite(root, SWT.NONE); tableArea.setLayout(new GridLayout(1, false)); tableArea.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); Composite bottomForm = new Composite(root, SWT.NONE); bottomForm.setLayout(new GridLayout(6, false)); bottomForm.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));参数说明:这里的三个 GridData 中,topBar 和 bottomForm 的第四参数是 false,意思是这两个区域不随窗口拉伸变高,只有中间的 tableArea 垂直方向被拉满,正好符合“表格区自动撑满”的直觉。如果你把三块都写成 true,窗口放大后表格会和其他区域一起被拉长,底部按钮会悬在窗口中间,观感非常奇怪。另外注意,只要创建 Composite 就必须配套 setLayout,否则子控件的位置无从谈起,运行后控件会全部挤在左上角,这一步是一层就有代码里最常见的错误。
3.2 用 Table 做书目列表:列宽、列标题与排序准备
书目列表是图书管理系统的核心视图。SWT 的 Table 控件支持多列,表头、网格线、单选多选都可以通过样式常量开启。
Table bookTable = new Table(tableArea, SWT.BORDER | SWT.FULL_SELECTION | SWT.V_SCROLL); bookTable.setHeaderVisible(true); bookTable.setLinesVisible(true); String[] columns = {"书名", "作者", "ISBN", "库存", "分类"}; int[] widths = {180, 100, 120, 60, 80}; for (int i = 0; i < columns.length; i++) { TableColumn col = new TableColumn(bookTable, SWT.NONE); col.setText(columns[i]); col.setWidth(widths[i]); }逻辑说明:SWT.BORDER 给表格加边框,FULL_SELECTION 允许选中整行而不是只选中单个单元格,V_SCROLL 开启垂直滚动。这两行设置很关键,没有 FULL_SELECTION 的话,用户在表格上点击时只能看到一个高亮格子,视觉上很难用;没有 V_SCROLL 的话,数据超过一屏时只能靠鼠标滚轮,而滚轮事件在 Table 上默认处理得并不顺滑。setHeaderVisible 和 setLinesVisible 分别控制表头显示和行间分隔线,图书管理系统里这两项都必须打开。
参数说明:TableColumn 的宽度是像素值,你可以根据实际显示效果调整。建议书名列给最宽的空间,ISBN 列 120 左右足够显示 13 位编号。列创建后如果想支持点击排序,可以在这个循环里给每列挂 SelectionListener,第 6 章会给出一个不破坏当前刷新逻辑的做法。
3.3 底部表单与校验:Group 把输入项分组
底部操作区放书名、作者、ISBN、库存、分类五个输入项和增删改按钮。SWT 里做带标题的输入框区域,常用 Group 代替 Composite,标题会显示在边框左上角。
Group formGroup = new Group(bottomForm, SWT.NONE); formGroup.setText("编辑区域"); formGroup.setLayout(new GridLayout(4, false)); formGroup.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, true)); Label nameLabel = new Label(formGroup, SWT.NONE); nameLabel.setText("书名"); Text nameText = new Text(formGroup, SWT.BORDER); nameText.setLayoutData(new GridData(SWT.FILL, SWT.FILL, true, false));逻辑说明:Group 本质上和 Composite 一样是容器,区别是多了标题文字。每个 Text 输入框旁边配一个 Label 做提示,属于 SWT 常规做法。需要特别注意,Text 的宽度不会自动跟随容器变化,必须给 setLayoutData 设置 GridData,否则窗口拉宽后输入框还是老宽度,整个表单看起来像左边一列输入框右边一片空白。
实际项目里表单区通常还有校验逻辑:书名和 ISBN 不能为空、库存必须是数字。这些校验建议在点击“新增/修改”按钮时做,而不是在 KeyListener 里每敲一个键就校验,否则输入过程中会有很强的打断感。校验失败时用 MessageBox 提示错误,比在控件旁边画红字简单很多。
提示:把校验写成一个返回 boolean 的小方法,通过后再走数据层。这样表单只负责收集输入,正确性收口到一处,后面维护时不用在按钮事件里到处找逻辑。
分类字段如果选项固定,建议用 Combo 下拉框替代 Text,可以直接限定合法值。Combo 的写法是new Combo(formGroup, SWT.DROP_DOWN | SWT.READ_ONLY),再 add 几个预设项。READ_ONLY 这个常量非常重要,没有它用户会往里输入任意值,数据库里就会混入你没见过的分类名。
3.4 借阅登记弹窗:Dialog 与 MessageBox 的用法
图书管理系统除了书目维护,还需要借阅、归还操作。借阅弹窗我建议用 Dialog 或者直接再开一个 Shell,通过setParent关联主窗口。弹窗里放读者编号、图书编号、借出日期三个字段即可。
Shell borrowShell = new Shell(shell, SWT.DIALOG_TRIM | SWT.APPLICATION_MODAL); borrowShell.setText("借阅登记"); borrowShell.setSize(320, 200); Text readerId = new Text(borrowShell, SWT.BORDER); readerId.setBounds(100, 20, 180, 24);这里的 SWT.DIALOG_TRIM 表示采用系统对话框的边框样式,只有标题栏和关闭按钮,没有最大最小化;APPLICATION_MODAL 表示模态窗口,在它关闭前主窗口不能操作。setBounds 是 SWT 里最原始的定位方式,适合弹窗这种表单简单、不需要响应窗口拉伸的场景。如果你在主窗口里也用 setBounds,窗口大小一变化布局就会乱套,所以主窗口一律走 GridLayout,弹窗里才可以用绝对坐标。
图书管理系统里还要想清楚一件事:图书被借出后库存要不要扣减。真正接数据库后会发现很多看似简单的界面操作背后都连着事务,这个在下一章数据打通时再做处理。弹窗关闭后的动作建议放在 OK 按钮的监听器里,通过borrowShell.dispose()关闭窗口,同时把结果返回给主窗口,而不是让主窗口轮询弹窗状态。
4. 界面和数据打通:JDBC 查询到 Table 刷新的完整链路
界面做得再像样,没有数据就是空壳。这一章用 MySQL + JDBC 把表结构建好、数据查出来、填进 Table,并实现选中行回填表单的联动。数据访问这层我不会引入 MyBatis 或 Spring,原因很简单:桌面程序要的是轻量交付,JDBC 在这个规模下足够干净。
4.1 建库建表:三张核心表的字段设计
图书管理系统的数据模型不需要太复杂,三张表就够:书籍表 books、读者表 readers、借阅记录表 borrow_records。下面给出可以直接执行的建表 SQL。
CREATE DATABASE IF NOT EXISTS library DEFAULT CHARSET utf8mb4; USE library; CREATE TABLE books ( id INT PRIMARY KEY AUTO_INCREMENT, book_name VARCHAR(100) NOT NULL, author VARCHAR(50) NOT NULL, isbn VARCHAR(20) UNIQUE NOT NULL, category VARCHAR(30), stock INT NOT NULL DEFAULT 1 ); CREATE TABLE readers ( id INT PRIMARY KEY AUTO_INCREMENT, reader_name VARCHAR(50) NOT NULL, role VARCHAR(20) NOT NULL ); CREATE TABLE borrow_records ( id INT PRIMARY KEY AUTO_INCREMENT, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_time DATETIME NOT NULL, return_time DATETIME, KEY idx_book_id (book_id), KEY idx_reader_id (reader_id) );字段说明:books 表里 ISBN 加上唯一索引,可以防止同一本书重复录入;stock 表示当前可借库存,借出时减 1,归还时加 1。borrow_records 用 book_id 和 reader_id 关联另外两张表,并建立普通索引,因为在查询“某本书被谁借走”“某人借了什么”时会高频用到这两个字段。如果你想把借阅历史保留得更细,可以把 return_time 设为 NOT NULL 并用一个特殊值标记“未归还”,但那样查询语句要多一个条件,课程设计里用 NULL 表示未归还是最直观的。
如果你不想手写这份 SQL,也有代码生成工具能根据实体类反向生成建表语句。不过我建议还是手写一遍,三张表的字段不多,写的时候能把主外键关系想清楚,后面写实体类和 JDBC 映射会顺很多。
4.2 数据访问:一个 JDBC 工具类加一个 DAO
网上很多教程会直接摆出 MyBatis 和 Spring 的依赖,但 SWT 程序通常用不着那么重的容器。界面层是桌面程序,数据层就两层:一个 DbUtil 负责拿连接,一个 BookDao 负责 SQL。这样发布的时候只需要带一个 JDBC 驱动 jar,不需要配置 Spring 的扫描路径和 XML。
public class DbUtil { private static final String URL = "jdbc:mysql://localhost:3306/library?useSSL=false&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }代码逻辑很简单,但有两个参数值得特别注意:URL 里的 characterEncoding=utf8 是中文不乱码的关键,字段里存的是 UTF-8,读取也必须按 UTF-8 解析;useSSL=false 是因为本地开发库不需要加密传输,不加的话新版 MySQL 驱动会打出 SSL 警告。密码直接写在常量里只适合课程设计阶段,如果要交付给他人使用,至少要改成从外部 properties 文件读取,避免别人拿到 jar 反编译就能看到数据库口令。
BookDao 里我用一个 findAll 方法演示核心写法,其他方法按同样模式扩展:
public List<Book> findAll() { List<Book> books = new ArrayList<>(); String sql = "SELECT id, book_name, author, isbn, category, stock FROM books ORDER BY id"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book book = new Book(); book.setId(rs.getInt("id")); book.setBookName(rs.getString("book_name")); book.setAuthor(rs.getString("author")); book.setIsbn(rs.getString("isbn")); book.setCategory(rs.getString("category")); book.setStock(rs.getInt("stock")); books.add(book); } } catch (SQLException e) { e.printStackTrace(); } return books; }逻辑说明:try-with-resources 是必须的写法,Connection、PreparedStatement、ResultSet 三个资源都会自动关闭,否则在 SWT 这种需要长时间运行的桌面程序里,连接会越积越多,最终把数据库连接数打满。如果你用 Teacher 教的那种先关闭 ResultSet 再关闭 Statement 的旧写法也正确,但 try-with-resources 更不容易漏。列名映射这里没有用 ORM,简单直接,也方便你在返回结果后调整字段。
4.3 把查询结果填进表格:刷新方法是全系统的主干线
表格刷新是图书管理系统里被调用最频繁的方法。新增、修改、删除、搜索都要回到同一时刻的表格状态,所以把 refreshTable 做对,后面每个按钮都轻松。
public void refreshTable() { bookTable.removeAll(); List<Book> books = bookDao.findAll(); for (Book book : books) { TableItem item = new TableItem(bookTable, SWT.NONE); item.setText(new String[]{ book.getBookName(), book.getAuthor(), book.getIsbn(), String.valueOf(book.getStock()), book.getCategory() }); item.setData(book); } }逻辑说明:第一步 bookTable.removeAll() 干掉旧行,再查询全量数据逐行 new TableItem。setData(book) 是把整个对象挂在表格行上,这样后面用户点击某一行时,可以直接从 item.getData() 把完整的 Book 对象取出来,不用再按 ISBN 去数据库查一遍。很多初学者会忽略 setData,然后不得不在点击事件里重新拼查询条件,白白多写一段代码。
参数说明:TableItem.setText(String[]) 要求数组元素个数等于表格列数,顺序和列定义一致。如果列数对不上,显示结果会错位,而且不报错,属于特别隐蔽的 bug。建议把列定义数组和这里的字符串数组都抽成同一个常量,比如定义一个COLUMNS数组,两处都引用它。
这个方法在数据量几千条时直接在主线程里跑也没有明显卡顿,但如果之后数据量上了万,建议把查询部分放子线程,只留下 UI 刷新部分用 asyncExec 回到主线程,具体写法在第 5 章讲。
4.4 选中行回填表单:SelectionListener 的联动写法
点表格某一行,底部表单自动变成这一行的内容。这个交互实现起来很直观:给表格挂上 SelectionListener,在 widgetSelected 里读取选中行的内容并 setText 到各个输入框。
bookTable.addSelectionListener(new SelectionAdapter() { @Override public void widgetSelected(SelectionEvent e) { TableItem item = bookTable.getSelection()[0]; Book book = (Book) item.getData(); nameText.setText(book.getBookName()); authorText.setText(book.getAuthor()); isbnText.setText(book.getIsbn()); categoryText.setText(book.getCategory()); stockText.setText(String.valueOf(book.getStock())); } });逻辑说明:getSelection() 返回的是选中行的数组,单选模式下直接取 [0] 是安全的;如果你开启了多选模式,这里要先判断数组长度再取值,否则空数组越界会抛异常。SelectionAdapter 是适配器类,用它只需要实现自己关心的事件,不用把所有监听方法都写出来。
到这里,从数据库到界面再到用户交互的主链路已经通了:查询刷新表格、点行回填表单、修改后再次刷新。剩下新增和删除两个按钮的逻辑本质是复用同样的写库方法再调用 refreshTable,不再展开写。你在做新增按钮时只要记住一件事:写库操作成功后调用一次 refreshTable,不要自己手动往表格里 add 一行,否则表格数据和数据库会很容易不一致,这也是很多图书管理系统的库存数字越跑越偏的根源。
5. 避坑与排查:SWT 图形化界面最容易翻车的五个实战问题
SWT 用着别扭的地方不在 API,而在系统句柄和线程模型。绝大多数跑到一半崩掉的问题,追根究底都是同一类原因。下面这 5 条是我做 SWT 图书管理系统过程中踩过也帮别人排查过的真实场景。
5.1 跨线程更新界面:Invalid thread access
现象:给查询按钮加了后台线程,线程里查完数据库直接调用 bookTable.removeAll() 或者刷新表格,程序立刻抛 SWTException: Invalid thread access。
原因:SWT 的控件不是线程安全的,所有对 Widget 的操作必须在创建它的 UI 线程里进行。后台线程做完耗时查询后直接碰控件,SWT 会检查当前线程和控件所属线程是否一致,不一致就拒绝执行。这个设计比 Swing 更严格,Swing 很多控件还允许在 EDT 外操作,SWT 是明确禁止的。
解决:后台线程只负责查询和计算,需要更新界面时通过 Display.getDefault().asyncExec() 把更新操作丢回 UI 线程队列。
new Thread(() -> { List<Book> books = bookDao.findAll(); Display.getDefault().asyncExec(() -> { refreshTable(books); }); }).start();说明:asyncExec 是非阻塞投递,适合刷新表格这种不要求同步结果的场景。如果你需要等待结果再继续执行,可以用 syncExec,但 syncExec 在 UI 线程里调用会直接死锁,所以默认请用 asyncExec。线程模型这块没有绕路可走,任何企图绕过检查的写法都会在 Windows 上换来随机崩溃。另外注意,这里线程里用到的 bookDao 如果也同时在主线程里被调用,要做好同步或各线程持有独立 DAO,否则连接池会报并发冲突。
5.2 越用越卡最后白屏:根因是 GDI 句柄泄漏
现象:程序刚开始很流畅,运行几个小时后窗口变卡,表格区域出现黑块,最后整个窗口白屏,重启又恢复正常。用任务管理器看进程的 GDI 对象数量,一路涨到一万多。
原因:SWT 里创建的 Image、Color、Font、GC 以及 Cursor 都是操作系统句柄资源。很多人在表格里给每一行创建新的 Image 图标,循环结束后既没有调用 image.dispose(),也没有复用同一个 Image 对象,句柄就只增不减。Windows 默认 GDI 对象上限约一万个,突破后界面绘制直接失效。
解决:能复用的资源只创建一次。比如表格行图标在类的成员变量里创建,shell 关闭时统一释放;局部创建的 Color、Font,用完立刻 dispose,写在 finally 里也行。每次看到一个new Image(display, ...),先问自己这个对象会不会被释放。
注意:SWT 的 dispose 机制里,对象不是自动垃圾回收的。“靠 GC 回收句柄”这种想法在 SWT 项目里行不通,必须显式调用 dispose。这一点和 Java 堆内存完全是两套逻辑。
如果你遇到 OutOfMemoryError,第一反应是调大 IDEA 的编译堆设置,但 SWT 程序跑几个小时后出现的这类问题,先查 GDI 对象数量再调堆参数,方向才对。IDEA 里那个“Build Process Heap Size”管的是编译器,管不了你程序的句柄泄漏。
5.3 布局失效:GridLayout 下按钮挤成一团
现象:明明给按钮设置了 widthHint 或 minimumWidth,窗口一打开按钮还是缩成默认大小;外层容器 resize 后,内部固定宽度的输入框又变形了。
原因:GridData 的 widthHint 只在控件获得首选尺寸时参与计算,如果同一个控件被赋了两次 GridData,或者你在布局之后又调用了 setLayoutData 覆盖,之前设置的宽度约束就会失效。更隐蔽的情况是,外层布局设置了 grabExcessHorizontalSpace=true,内层控件虽然写了宽高,但分配到的空间不是按 widthHint 而是按剩余空间均分。
解决:每个控件的 GridData 只生成一次,并保持引用;需要固定宽度时,同时设置 widthHint 和 minimumWidth 两个值。另外布局写完后不要用 setBounds 去微调,一旦 setBounds 和 GridLayout 同时存在,SWT 的布局系统会强制覆盖 setBounds 的结果,出现“明明改了坐标却不动”的诡异现象。如果你要的是“某些列宽固定、其余列拉伸”的效果,正确做法是给不需要拉伸的列 setLayoutData 设一个单独的 GridData(false, false, false, false),并在那一列控件上设 widthHint。
5.4 高分屏发虚:4K 显示器下字体模糊
现象:在 4K 屏幕、系统缩放 200% 的情况下,SWT 程序界面的字体和图标看起来像是从低分辨率拉伸上来的,文字边缘发虚,而原生记事本应用是清晰的。
原因:SWT 渲染控件时依赖 Windows 的 DPI 感知级别。老版本 SWT 项目默认不做 DPI Aware 声明,Windows 会把它当作不具备缩放感知能力的程序,采用位图拉伸方式放大,视觉上自然模糊。
解决:在 main 方法里最早的位置调用系统 API 触发 DPI 感知。如果用的是 JDK 9 以上和较新 SWT,可以直接在 jar 所在目录放一个 manifest 文件声明 PerMonitorV2;更通用的写法是用 JNA 或者反射调用 SetProcessDPIAware。注意这个调用必须发生在任何窗口创建之前,否则不生效。如果你是在虚拟机里跑 Linux 桌面,这个问题还会以另一种形式出现——SWT 的 GTK 版本和系统 GTK 版本不匹配,字体渲染直接崩掉,那种情况一般要先升级系统 GTK 依赖。
5.5 启动即崩溃:Can't load library swt-win32-xxxx.dll
现象:双击启动脚本后,控制台立刻输出 java.lang.UnsatisfiedLinkError: Can't load library: swt-win32-XXXX.dll,窗口都没出现就退出了。
原因:SWT 的 jar 里不只有 Java 类文件,真正的原生代码是 jar 内的 dll 或 so。JVM 启动时通过 jar 包里的 native 目录查找动态库,如果 jar 没有正确出现在 classpath 里、或者被某些打包工具压扁成普通文件,native 库找不到就会抛这个异常。最常见的两个场景是:把 32 位 SWT jar 用在 64 位 JVM 上,以及把 SWT jar 打进 fat jar 后没有正确处理 native 解压顺序。
解决:第一,确认 SWT jar 的文件名和 JVM 位数匹配,win32.win32.x86_64 配 64 位 JVM。第二,不要用 One-Jar 这类工具强行把 swt jar 合并进一个 jar,正确的做法是 SWT jar 单独放在 lib 目录,用 Class-Path 引用或写启动脚本指定 -cp。打包细节在下一章展开,这里先记住:SWT 项目里的 native 库和普通 jar 不是一个处理思路。
6. 把系统交出去:打包成可双击运行的程序与表格性能小优化
系统做好了,不能只在 IDEA 里能运行。SWT 项目的打包和普通 Spring Boot 项目思路完全不同,最后单独拿出来说。
6.1 打包策略:保留原生包,用 -cp 指定运行
很多同学第一次打包 SWT 程序都会踩 5.5 的坑。正确做法是打一个“普通可运行 jar”,界面代码打进 jar,SWT 的原生 jar 放在外面的 lib 目录,再用一个 bat 或 sh 启动。
java -cp "library-system.jar;lib/*" com.example.MainClass说明:Windows 用分号分隔 classpath,Linux/macOS 用冒号。lib/*会让 JVM 加载 lib 下面所有 jar,SWT 的原生 dll 还在它自己的 jar 里,由 JNI 在运行时自动释放到临时目录再加载。这套方式最稳定,也最容易让接手的人看懂。如果你偏要在 IDE 里点那个绿色三角形运行,那当然没问题,但交付给别人的时候一定要回到命令行脚本,否则对方机器上没有你的 IDEA 配置,ClassPath 就丢了。
如果非要做一个双击就能跑的 exe,常见做法是用 jpackage 或 exe4j 这类工具,把 JRE 和程序一起捆绑,但要注意 SWT jar 仍然是以外部依赖形式存在,不要合并为一个 fat jar。合并后不是不能运行,而是会出现偶发的 dll 释放失败问题,不值得去赌。
6.2 表格虚拟化:数据量大时的 SWT.VIRTUAL 模式
如果图书量上万,每次刷新全量 removeAll 再重插会有些卡顿。SWT 提供了虚拟表格模式,只创建可见行对应的 TableItem,配合 SetData 监听器按需填充数据。
bookTable.setItemCount(books.size()); bookTable.addListener(SWT.SetData, event -> { TableItem item = (TableItem) event.item; int index = bookTable.indexOf(item); Book book = books.get(index); item.setText(new String[]{book.getBookName(), book.getAuthor()}); });参数说明:setItemCount 告诉表格一共有多少行,SWT 内部只为可见行创建 item;用户滚动时才对新的 item 触发 SetData 事件。这个模式对内存和创建耗时都很友好,但代价是不能直接用 TableItem 自带的方法到处遍历行,需要在外面维护 books 列表。图书管理这种几千条的场景其实用不上虚拟表格,但如果你的系统以后升级成几万条数据的期刊管理,这个切换成本很低,值得现在就把刷新逻辑独立出来,不要和具体 TableItem 绑定得太死。
6.3 交付前自查清单
给读者一张交付前的验证清单:
- 换一台没装 JDK 的机器,用捆绑的 JRE 或已安装的 JRE 双击启动一次,验证启动脚本里的 classpath 写法没问题。
- 对抗性测试:连续新增 20 本书、删除 20 本书,再打开任务管理器的 GDI 对象数量,确认不持续增长。
- 高分屏缩放 150% 下跑一轮完整流程,确认字体不虚、布局不挤。
- 把数据库密码从代码里挪到外部配置文件,并在配置里处理连接断开后的重连。
- 用至少 1000 条真实书目数据做一次全量刷新和按 ISBN 搜索,记录响应时间,给后面优化留基线。
这几条做完,系统就可以大大方方交给使用的人了。我经历过的最典型的教训是:界面写完就着急打包,结果在别人机器上双击没反应,一查是 classpath 漏了 SWT 原生 jar。自那以后我每次交付前都会先跑一遍换机测试,把“能跑”和“能交”之间的差距补齐。希望这篇笔记能帮你少踩几个我已经踩过的坑,做出一个界面像样、运行稳定、真正能落地的 SWT 版图书管理系统。
本文还有配套的精品资源,点击获取