1. 写在前面:为什么 2025 年还在聊 Swing
先说个现状,很多 Java 开发者一听到 “Swing” 就皱眉,觉得这东西老、丑、过时。但实际上,Swing 依然是 JVM 生态里最成熟、最稳定、资料最全的桌面 GUI 方案之一。不少企业内部的运维工具、数据看板、客户端管理系统,跑在生产环境里的界面就是 Swing 写的。你去看一些银行、制造业、物流行业的机房,那些屏幕上的老系统,翻开源码十有八九是 Swing。
所以这篇文章不是带着大家重新翻一遍 JButton、JLabel 的 API 文档,而是从一个实战角度,把 Swing 真正用“现代化”的思路重构一遍。我会结合自己做过的一个桌面客户端项目,从界面布局、主题美化、异步任务、打包分发这几个维度,把踩过的坑、验证过好用的方案、以及背后“为什么要这么做”的逻辑全部摊开讲。
适合看这篇文章的人,主要是三类:一是刚学完 Java 基础、想找个真实项目练手的同学;二是工作中突然接到 Swing 维护或改造任务、急需系统补课的开发者;三是想评估“Swing 到底能不能做出现代化界面”的技术决策者。
需要提前说明的是,这篇文章不涉及任何第三方付费组件,全程基于原生 JDK 自带的 Swing 库,配合少量开源辅助库。只要你本机装了 JDK 8 或更高版本,就能跟着一步步跑起来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体设计与方案选型:先把“现代化”这件事定义清楚
2.1 “现代化”到底指什么
很多人对 Swing 的刻板印象是灰色按钮、僵硬布局、丑到没法看。但说实话,这锅不该完全让 Swing 背。Java 早期界面丑,主要是因为开发者普遍不重视 UI 细节,而且当时硬件性能和系统渲染能力也有限。现在你用的电脑,屏幕分辨率、显卡渲染能力、系统级字体渲染都已经远超 Swing 诞生时的环境,只要设计得当,Swing 界面完全可以做到和 JavaFX、Electron 不相上下的视觉体验。
我用一个词来概括现代化桌面应用的核心诉求:克制。界面干净、配色统一、间距合理、交互反馈即时。不需要花哨的动画和毛玻璃效果,但基础的 hover 反馈、焦点状态、布局随窗口缩放的自适应,这些必须有。
所以我在设计这个实战项目时,给自己定了几个硬性指标:
- 布局:使用 BorderLayout + GridBagLayout 的组合,不用绝对定位。
- 配色:定义一套主题色板,所有组件统一从主题中取色,不散落硬编码颜色。
- 字体:使用全局字体设置,统一调整字号和字重。
- 操作反馈:按钮点击、列表选中、数据加载等场景,必须有明确的视觉反馈或状态提示。
- 响应式:窗口拉伸时,内容区能自适应缩放,而不是组件挤在一起或大片留白。
2.2 为什么选择 Swing 而不是 JavaFX
这个话题几乎每次都会被问到。JavaFX 确实是 Oracle 主推的新一代 UI 框架,支持 CSS 样式、硬件加速、更现代的渲染管线。但我个人的选择逻辑是看场景。
如果你的项目是从零开始、没有任何历史包袱、且团队愿意学习新框架,那 JavaFX 确实值得投入。但如果你的目标是快速交付一个稳定可靠的工具类桌面应用,或者你正在维护一个既有 Swing 项目,Swing 的低门槛和高稳定性就是巨大优势。
Swing 的核心优势有三个:
- JDK 自带,零额外依赖。任何装好 JDK 的机器都能直接跑,不需要额外安装运行时。
- 组件体系成熟稳定。二十多年的迭代,各种边界情况基本都被踩平了,网上能搜到几乎所有问题的答案。
- 内存占用和启动速度可控。相比 Electron 动辄两三百 MB 的内存,Swing 应用一般也就几十 MB,轻量得多。
当然 Swing 也有它明显的短板:默认外观老旧、不支持 CSS、复杂动画实现成本高。但这些都有对应的解决方案,后面我会逐个细讲。
2.3 项目背景与功能范围
为了让这篇博文不那么“空对空”,我给自己设定了一个具体的实战场景:做一个员工信息管理桌面客户端。功能包括部门树浏览、员工列表展示、员工新增/编辑对话框、数据检索、Excel 导入导出。这个场景很典型,既能覆盖 Swing 大部分常用组件(JTree、JTable、JDialog、JFormattedTextField),又能延伸到异步加载、线程安全更新 UI 等真实项目避不开的难点。
这个项目我建议你打开 IDE 跟着敲,不要只看不练。代码不长,核心部分大概在 1500 行左右,但每一块的取舍都有讲究。
3. 环境准备与工程骨架搭建
3.1 JDK 版本选择与安装细节
做 Swing 开发,我强烈建议直接用 JDK 17 或更高版本。原因不仅仅是 Java 17 是 LTS 版本,更关键的是从 JDK 9 开始,Swing 转入模块化体系(java.desktop 模块),很多老教程里那套“默认导入全部”的写法已经不适用了。而 JDK 17 在性能、GC 稳定性、容器支持上都有明显进步,桌面应用跑起来体感更流畅。
安装时有一个细节容易踩坑:如果你装了多个 JDK 版本,一定要确认 JAVA_HOME 指向正确。因为后面打包阶段我会用到 jpackage 工具,它会直接读取 JAVA_HOME 环境变量,指错了会让你白折腾半小时。
验证安装是否成功,在终端里执行:
bash复制java -version
javac -version
看到版本号输出了,就说明没问题。顺便说一句,如果你的系统里只有 JRE 没有 JDK,那是没法编译的,Swing 开发必须装完整 JDK。
3.2 构建工具选择:Maven 还是 Gradle
国内 Java 生态里 Maven 依然是绝对主流,所以我默认使用 Maven 来管理依赖和构建。不管你是 IntelliJ IDEA 还是 Eclipse,新建一个 Maven 项目都很简单。关键是在 pom.xml 里设置正确的编译级别,避免默认的 Java 5 级别导致语法报错:
xml复制<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
这里有个细节值得注意:project.build.sourceEncoding 必须显式设置为 UTF-8。Swing 界面里如果包含中文,而编译编码不是 UTF-8,轻则界面上出现乱码,重则直接编译失败。我见过太多人在这里栽跟头了,界面上的中文要么是问号,要么是方块,原因基本都在编码配置上。
3.3 包结构与主类设计
工程建好后,我采用的包结构如下:
code复制com.example.employee
├── App.java
├── ui
│ ├── MainFrame.java
│ ├── EmployeeTableModel.java
│ ├── EmployeeDialog.java
│ └── DepartmentTree.java
├── model
│ ├── Employee.java
│ └── Department.java
├── service
│ ├── EmployeeService.java
│ └── DataImporter.java
└── theme
├── AppTheme.java
└── UiUtils.java
这个分层思路很直观:model 放数据实体,service 放业务逻辑,ui 放界面组件,theme 统一管理样式和工具方法。为什么这么分?因为 Swing 项目最大的痛点就是逻辑和界面耦合,一个窗体类里既查数据库又渲染表格,写到后面基本没法维护。保持单向依赖关系——界面层可以调用业务层,但业务层绝不允许反向依赖界面层——能让你在后续扩展功能时省下大量时间。
主类 App.java 是整个程序的入口。记住一个关键点:所有 Swing 操作必须在事件调度线程(EDT)中执行。入口写法如下:
java复制public class App {
public static void main(String[] args) {
SwingUtilities.invokeLater(() -> {
try {
UIManager.setLookAndFeel(new FlatLightLaf());
} catch (Exception ex) {
ex.printStackTrace();
}
new MainFrame().setVisible(true);
});
}
}
SwingUtilities.invokeLater 的作用是把初始化 UI 的任务丢到 EDT 队列里,确保界面创建和后续交互的线程模型一致。很多人学 Swing 时不理解为什么非要包这一层,实际后果是:如果你直接在 main 线程里操作 Swing 组件,轻则偶发界面卡顿,重则抛出 InterruptedException 或出现不可预期的绘制错乱。
4. 核心界面搭建:从布局到组件
4.1 全局主题与 FlatLaf 外观库
开头我提到要让 Swing 变得现代化,最简单粗暴有效的方案,就是引入 FlatLaf 这个开源外观库。它的核心价值在于,用很小的改动把 Swing 的默认观感替换成现代扁平风,并且内置了多套配色主题。最重要的是它是纯 Java 实现,兼容性好,不会像原生外观一样在不同操作系统上呈现完全不同的风格。
在 pom.xml 中加入依赖:
xml复制<dependency>
<groupId>com.formdev</groupId>
<artifactId>flatlaf</artifactId>
<version>3.4.1</version>
</dependency>
然后写一个主题配置类,统一管理颜色和字体:
java复制public class AppTheme {
public static final Color PRIMARY = new Color(0x4F46E5);
public static final Color BG_COLOR = new Color(0xF5F6FA);
public static final Color CARD_BG = Color.WHITE;
public static final Color TEXT_PRIMARY = new Color(0x1F2937);
public static final Color TEXT_SECONDARY = new Color(0x6B7280);
public static final Font BASE_FONT = new Font("Microsoft YaHei", Font.PLAIN, 14);
public static final Font TITLE_FONT = new Font("Microsoft YaHei", Font.BOLD, 18);
}
这套色板是从很多现代化 Web 后台管理系统中抽取的通用色系,不会太跳跃也不会太暗淡。在实际编码时,我建议全项目统一引用 AppTheme 里的颜色,尽量不要在组件里直接写 new Color(...)。后期想要换主题的时候,就知道集中管理的好处了——改一个文件,全局生效。
4.2 主窗体框架与菜单栏
主窗体是应用的脸面,我一般习惯用 BorderLayout 做整体布局,这样能保证各区域相对稳定。顶部放菜单栏和工具条,左侧放部门树,中央放员工表格,底部放状态栏。
MainFrame 的核心结构:
java复制public class MainFrame extends JFrame {
private JTree departmentTree;
private JTable employeeTable;
private EmployeeTableModel tableModel;
private JLabel statusLabel;
public MainFrame() {
setTitle("员工信息管理系统");
setSize(1200, 800);
setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
setLocationRelativeTo(null);
setExtendedState(JFrame.MAXIMIZED_BOTH);
initMenuBar();
initToolBar();
initContent();
initStatusBar();
}
}
这里面要注意 setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE) 和 setExtendedState(JFrame.MAXIMIZED_BOTH) 这两个设置。前者让窗口关闭时进程直接结束,避免后台残留;后者让程序启动即最大化,这在做内部工具时体验很好,省得每次手动拖窗口。
菜单栏里我放了“文件、编辑、帮助”三个菜单。其中“文件”菜单包含导入和退出,“编辑”菜单包含新增、修改、删除,“帮助”菜单里面是一个“关于”对话框。这样用户操作的基本路径都覆盖了。
4.3 树形结构与部门列表
左侧部门树的核心价值是让用户按部门维度筛选员工。JTree 用起来并不复杂,但要注意数据模型的应用。我先从 service 层拿到部门的层级结构,再构建 DefaultMutableTreeNode,最后设置到 JTree 上。
这里有个实操经验:节点数据不要直接用中文名称作为节点对象。因为后面你要根据选中节点查询员工时,如果没有额外的 ID 字段关联,就只能靠名称匹配,会非常痛苦。正确的做法是写一个 DepartmentNode 包装类,把部门 ID 和名称都塞进去,重写 toString() 只返回名称。这样一个节点既能显示名称,又能快速拿到 ID。
java复制public class DepartmentNode {
private final int id;
private final String name;
public DepartmentNode(int id, String name) {
this.id = id;
this.name = name;
}
public int getId() {
return id;
}
@Override
public String toString() {
return name;
}
}
树建好后要给 JTree 注册监听器,处理选中事件来刷新右侧员工表。
java复制departmentTree.addTreeSelectionListener(e -> {
DefaultMutableTreeNode node = (DefaultMutableTreeNode)
departmentTree.getLastSelectedPathComponent();
if (node == null) return;
Object userObject = node.getUserObject();
if (userObject instanceof DepartmentNode dept) {
loadEmployeeByDept(dept.getId());
}
});
这里有个新手容易犯的错误:树选中事件在折叠、展开、刷新节点等场景下都可能被触发,如果每次都去查数据库加载数据,会产生大量无效请求。我建议在事件处理里先判断节点是否为空、userObject 是否为期望类型,再做后续逻辑。
4.4 表格组件与自定义模型
员工列表是整个系统的信息中枢。JTable 的默认写法是把数据塞进 Vector 或二维数组里,这样做非常不优雅,不仅类型不安全,后续想要加按钮列、自定义渲染器都会变得困难。
正确做法是继承 AbstractTableModel 自定义数据模型。代码结构:
java复制public class EmployeeTableModel extends AbstractTableModel {
private final String[] columns = {"ID", "姓名", "部门", "职位", "入职日期", "状态"};
private List<Employee> employees = new ArrayList<>();
@Override
public int getRowCount() {
return employees.size();
}
@Override
public int getColumnCount() {
return columns.length;
}
@Override
public String getColumnName(int column) {
return columns[column];
}
@Override
public Object getValueAt(int rowIndex, int columnIndex) {
Employee emp = employees.get(rowIndex);
return switch (columnIndex) {
case 0 -> emp.getId();
case 1 -> emp.getName();
case 2 -> emp.getDepartment();
case 3 -> emp.getPosition();
case 4 -> emp.getHireDate().toString();
case 5 -> emp.getStatus();
default -> "";
};
}
public void setEmployees(List<Employee> list) {
this.employees = list;
fireTableDataChanged();
}
public Employee getEmployeeAt(int row) {
return employees.get(row);
}
}
为什么非要这么麻烦?因为 AbstractTableModel 自带了一套通知机制:当你调用 fireTableDataChanged() 时,JTable 会自动刷新显示。这样你就把“数据管理”和“界面显示”解耦了——表格只负责展示模型里的数据,至于数据从哪来,是数据库、文件还是网络,它完全不关心。以后的增删改查,只要更新这个 model 里的 List,然后通知刷新就行。这个设计模式在 Swing 开发里是基本功,务必掌握。
4.5 单元格渲染:让数据状态一目了然
默认的 JTable 只显示纯文本,很单调。实战中,我需要在“状态”列里展示不同的数据状态,比如“在职”显示绿色、“离职”显示灰色、“试用期”显示蓝色。这时就要用到自定义单元格渲染器。
java复制public class StatusCellRenderer extends DefaultTableCellRenderer {
@Override
public Component getTableCellRendererComponent(JTable table, Object value,
boolean isSelected, boolean hasFocus, int row, int column) {
super.getTableCellRendererComponent(table, value, isSelected, hasFocus, row, column);
String status = (String) value;
if ("在职".equals(status)) {
setForeground(new Color(0x059669));
} else if ("试用期".equals(status)) {
setForeground(new Color(0x2563EB));
} else {
setForeground(new Color(0x9CA3AF));
}
return this;
}
}
然后把渲染器设置到对应列:
java复制employeeTable.getColumnModel().getColumn(5).setCellRenderer(new StatusCellRenderer());
这就是 Swing 的可扩展性体现。表格的每一列都可以自定义渲染逻辑,不只是文字颜色,甚至可以往单元格里塞按钮、进度条、复选框。比如后续你想在每一行加一个“查看详情”按钮,都可以用自定义渲染器来实现。不过有一点提醒:单元格里放按钮,点击事件的处理比普通文本要绕一些,涉及 TableMouseListener 里根据点击列和行坐标来判断,后面如果大家感兴趣,可以单独写一篇展开讲。
5. 对话框、交互与异步加载实战
5.1 添加/编辑对话框的封装
新增或编辑员工信息时,最常用的交互方式是弹出一个模态对话框。这里我犯过一个典型错误:把整个表单的 Flield 都定义成类字段,然后在对话框关闭后还继续访问这些字段取数据。这种方式能工作,但对话框复用时状态管理很容易出 bug。
后来我改成一种更干净的模式:对话框自己负责收集输入,通过一个公共方法向外输出数据。初始化时把要编辑的员工对象传进去,如果传 null 就代表新增模式。
java复制public class EmployeeDialog extends JDialog {
private JTextField nameField;
private JComboBox<String> departmentBox;
private JComboBox<String> positionBox;
private JDateChooser hireDatePicker;
private JComboBox<String> statusBox;
private boolean succeeded;
public EmployeeDialog(Frame owner, Employee employee) {
super(owner, employee == null ? "添加员工" : "编辑员工", true);
initUI();
if (employee != null) {
fillData(employee);
}
}
public boolean isSucceeded() {
return succeeded;
}
public Employee getEmployee() {
// 从各字段收集数据返回
}
}
调用端逻辑:
java复制EmployeeDialog dialog = new EmployeeDialog(this, selectedEmployee);
dialog.setVisible(true);
if (dialog.isSucceeded()) {
Employee emp = dialog.getEmployee();
employeeService.save(emp);
loadEmployeeByDept(currentDeptId);
}
这里 setVisible(true) 是阻塞式的,会停在对话框关闭后才继续往下执行。这个特性让对话框的逻辑非常直观——关闭后立即判断用户是否点了保存,如果保存了就处理新数据。
5.2 用 SwingWorker 解决界面卡死问题
做桌面应用最要命的问题就是界面卡死。尤其是点击“查询员工”后,如果数据量很大,或者网络请求耗时较长,界面会像死了一样,标题栏出现“无响应”提示。根因是耗时的业务逻辑直接跑在 EDT 上,阻塞了界面的绘制和事件响应。
Swing 提供的标准解决方案是 SwingWorker<T, V>。它天生就是“后台执行、前台更新”的正确姿势。我先写了一个后台加载员工数据的逻辑:
java复制class LoadEmployeeWorker extends SwingWorker<List<Employee>, Employee> {
private final int deptId;
LoadEmployeeWorker(int deptId) {
this.deptId = deptId;
}
@Override
protected List<Employee> doInBackground() {
// 模拟耗时查询,这里会跑在后台工作线程里
return employeeService.queryByDepartment(deptId);
}
@Override
protected void done() {
try {
List<Employee> result = get();
tableModel.setEmployees(result);
statusLabel.setText("共 " + result.size() + " 名员工");
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
statusLabel.setText("加载失败");
}
}
}
然后在树节点选中时启动这个后台任务:
java复制new LoadEmployeeWorker(deptId).execute();
为什么 done() 方法里可以直接操作 UI?因为 SwingWorker 框架保证 done() 和 process() 这两个方法在 EDT 中被回调,所以你可以放心大胆地在里面更新组件状态。
这里重点强调一点:永远不要在 Worker 的内部逻辑(doInBackground 之外的代码)之外,尝试直接更新界面组件。我在维护老代码时见过有人用 Thread.sleep 想延迟刷新 JLabel,结果导致界面崩溃,问题排查了整整一天。后台线程和 UI 线程的交互,老老实实走 SwingWorker 的机制,不要自己用 Thread + invokeLater 去裸写,容易漏掉边界情况。
5.3 搜索与数据检索的防抖设计
员工列表的搜索功能,最直接的做法是给搜索框加键盘事件,每次敲一个字符就去查一次数据库。这个方案在小数据量下没问题,但数据量稍大或者连接远程数据库,就会有明显延迟,而且会频繁请求后端。比较现代的处理方式是防抖:用户停止输入 300 毫秒后才真正发起查询。
虽然 Swing 没有现成防抖 API,但用 javax.swing.Timer 就能轻松实现。注意这个 Timer 跟 java.util.Timer 不一样,Swing Timer 是在 EDT 中触发,天然线程安全:
java复制searchField.getDocument().addDocumentListener(new DocumentListener() {
private Timer debounceTimer = new Timer(300, e -> doSearch());
{
debounceTimer.setRepeats(false);
}
@Override
public void insertUpdate(DocumentEvent e) {
debounceTimer.restart();
}
@Override
public void removeUpdate(DocumentEvent e) {
debounceTimer.restart();
}
@Override
public void changedUpdate(DocumentEvent e) {
debounceTimer.restart();
}
});
只要在每一次输入变化时调用 restart(),Timer 就会重置计时。只有用户停止输入超过 300 毫秒,doSearch() 才会被执行。这个细节对用户体验的提升非常明显,用户不会感觉到每一次敲击都触发一次卡顿,程序也不会无意义地发请求。
5.4 状态栏与操作结果反馈
一个专业的桌面应用离不开状态栏。不要小看底部那一条信息,它是应用给用户最直接的“交代”。状态栏左侧显示当前状态,比如“正在加载数据…”“共 42 条记录”,右侧可以放时间监听或版本号。
我的实现方式是构造一个简单的状态栏面板,里面放两个 JLabel,一个靠左一个靠右:
java复制private void initStatusBar() {
statusLabel = new JLabel("就绪");
JLabel versionLabel = new JLabel("v1.0.0");
JPanel statusBar = new JPanel(new BorderLayout());
statusBar.setBorder(new EmptyBorder(4, 8, 4, 8));
statusBar.add(statusLabel, BorderLayout.WEST);
statusBar.add(versionLabel, BorderLayout.EAST);
add(statusBar, BorderLayout.SOUTH);
}
当后台任务运行的时候,我会在 doInBackground() 之前把状态栏先改成“正在加载…”,任务完成后再更新成实际结果。这看起来是小事,但对用户感知极其重要。很多桌面应用给人“卡死”“没反应”的印象,其实就是因为缺少这种低成本的反馈机制。
6. 数据导入导出与键盘可用性细节
6.1 基于 Excel 的批量导入
在企业内部的桌面应用里,从 Excel 导入员工信息几乎是刚需。我在这里选用 Apache POI 作为 Excel 解析库。引入依赖时需要注意,POI 有多个子包,一般只需要 poi-ooxml,它会连带引入核心模块:
xml复制<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.2.5</version>
</dependency>
解析 Excel 的流程,我会把它放进 SwingWorker 里在后台执行,避免大文件解析时界面冻结。关键实现:
java复制try (Workbook workbook = WorkbookFactory.create(new FileInputStream(file))) {
Sheet sheet = workbook.getSheetAt(0);
List<Employee> imported = new ArrayList<>();
for (Row row : sheet) {
if (row.getRowNum() == 0) continue; // 跳过表头
String name = row.getCell(0).getStringCellValue();
String dept = row.getCell(1).getStringCellValue();
// ...
imported.add(new Employee(name, dept));
}
employeeService.batchInsert(imported);
}
POI 操作 Excel 有一个需要注意的坑:单元格类型的判断。Excel 单元格可能存的是字符串、数字或日期,如果直接用 getStringCellValue() 去读数字单元格,会抛异常。稳妥做法是先判断 getCellType(),再按类型取值。
6.2 文件选择器与默认路径
导入和导出都需要用到文件选择对话框。JFileChooser 用起来中规中矩,但有几点经验值得分享:
- 文件类型过滤一定设置好,不然用户选个 .txt 或 .jpg 进来,你的解析逻辑直接崩。
- 打开对话框前,把当前目录设置成上一次使用的目录。这听起来像小事,但实际使用中体验差别很大。没人愿意每次导出都从“我的电脑”开始层层点目录。
- 对话框的标题应该动态变化,是“导入”就写“请选择 Excel 文件”,是“导出”就写“保存文件到”。
6.3 键盘操作与无障碍支持
现代化应用有一个很容易被忽略的侧面:不要逼用户必须依赖鼠标。做内部管理系统时,操作员可能一天要录入几百条数据,如果每一行都要鼠标点来点去,效率会大打折扣。所以我很重视键盘交互。
JTable 自带基本的键盘导航(方向键、Tab 键),但很多功能默认是没有快捷键的。我通过 KeyStroke 给主要操作都注册了快捷键:
java复制InputMap im = getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
ActionMap am = getRootPane().getActionMap();
im.put(KeyStroke.getKeyStroke(KeyEvent.VK_N, InputEvent.CTRL_DOWN_MASK), "addEmployee");
am.put("addEmployee", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
showAddDialog();
}
});
这种 InputMap / ActionMap 机制也是 Swing 中很重要的抽象:InputMap 负责把“按键组合”映射成“逻辑操作名”,ActionMap 把“逻辑操作名”映射成“实际动作”。把两者分开的好处是,未来如果你想做按键绑定方案切换,比如从 Ctrl+N 改成 Alt+N,只需要改 InputMap 里的映射,完全不用动业务代码。
如果你的应用以后要支持键盘驱动的数据录入,可以进一步自定义单元格编辑器,让用户在表格里直接填写并按回车确认,这是高效录入的核心体验,Swing 完全能做到。
7. 常见问题与排查技巧实录
7.1 中文乱码
这是 Swing 新人遇到最多的一个问题。界面上的中文要么显示成方框,要么显示成问号。原因一般有三种:
原因一:源代码编码不是 UTF-8。 Maven 项目里编译编码没设置。解决方式在之前的 pom 配置里已经讲过了,务必加上 project.build.sourceEncoding。
原因二:运行环境缺少中文字体。 某些精简版 Linux 系统没有安装中文字体,Swing 找不到中文字体时就渲染成方格。解决方式是在代码初始化阶段指定字体,比如我之前定义 AppTheme.BASE_FONT 时用的 “Microsoft YaHei”,在 Windows 和 macOS 上都有,但 Linux 上不一定有。稳妥做法是写一个字体探测工具,动态查找系统中可用的中文字体。
原因三:某些组件渲染异常。 JFileChooser 在一些 Linux 桌面环境下会走系统原生外观,而原生外观的中文字体配置不佳。可以强制设置成跨平台外观规避。
7.2 界面缩放模糊或过大过小
高分屏(HiDPI)环境下,Swing 应用默认可能会显示得特别小。JDK 9 之后有系统属性可以调整缩放比例:
bash复制java -Dsun.java2d.uiScale=2.0 -jar employee-app.jar
更好的做法是在打包或启动脚本中动态判断系统缩放因子。不过 JDK 11 及以上默认会自动适配大部分 Windows 高分屏,如果你用的是老 JDK,建议优先升级版本。
7.3 EDT 违规导致的诡异卡顿
Swing 的线程模型是整个体系的基石。最常见的违规操作就是在后台线程中更新组件。表现往往是偶发性的:一会儿正常,一会儿界面刷新不及时,偶尔还会直接抛异常。
如果你觉得程序行为不对劲,可以打开 EDT 违规检查:
bash复制java -Dsun.awt.nativedebug=true
这个系统属性会在违规操作发生时输出调试日志,定位问题非常方便。但要注意,它本身并不阻止违规操作,只是辅助排查。
7.4 长耗时任务的用户体验优化
我上面已经用 SwingWorker 解决了加载数据卡顿的问题。但如果你有更耗时的任务,比如批量导入 10 万条记录,那种动不动十几秒的等待,仅仅不卡死界面是不够的。用户需要看到进度。
SwingWorker 提供了 publish() 和 process() 机制来支持进度更新。你可以在 doInBackground() 中不断调用 publish(progressValue),然后在 process() 中接收并更新进度条。更简单的方式是直接调用 setProgress(),再配合 PropertyChangeEvent 监听进度变化更新进度条组件:
java复制worker.addPropertyChangeListener(evt -> {
if ("progress".equals(evt.getPropertyName())) {
int progress = (Integer) evt.getNewValue();
progressBar.setValue(progress);
}
});
在比较耗时的任务里,我习惯同时在状态栏显示“正在处理第 X / Y 条”这类文本提示,比单纯一个百分比进度条更具语义。用户在等待时可以明确知道程序在干活、干了多少、还剩多少。这种细节对用户体验的提升是决定性的。
8. 打包分发:让程序跑在别人的机器上
8.1 可执行 JAR 包的配置
依赖做完后,程序不可能永远在 IDE 里跑。要交付给使用者,第一步是打成可执行 JAR。如果你用 Maven,推荐用 maven-shade-plugin,它会将项目依赖一起打进去,生成一个 fat JAR:
xml复制<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>shade</goal>
</goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.employee.App</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
打包完成后,在终端执行:
bash复制java -jar target/employee-app.jar
如果出现 ClassNotFoundException,说明依赖没有打进去,或者打包过程中某些配置文件没有正确合并。若使用多模块项目,注意 shade 插件和 maven-jar-plugin 的顺序配合。
8.2 使用 jpackage 生成本地应用
JDK 14 引入的 jpackage 工具,算是给 Swing 桌面分发打了一剂强心针。它可以把应用、运行时、依赖打包成各平台的原生安装包:Windows 上是 exe 或 msi,macOS 上是 dmg 或 pkg。用户机器上不需要提前安装 JDK,这对非技术用户非常友好。
常用打包命令示例:
bash复制jpackage --input target/ \
--name "EmployeeManager" \
--main-jar employee-app.jar \
--main-class com.example.employee.App \
--type exe \
--icon app.ico \
--win-menu
这里面有几个参数要注意:
--input指定包含 JAR 的目录。--main-jar是主 JAR 文件名。--type按目标平台取值,Windows 用 exe 或 msi,macOS 用 dmg。--icon指定应用图标,如果不设置就会用默认 Java 图标,非常掉档次。
jpackage 也有不少坑,比如 Windows 平台打包需要 WiX Toolset 支持。第一次打包失败很正常,不要灰心,多试几次就能把环境理顺。
8.3 启动脚本里加 JVM 参数
自行打包时,我一般会配合一个 .bat 或 .sh 脚本,在启动命令中加上更合理的 JVM 参数。桌面应用不像服务端需要超大堆内存,但初始堆设置太小时,数据量上来后容易出现频繁 GC 导致的界面卡顿。常用的参数是:
bash复制java -Xms256m -Xmx1g -Dsun.java2d.uiScale=1.5 -jar employee-app.jar
对于 Swing 应用在 Linux 环境下渲染不正常的情况,还可以加 -Djava.awt.headless=false 来排除无头模式误开启。
9. 写到最后的一点实在建议
如果你动了念头想系统地把 Swing 捡起来或进阶一把,我建议你直接拿一个身边真实的需求做靶子,哪怕是一个通讯录管理系统或者记账小工具都好。然后在实现中刻意要求自己:代码必须有 model / service / ui 分层;所有耗时逻辑必须交给 SwingWorker;所有颜色字体必须走主题类;所有列表和表格必须用自定义模型。如果你能把这四个要求贯彻执行,写出来的程序从结构到体验就已经能超过绝大多数“能用就行”的项目了。
我在一开始也觉得 Swing 的上限很低,但后来发现限制自己的从来不是框架,而是设计思路和实现习惯。Swing 提供的基础能力远比多数人想象得要扎实,把这些基础能力组合好,加上一套现代的设计规范,它完全能稳定服务一个又一个真实业务场景。下一次如果再有人跟你说 Swing 过时了,你不妨把他带去机房看看那些稳定跑了几年的老系统,然后笑着告诉他:成熟不等于老去,好用才是真道理。
