我之前带过几批刚接触 Java GUI 的同学做 SWT 的阶段练习,最常见的画面是:窗口在开发机上跑得又快又稳,打包发给别人,双击之后只弹出一行 UnsatisfiedLinkError;或者界面能开,点几下就白屏、卡死,控制台刷出一串 Widget is disposed。SWT 这套东西和大多数人熟悉的 Swing、JavaFX 差别不在于“控件名字叫什么”,而在于它一开始就把自己绑在了操作系统的原生控件上,于是生命周期、线程、平台依赖这些问题会比别的 GUI 框架更早、更硬地砸到你脸上。这篇东西就是我给的阶段练习复盘:用一个能真正跑起来的小工具,把 SWT 的 Display/Shell/Widget 心智模型、swt.jar 的平台差异、GridLayout 的布局逻辑、TableViewer 的数据驱动、资源回收和打包分发这一条链走完,适合刚学完 SWT 基础控件、正打算做第一个完整练习的人,也适合写惯了 Swing、想换到原生控件方案的人拿来对照。
1. 阶段练习别一上来就堆控件:先把 SWT 的心智模型立住
1.1 原生控件不是一句宣传语,它决定了你后面所有的坑
SWT 的每一个控件背后都是一次对操作系统原生窗口组件的调用,这一点不是概念差异,而是会直接影响你写代码方式的事实。Swing 是纯 Java 自绘,你自己管一套轻量组件树,界面长什么样由 Java 决定;SWT 则通过一层本地绑定去创建真正的系统控件,按钮是系统的按钮,表格是系统的表格。带来的好处很直观:界面观感、输入法、系统主题适配、无障碍支持这些几乎免费拿到,启动速度也漂亮。但代价同样直接:每个控件都会持有操作系统资源,你必须显式释放;控件只能由创建它的那个线程碰;不同平台的实现库不一样,你的 jar 包天然带平台属性。
我见过太多同学在练习阶段把 SWT 当成“换了个包名的 Swing”来写,结果就是三类问题反复出现。第一类是把界面元素塞进成员变量到处传,窗口关了资源没释放,任务管理器里进程挂着一堆句柄;第二类是在按钮回调里直接开线程改表格内容,一跑就抛线程访问异常;第三类是在这台机器上编译完直接把工程目录压缩发给别人,对方一运行就报本地库加载失败。这三类问题的根都不在具体 API 上,而在心智模型上——你得先接受“SWT 的界面对象是有寿命、有归属、有平台属性的实体”,后面的代码才写得对。
所以阶段练习的第一目标不是“画出一个好看的界面”,而是把这三个约束练成肌肉记忆:谁创建、谁释放、谁在哪个线程上碰它。界面美不美观在这个阶段真不重要,我甚至建议前面两三天就故意用最朴素的默认外观,把所有注意力放在结构上。
1.2 练习选题的判定标准:小、闭环、有真实数据
选题这件事比很多人想的更影响练习效果。太大,比如一上来就做“带数据库的多用户管理系统”,你会把时间全花在 JDBC、连接池、权限模型上,SWT 本身反而练不到;太小,比如“点一下按钮弹个对话框”,你又碰不到表格、布局、线程、资源回收这些真正有价值的部分。
我一般给三条判定标准。第一,界面里必须同时出现输入控件、列表型控件和状态反馈,这三样凑齐,你才不得不处理布局、数据绑定和异步更新。第二,数据必须有落盘或导出的出口,有了出口你就一定会碰到文件对话框、后台任务、异常处理。第三,功能范围能在一周左右的业余时间里跑通闭环,增删改查、保存加载、搜索过滤,做完能自己天天用一下。符合这三条的题目其实很多:本地待办清单、书签整理器、素材归档表、账单记账小工具、配置文件对照器,都行。
反过来说,我不建议在阶段练习里碰的东西也很明确:多窗口 MDI 那种复杂窗口管理、复杂的自绘图表、需要联网同步的功能、需要内嵌浏览器组件的功能。这些每一块都能单独吃掉你一周时间,而且和 SWT 的核心机制关系不大。
1.3 一个能练到位的题目:本地资料清单工具
我这次拿来做示范的题目是“本地资料清单工具”,说白了一个加强版待办表。它的界面分区很典型:顶部一条工具条放新增、删除、标记完成三个按钮;工具条下面一行搜索框加一个“只看未完成”的勾选框;中间主体是一张四列表格,列分别是标题、优先级、截止日期、状态;表格右侧用一条可拖动的分隔条挂一个详情面板,选中某一行时在详情面板里编辑备注;最底下一条状态栏显示总条数和当前筛选结果数。
数据模型就是一个简单的条目类,字段包括标题、优先级枚举、截止日期、完成标记、备注。持久化我用最土的办法:一份用制表符分隔的文本文件放在用户目录下,程序启动时读、退出时写,另外提供一个“导出为文本”的按钮走文件对话框。为什么不用数据库?因为练习阶段的重点不是数据层,而且纯文本文件方便你直接打开看,出问题时肉眼就能定位,比翻 SQLite 文件高效得多。
这个题目的好处是每一块都能对上 SWT 的一个知识点:工具条对 ToolBar 和图片资源,搜索框对 ModifyListener 和 ViewerFilter,表格对 TableViewer 和 JFace 的提供器体系,详情面板对多行文本框和选择事件,状态栏对标签刷新和异步更新,导出按钮对 FileDialog 和后台线程,关窗时对资源释放。一个题目串起一条链,比做五个互不相干的小 Demo 有效得多。
2. swt.jar 是平台相关的:依赖与运行环境这一步最容易翻车
2.1 为什么不能像别的库那样只放一个 jar
绝大多数 Java 库是纯字节码,一个 jar 走遍天下;SWT 不是。它的主体确实也是 Java 类,但底下那层本地实现是按平台编译的,Windows、Linux、macOS 各有一套,甚至 macOS 还分 x86 和 ARM 两种架构。所以你在依赖里引入的其实不是“SWT”,而是“某个平台上的 SWT”。这个事实在开发阶段容易被掩盖:你在自己机器上加了依赖,跑起来了,就以为成了,直到换机器或者打包才暴露。
我建议在练习一开始就把这件事摆到台面上,做法就是在依赖声明里把平台写清楚,而不是用一个模糊的通用坐标。以 Maven 为例,Windows 64 位下的写法大致是这样:
<dependency> <groupId>org.eclipse.platform</groupId> <artifactId>org.eclipse.swt.win32.win32.x86_64</artifactId> <version>3.124.0</version> </dependency>同样的版本号下,Linux 的 GTK 版本、macOS 的 Cocoa 版本是另外的 artifact 名。版本号这块不用死记,去中央仓库搜一下当前主线的 3.12x 系列,挑一个和你 JDK 匹配的就行,重要的是记住它是平台绑定,不是“随便哪个都行”。
2.2 用 profile 把多平台依赖管起来
如果你只在 Windows 上练习,写死一个依赖完全够用。但只要你有第二台机器(比如一台 Linux 笔记本,或者同事的 Mac),就值得花十分钟把 Maven profile 配好。我的做法是按操作系统自动激活:
<profiles> <profile> <id>swt-windows</id> <activation><os><family>windows</family></os></activation> <dependencies> <dependency> <groupId>org.eclipse.platform</groupId> <artifactId>org.eclipse.swt.win32.win32.x86_64</artifactId> <version>3.124.0</version> </dependency> </dependencies> </profile> <profile> <id>swt-linux</id> <activation><os><family>unix</family><name>Linux</name></os></activation> <dependencies> <dependency> <groupId>org.eclipse.platform</groupId> <artifactId>org.eclipse.swt.gtk.linux.x86_64</artifactId> <version>3.124.0</version> </dependency> </dependencies> </profile> </profiles>这套配置的实际价值不在于“看起来专业”,而在于它会逼你在换环境时立刻发现问题,而不是等到交付前夜。我第一次做这个练习的时候就是因为懒,本地跑通就直接把代码推到共享仓库,另一位同学拉下去编译能过、运行就崩,最后排查半小时才发现是依赖里躺着 Windows 的实现包。
注意:
swt.jar与jface的版本要成对。混用不同版本的 SWT 和 JFace,最常见的表现是编译期正常、运行期在 Viewers 相关类上抛 NoSuchMethodError,这种错误信息指向的位置往往离真正原因很远。
2.3 启动参数和 JDK 版本要提前对齐
macOS 上有个历史遗留问题:SWT 要求在启动时把 UI 相关的工作放在主线程上,如果直接用java -jar或者某些构建工具启动,需要在启动参数里加上-XstartOnFirstThread。这不是什么可选优化,缺了它就是启动即崩或者窗口出不来。练习阶段如果班上有人用 Mac,这一条一定要提前说,能省掉大量“为什么只有我不行”的困惑。
JDK 版本也要看一眼。较新的 SWT 版本普遍要求 Java 11 以上,有些主线版本要求 17。我在练习群里见过因为 IDE 里默认用了一个更老的 JDK,编译能过但运行时在模块访问上出问题的情况。最简单的核对办法是:先写一个只有主窗口的空壳,跑通再说业务代码,把环境问题和代码问题彻底分开。
3. 三段式骨架:Display、事件循环、资源回收
3.1 main 方法的标准骨架,一次写对省很多事
SWT 程序的主入口有一套几乎固定的三段式结构:创建 Display、装配并打开 Shell、跑事件循环直到窗口销毁。我要求所有同学的第一个练习文件就按这个骨架写,别省步骤:
public class TodoApp { public static void main(String[] args) { Display display = new Display(); try { Shell shell = new Shell(display); shell.setText("本地资料清单"); new MainPanel(shell); // 界面装配交给独立类 shell.setSize(960, 620); shell.open(); while (!shell.isDisposed()) { if (!display.readAndDispatch()) { display.sleep(); } } } finally { display.dispose(); } } }这段代码里有两个容易被省略掉的细节。一个是try/finally,display.dispose()必须无条件执行,否则内部的系统资源会挂着,反复启动调试的时候尤其明显。另一个是把界面装配放到独立类里,主入口只留骨架。我强烈建议这么做,因为练习做下去你会不断往界面上加东西,全塞在 main 方法里,三天之后你自己都不想看那个文件。
readAndDispatch和sleep这两句的作用值得说清楚。readAndDispatch会从系统事件队列里取一个事件并派发出去,返回 true 表示还处理了事件;返回 false 表示当前没事件了,于是调用sleep让线程挂起等待,直到有新事件进来。如果把它写成while (!display.readAndDispatch()) {}这种忙等形式,CPU 会直接飙到一个核满载,风扇立刻响,这是新手练习里非常常见的一个“性能问题”,实际上只是事件循环写错了。
3.2 UI 线程只有一条,跨线程改界面必须绕回来
SWT 对线程的限制是硬性的:控件只能在创建它的线程上被访问和修改,那条线程就是 UI 线程,也就是跑事件循环的线程。任何回调——按钮点击、表格选中、文本修改——本身都已经在 UI 线程上了,所以你在回调里改控件是安全的。真正的问题出在你开了后台线程之后。
练习项目里的第一个真实需求就会出现:导出文件、加载大文本、批量重命名,这些操作要花时间,如果放在 UI 线程上,界面会立刻假死,用户点哪都没反应。正确做法是把耗时逻辑放后台线程,算完之后用display.asyncExec把界面更新投递回 UI 线程:
button.addSelectionListener(SelectionListener.widgetSelectedAdapter(e -> { statusLabel.setText("正在导出..."); new Thread(() -> { String message; try { message = Exporter.export(todos); } catch (Exception ex) { message = "导出失败:" + ex.getMessage(); } String finalMessage = message; if (!display.isDisposed()) { display.asyncExec(() -> statusLabel.setText(finalMessage)); } }, "export-worker").start(); }));这段代码有三个点值得注意。后台线程里绝对不能碰任何控件,哪怕只是读一个文本框的值,也应该在启动线程之前先读出来传进去。捕获异常要在后台线程里做完,别让异常一路抛上去,后台线程的异常不会自动显示到界面上,只会悄悄打印到控制台,用户看到的就是“点了没反应”。最后那个isDisposed判断不是多余的,如果用户在导出过程中直接把窗口关了,asyncExec投递的任务会在一个已经销毁的 Display 上执行,直接抛异常。
还有一个更隐蔽的坑:我给每个线程都起了名字(上面那个"export-worker")。这个习惯是被逼出来的,线上排查线程问题时,线程转储里全是Thread-7、Thread-8这种名字,你根本分不清哪个是自己开的。练习阶段就养成习惯,成本为零。
3.3 资源回收挂在关闭事件上,别散落各处
SWT 里 Color、Font、Image、Cursor 这些都属于必须手动释放的资源。它们不是普通对象,底下握着系统句柄,不释放就会泄漏。麻烦在于它们的寿命通常比创建它们的方法长:你在装配界面时创建了一个颜色给标签用,这个颜色对象必须活到标签销毁为止,所以不能像普通对象那样用完就扔。
我推荐的做法是在装配类里维护一个资源池,创建资源时统一登记,窗口关闭时一次性释放:
public class ResourcePool { private final List<Resource> items = new ArrayList<>(); public Color color(Device device, int r, int g, int b) { Color c = new Color(device, r, g, b); items.add(c); return c; } public Font font(Device device, FontData data) { Font f = new Font(device, data); items.add(f); return f; } public void disposeAll() { for (Resource r : items) { if (r != null && !r.isDisposed()) { r.dispose(); } } items.clear(); } }然后在装配入口挂一句:
ResourcePool pool = new ResourcePool(); shell.addDisposeListener(e -> pool.disposeAll());这样做的好处是回收逻辑只有一个出口,你能一眼看出程序里创建了哪些长生命周期的资源。我自己的经验是,练习项目做到第三四天,界面上的颜色字体图标会越来越多,如果每个地方都单独new Color而不管释放,一次调试运行能泄漏几十个句柄,跑久了界面开始出奇怪的绘制问题,你还找不到原因。
提示:SWT 生态里有个专门追踪资源创建与释放的小工具,原理是包一层设备类来记录每次 new 和 dispose。练习阶段可以用它做一次体检,看看到底有多少资源没被释放,比自己肉眼翻代码有效得多。
4. 布局才是 SWT 的分水岭:GridLayout 与 GridData 的配合
4.1 GridLayout 的列数怎么定
SWT 的布局管理器和 Swing 的思路接近,但用起来的感受差别很大:Swing 的布局管理器通常能“自动”给你一个还算能看的排布,SWT 的布局你要是没给每个控件配上布局数据,它们会全部挤在左上角,看起来像界面写坏了。这不是 bug,是 GridLayout 的设计:它按列排布,每个控件占一个格子,控件具体怎么填、占几格、能不能拉伸,全部由 GridData 决定。
列数怎么定?我的经验是先画分区,再数每行最多几个控件。上面那个清单工具,顶部工具条是一行三个按钮,可以单独用一个布局为GridLayout(3, true)的 Composite;中间搜索区是一行“搜索框 + 勾选框”,用GridLayout(2, false);主体是表格加详情面板,用GridLayout(2, false)但要让两列按比例分空间。分开用多个 Composite 各自布局,比在一个大布局里用跨列硬拼要清晰得多,也更好改。
只有一件事要小心:GridLayout 的makeColumnsEqualWidth参数。设成 true 会让所有列等宽,这在工具条那种按钮等宽的场景很合适,但在“搜索框 + 小勾选框”这种场景就会很难看,勾选框会被拉得很宽。这个参数新手特别容易顺手写 true,然后抱怨控件显示得怪。
4.2 GridData 每个字段到底在干什么
GridData 的字段不少,但练习里真正高频的就那么几个,我把它们整理成一张表,方便你直接对着抄:
| 字段 | 作用 | 练习中的典型取值 |
|---|---|---|
widthHint/heightHint | 给控件一个初始宽高建议值 | 搜索框 220,按钮 90 |
horizontalSpan/verticalSpan | 横向或纵向跨几个格子 | 状态栏跨 2 列 |
horizontalAlignment/verticalAlignment | 控件在自己的格子内侧怎么对齐 | SWT.FILL、SWT.CENTER |
grabExcessHorizontalSpace | 窗口变宽时是否吃掉多余空间 | 表格 true,按钮 false |
horizontalIndent | 左缩进像素,用于视觉层级 | 详情区内的小标签缩进 8 |
minimumWidth/minimumHeight | 拉伸时的下限 | 详情面板 260 |
这里最容易搞混的是widthHint和grabExcessHorizontalSpace的关系。前者只是建议初始宽度,后者决定窗口拉伸时谁变宽。我练习时的做法是:凡是希望跟着窗口一起变宽的控件,grabExcessHorizontalSpace一定设 true,并且horizontalAlignment设SWT.FILL;固定用途的按钮一律 false。搜索结果表格和详情面板都要跟着变宽,所以两个都设 true,但给详情面板加一个minimumWidth,避免窗口缩小时它被压成一条线。
还有一个非常实用的细节:GridData不必每个字段都显式写。常用的组合可以封装成一个工具方法,比如fillHorizontal(boolean grab)、fixed(int width),练习代码里会干净很多。写到后面你会发现,界面结构的复杂度大半耗在布局数据上,能省一点是一点。
4.3 嵌套 Composite 与可拖动分隔条
主体区“表格 + 详情面板”这种左右结构,很多人第一反应是用SashForm。它确实好用,直接给你一条可以拖的分隔条,用户能自己调整两边的宽度。但要注意它内部用的也是权重机制,setWeights传的是相对比例,两边的控件仍然要各自的布局数据配合,否则拖起来会出现一边铺满一边挤扁的情况。
我建议的装配顺序是这样的:先在 Shell 上建一个纵向的 GridLayout,放工具条 Composite、搜索 Composite、主体 SashForm、状态栏标签;每个 Composite 自己再定布局。一层一层往下拆,每层只关心自己这一行怎么排。这种写法在后期加功能时特别友好——想加一行筛选条件,只在搜索区那个 Composite 里加控件、把列数改一下就行,不会牵动整个界面。
4.4 窗口缩放自测清单
布局写完一定要做的事是拖窗口边缘,从最小拖到最大,再拖回来。我在练习评审时就看这一个动作,能看出八成问题。常见的失败表现有:控件重叠、按钮被压成一条缝、表格下半部分被裁掉、状态栏跑到屏幕外、详情面板宽到把表格挤没。
这些现象背后的原因基本都能在布局数据里找到:某个控件忘了设 grab 导致窗口变大时其他控件把空间吃光了;容器没设布局导致子控件挤在左上;表格所在的父容器没有垂直方向的 grab,导致表格拿不到剩余高度。我给自己的检查清单是四句话:窗口最大化时是否所有元素都在,窗口最小化尺寸时是否还有能用的最小宽度,中间拖动过程中有没有出现闪烁或重叠,表格这类滚动型控件的高度是否会跟着窗口变。
5. 用 TableViewer 把界面变成数据驱动
5.1 裸 Table 能跑,但会把你拖进手工同步的泥潭
SWT 的Table控件本身完全够用,你可以直接new TableItem、setText,做增删改。练习前几天我也建议先这么写一版,因为你需要亲手感受一次“数据改了但界面没跟上”的痛苦,才知道 Viewer 到底解决了什么。
痛苦具体是这样的:你有一个 List 存数据,界面上有一个 Table 显示数据,每次新增一条,你要同时改 List 和 Table;每次删除,你要算出被删的那行在 Table 里的索引再删;每次修改字段,你要找到对应的 TableItem 再 setText。三处逻辑分散,任何一处漏掉就是数据和界面不一致,而且这种 bug 非常难复现,因为界面上看起来“就是少了一条”,你不知道是数据没了还是显示没了。
TableViewer 的价值就在这:你只管把数据源交给它,界面怎么显示由提供器决定,你改完数据调一次刷新,界面自动对齐。练习项目里我要求表格一律用 Viewer,不是为了显得高级,而是为了让练习重点落在“数据与视图的关系”上,而不是“怎么手工同步两套状态”。
5.2 三件套:内容提供器、标签提供器、输入
一个最小的表格需要三样东西凑齐才能出效果,缺一样就是空表。内容提供器负责回答“这一层有哪些元素”,标签提供器负责回答“某个元素在第几列显示什么文字”,输入负责把数据源交给 Viewer。
TableViewer viewer = new TableViewer(parent, SWT.BORDER | SWT.FULL_SELECTION | SWT.MULTI); Table table = viewer.getTable(); table.setHeaderVisible(true); table.setLinesVisible(true); String[] titles = { "标题", "优先级", "截止日期", "状态" }; int[] widths = { 260, 80, 120, 80 }; for (int i = 0; i < titles.length; i++) { TableViewerColumn col = new TableViewerColumn(viewer, SWT.NONE); col.getColumn().setText(titles[i]); col.getColumn().setWidth(widths[i]); col.getColumn().setAlignment(i == 1 ? SWT.CENTER : SWT.LEFT); } viewer.setContentProvider(ArrayContentProvider.getInstance()); viewer.setLabelProvider(new TableLabelProvider()); viewer.setInput(items.toArray());TableLabelProvider是我自己实现的一个类,实现ITableLabelProvider接口,在getColumnText里按列索引返回对应字段。这种写法比每一列单独挂一个ColumnLabelProvider稍微集中一点,练习阶段我更推荐后者,因为一列一个匿名类更直观,不容易把列索引对应错:
TableViewerColumn titleCol = new TableViewerColumn(viewer, SWT.NONE); titleCol.getColumn().setText("标题"); titleCol.setLabelProvider(new ColumnLabelProvider() { @Override public String getText(Object element) { return ((Todo) element).getTitle(); } });新手最容易踩的一个坑是:setInput传的必须和内容提供器匹配。用ArrayContentProvider就要传数组,传个 List 进去,界面一片空白,也不报错,你盯着代码看半天找不到问题。这个错误我自己也犯过,后来养成的习惯是——只要表格没内容,先看输入类型。
5.3 刷新与过滤:两个高频误区
第一个误区是 setInput 居然不刷新。Viewer 在设置输入时会比较新旧输入是否相等,相等就直接返回,什么都不做。所以如果你的数据在同一个 List 上原地修改,然后把同一个引用再 setInput 一次,界面不会有任何变化。解决办法有两个:一是每次都传新数组(list.toArray()每次都是新对象,所以一定触发刷新),二是数据改完直接调viewer.refresh()或refresh(element)。我在练习里统一用后者,语义更明确。
第二个误区是过滤条件叠加。viewer.addFilter(...)是往列表里加,不是替换。你在搜索框的修改事件里每次都 add 一个匿名过滤器,输入十个字就加了十次,所有过滤条件同时生效,结果就是越搜越空。正确写法是把过滤器保存成一个字段,每次搜索时先判断是否已经加过,或者干脆先remove再add:
private ViewerFilter keywordFilter; private void applyFilter(String keyword) { if (keywordFilter != null) { viewer.removeFilter(keywordFilter); } final String kw = keyword.trim().toLowerCase(); keywordFilter = new ViewerFilter() { @Override public boolean select(Viewer v, Object parent, Object element) { if (kw.isEmpty()) return true; Todo t = (Todo) element; return t.getTitle().toLowerCase().contains(kw) || t.getNote().toLowerCase().contains(kw); } }; viewer.addFilter(keywordFilter); viewer.refresh(); }顺带一提,数据量上到几千行之后,建议打开viewer.setUseHashlookup(true),它让 Viewer 内部用哈希表定位元素,刷新单条和选择定位会快很多。练习数据小的时候感觉不到,等你导入一个几千行的文件就明显了。
5.4 列宽、排序与选中项取值
列宽这件事有个小经验:把设置列宽的代码放在setInput之后执行一次。有些情况下表格在填充数据后会把列宽重新按内容估算,导致你在前面设的宽度被覆盖,表现是“我明明设了 260,跑起来却是 100 多”。我自己遇到过两次,之后一律在数据加载完再设一遍。
排序用不上自己写比较器循环,Viewer 提供了setComparator,配一个按列切换的监听就能实现点击表头排序。练习阶段做个“按截止日期升序”就够了,重点是理解排序是作用在 Viewer 展示层上的,你的原始 List 顺序不变,这对后面“导出时按什么顺序写出”是有影响的,别搞混。
选中项取值也要注意:
IStructuredSelection sel = viewer.getStructuredSelection(); Todo current = (Todo) sel.getFirstElement(); if (current == null) return; // 空表或未选中必须判空表格为空或者刚刷新完还没选中任何行时,getFirstElement()返回 null,直接强转就会空指针。这个判断在详情面板的刷新逻辑里尤其重要,我练习时的做法是详情面板整体做一个“无选中则禁用”的处理,比到处判空干净。
6. 练习中最容易翻车的五类报错与排查链路
6.1 Invalid thread access:错误信息很明白,原因往往很绕
这个异常的提示是Invalid thread access,字面意思就是线程不对,但新手往往觉得自己明明没开线程。我的排查链路是这样的:先看异常栈里报错的那一行,确认是哪个控件被碰了;然后往上找,看这个调用链是不是从某个回调开始的——回调本身一定在 UI 线程上,所以一定是途中经过了什么异步机制;最后去找代码里所有的new Thread、定时器、后台任务提交点。
有几类隐蔽来源。一类是定时刷新,用后台线程每隔几秒读一次文件再更新标签,忘了绕回 UI 线程。一类是第三方库的回调,某些 IO 或网络库的回调线程不是 UI 线程,在回调里直接改界面就炸。还有一类最阴——在构造函数里就启动了一个线程去初始化数据,界面还没建完线程就开始改控件了。我自己的规矩是:所有后台任务只做纯计算和 IO,产出结果对象,绝不接收控件引用。这条守住了,这类问题基本绝迹。
6.2 Widget is disposed:窗口关了,活儿还在跑
窗口已经关掉,但某个后台任务或者延迟执行的任务还在跑,跑完去更新控件,就会抛Widget is disposed。它和上面那个异常的区别是:一个是线程不对,一个是对象已经不存在了。
排查时先看报错的是哪个控件,再看它属于哪个 Shell,然后去找有没有在窗口关闭后仍然会执行的任务。常见的来源有三个:后台线程跑完asyncExec回来时窗口已关;用display.timerExec设置的延时任务没有在窗口关闭时取消;事件监听器里做了耗时操作,用户在操作过程中把窗口关了。
处理办法很直接:所有投递回 UI 线程的任务,执行体第一行判display.isDisposed()和控件isDisposed();用timerExec的地方,把返回的任务 id 存起来,在 Shell 的销毁监听里调display.timerExec(id, null)取消。练习阶段加上这几行,能省掉大量莫名其妙的崩溃。
6.3 资源泄漏:不报错,但是会越来越慢
这是最不容易被察觉的一类问题,因为它不抛异常。表现是:程序开一会之后开始变慢,界面切换时有残影,反复打开关闭详情窗口后内存涨上去不回落。根因就是 Color、Font、Image 这类资源没释放。
我的排查办法分两层。第一层是代码审查,把所有new Color、new Font、new Image搜出来,看每一个是否登记进了资源池或者在dispose监听里释放。第二层是运行时观察,用系统自带的资源监视工具看进程的句柄数,反复执行“打开详情窗口再关闭”这个动作二十次,如果句柄数一直往上走,那就是有泄漏。
常见的泄漏点很集中:在绘制回调里new Color(每帧新建一个颜色对象,这是最经典的泄漏写法);在表格的标签提供器里每次getText都创建一个 Font;图片资源只创建不释放,尤其是工具条按钮上的图标。
// 错误写法:每次绘制都新建 public void paintControl(PaintEvent e) { e.gc.setForeground(new Color(display, 200, 40, 40)); } // 正确写法:资源池里取,生命周期跟随窗口 Color warnColor = pool.color(display, 200, 40, 40); public void paintControl(PaintEvent e) { e.gc.setForeground(warnColor); }6.4 中文显示与高 DPI 缩放:在别人机器上才暴露
中文的问题一般出在字体上。SWT 默认会用系统字体,多数情况下中文没问题,但如果你手动给某个控件设了字体,而那个字体在目标机器上没有中文字形,就会显示成方块。我练习时的做法是不手动设字体族,只调整字号和加粗,字号通过FontData基于系统默认字体改,这样跨机器基本不会出问题。
高 DPI 是另一回事。较新的 SWT 版本在 Windows 上已经能自动处理缩放,但你自己塞进去的图片不会自动变清晰。表现是:在高分屏上界面文字很清楚,唯独工具条图标糊成一团。解决办法是给图片提供多倍图,或者用ImageDataProvider按缩放比例动态生成。练习阶段最简单的做法是用矢量风格的图标(或者干脆用文字按钮),避开这个问题,等基础功能跑通再来处理。
还有个小现象值得一提:同一份代码在不同系统上窗口的实际大小不一样。这是原生控件的度量方式差异,不是你算错了。我练手时会给主窗口设一个初始尺寸,然后用shell.setMinimumSize卡一个下限,避免在小屏上开出来就挤成一团。
6.5 打包之后就跑不起来:本地库的问题
开发阶段一切正常,打包之后就崩,多半是本地库没跟着走。SWT 的平台实现包里带着本地库文件,普通的“把所有依赖打进一个 jar”的做法,运行时未必能正确解出本地库,尤其是在某些构建工具的默认配置下。
| 现象 | 可能的根因 | 定位手段 | 处理方向 |
|---|---|---|---|
| 启动即抛本地库加载失败 | 打包时本地库未解出或架构不匹配 | 检查产物里是否含目标平台库文件 | 换用平台专用启动器或安装包工具 |
| 能启动,某个控件创建时报错 | SWT 版本与 JFace 版本不匹配 | 打印两者版本,核对依赖树 | 版本对齐到同一主线 |
| Mac 上窗口出不来 | 缺少主线程启动参数 | 加参数后重试 | 启动脚本里补-XstartOnFirstThread |
| 别人机器上字体错乱 | 手动设了目标机器没有的字体 | 换回系统默认字体族 | 只调字号不调字体名 |
我自己的习惯是:练习项目在第三天就先打一次包,自己拷到另一台机器上跑一遍。早暴露比晚暴露好,等到功能都做完了再处理打包问题,你会分不清是功能 bug 还是环境 bug。
7. 从练习到能给别人用:几个提升完成度的细节
7.1 窗口图标、标题与单实例
这些东西不影响逻辑,但极大影响“这东西像不像个正经程序”。窗口图标一行代码的事,注意图标文件要跟着资源目录走,不要写绝对路径,打包之后绝对路径必然失效。标题里带个版本号,调试的时候一眼能看出对方跑的是哪一版,这个习惯在给别人发测试版时特别有用。
单实例这块,练习阶段可以用一个简单办法:启动时在用户目录下尝试创建一个锁文件,创建失败就说明已经有一个在跑,直接退出并给个提示。更严谨的做法是让已经运行的实例把窗口提到前台,但那需要进程间通信,练习阶段不必做到那一步。我自己第一次做这个功能时踩的坑是:程序异常退出后锁文件没删,下次启动直接起不来。所以锁文件要么在启动时判断持有进程是否还活着,要么加一个“强制启动”的启动参数。
7.2 配置持久化别用硬编码路径
窗口大小、上次打开的目录、列宽、筛选状态,这些“用户偏好”都值得存下来,下次打开还是原样,用起来的感觉完全不同。存哪里?不要存在程序目录,那个目录可能没有写权限。用系统提供的用户目录接口拿到当前用户的主目录,在里面建一个以程序名命名的隐藏目录,配置文件放里面。
存什么格式?练习阶段没必要上复杂的配置框架,一个简单的键值文件就够。要注意的是读写都要包异常处理,配置文件损坏或者手改坏了不能让程序起不来,最差也要能回退到默认值继续运行。我练习时写过一个小工具类,读配置时任何异常都返回默认值,写配置时异常只打印日志不中断,这个“宽容”策略在实际使用中救过我好几次。
窗口位置恢复也有个细节:多显示器环境下,上次关闭时窗口在第二块屏幕上,这次只插了一块屏幕,恢复位置后窗口就在可见区域之外了。稳妥的做法是恢复前先判断保存的坐标是否落在当前任何一块屏幕的范围内,不在就用默认位置居中。
7.3 让练习项目真正被用起来
这一步听起来虚,但它是阶段练习里最有价值的一环。我给自己定的规矩是:做完的练习工具必须连续用一周,每天用,把用起来别扭的地方记下来。你会发现真正的问题往往和你写代码时以为的问题完全不一样——比如我原以为表格列宽是最重要的,实际用起来最烦的是“新增一条之后光标没落在标题输入框里”;我以为导出功能很关键,实际一周只用了一次,反倒是“按截止日期排序”天天用。
这一周的记录,比再多写三个 Demo 都值钱。因为界面的可用性问题只有真的用才会暴露,而这类问题恰恰是 SWT 这种原生控件框架最能发挥优势的地方——系统的输入法、右键菜单、快捷键习惯都是现成的,你只要把交互流程理顺,用户上手几乎零成本。
最后分享一个我自己一直在用的小技巧:练习项目的代码里,凡是“看起来能省一行”的地方我都留着不省,比如显式写try/finally、显式判空、显式给线程起名字。这些写法在业务代码里可能显得啰嗦,但在 SWT 这种和系统资源打交道的场景里,它们就是你的安全网。等这套骨架写到不用想就能写对的时候,你再去做真正的桌面工具,会发现难点已经只剩下业务本身了。