简介:JavaFX+Jfoenix系列学习笔记(十)配套源码,面向需要掌握桌面托盘交互与中文乱码处理的JavaFX开发者。内容基于Jfoenix Material Design组件库,演示通过java.awt.SystemTray实现系统托盘图标、关闭窗口后驻留以及点击恢复等操作,同时结合UTF-8编码、pom.xml属性配置、FXMLLoader加载编码设置,系统梳理中文乱码的常见成因与解决途径。包内含80个文件,以58个class文件、3个java源文件为主体,另有6个png图标、2个xml、2个css样式表以及properties、ico、prefs等辅助资源,压缩包仅262KB,便于直接导入项目对照运行。作为学习笔记源码,目录划分了src、resources、target等区域,可配合笔记逐步阅读,快速理解托盘API和字符编码在JavaFX工程中的实际落地。已有1666人参与学习,适合初、中级桌面应用开发者用来补充实战经验。 这份学习笔记我已经写到了第10篇,前面聊过布局、动画、自定义控件这些,但真正把应用从“开发完成”推到“能用、好用”,反而是在系统托盘和中文乱码这两个看似不起眼的点上磨了最久。这次就把这块源码和实践过程完整拆出来,适合正在做 JavaFX 桌面应用、需要常驻后台或通知栏的产品,也适合那些刚把 Jfoenix 引入项目、结果发现中文显示各种不对劲的朋友。
先说结论:JavaFX 本身没有托盘 API,绕不开 AWT 的 SystemTray;中文乱码也不是一句“把文件存成 UTF-8”就能解决,它要从源码、编译、运行、字体四个层面一起堵。下面按模块讲清楚,附带可直接复制的源码。
1. 系统托盘:为什么JavaFX不直接给个API
1.1 JavaFX的“桌面集成”短板
JavaFX 定位是富客户端界面框架,它在窗口、控件、动画、CSS 样式上做得非常到位,但托盘属于“操作系统级”的桌面集成能力,JavaFX 官方一直没有提供跨平台封装。如果你翻过源码,会发现javafx.stage.Stage可以设置图标、设置窗口类型,但没有任何一个方法能让你往系统通知栏塞一个常驻图标。
早期很多项目用 Swing 的SystemTray,因为 Swing 和 AWT 绑在一起,java.awt.SystemTray也是 JDK 里唯一官方支持的托盘入口。JavaFX 项目引入 AWT 类并不冲突,两者可以共存,只要注意线程调度即可。我见过不少人为了避免 AWT 引入额外复杂性,用纯 JavaFX 加第三方库去实现托盘,反而踩更多坑。实际项目里最稳妥的方案还是java.awt.SystemTray+java.awt.TrayIcon,跨 Windows、Linux 桌面环境都能工作。
1.2 用AWT SystemTray衔接FX的三个理由
第一,JDK 原生支持,不需要额外依赖,编译打包时也不需要担心库冲突。第二,SystemTray的弹出菜单是操作系统原生菜单,用户右键看到的是系统风格,观感上比自绘菜单更自然。第三,稳定性好,经过这么多年大规模桌面应用验证,很少出现点击无响应的问题。
当然它也有短板。TrayIcon的事件回调运行在 AWT 事件分发线程,而 JavaFX 的 UI 操作必须回到 FX Application Thread,所以托盘代码里到处要写Platform.runLater()。这是新手最容易踩的地方:在托盘监听里直接操作 Stage,要么效果不生效,要么直接抛异常。
1.3 Jfoenix在这个项目里到底扮演什么角色
Jfoenix 是 JavaFX 的 Material Design 控件库,它解决的是界面风格统一的问题,和托盘没有直接关系。但既然标题写了 Jfoenix,说明项目主界面用的是这一套控件。实际开发中 Jfoenix 最常用的几个类是JFXButton、JFXTextField、JFXDialog、JFXSnackbar,它们默认字体在 Windows 下渲染中文经常出现方框或模糊,所以我在第三部分会单独讲字体层的修复。
这里的整体设计方案是:应用主界面用 JavaFX + Jfoenix 搭建,系统托盘用 AWT SystemTray 补充,两者通过线程切换协作。这也是很多商业 JavaFX 桌面应用的通用架构,先把这层跑通了,后续加自动更新提示、消息通知都会顺畅很多。
2. 托盘功能实现:核心代码与线程协作
2.1 最小可运行的TrayManager
先给出一份可以直接复制的TrayManager源码,它包含了托盘安装、菜单构建、图标设置、事件处理四个核心部分。
import java.awt.AWTException; import java.awt.Image; import java.awt.MenuItem; import java.awt.PopupMenu; import java.awt.SystemTray; import java.awt.Toolkit; import java.awt.TrayIcon; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.net.URL; import javafx.application.Platform; import javafx.stage.Stage; public class TrayManager { private static TrayIcon trayIcon; private static Stage primaryStage; public static void install(Stage stage) { if (!SystemTray.isSupported()) { return; } primaryStage = stage; PopupMenu menu = new PopupMenu(); MenuItem showItem = new MenuItem("显示主界面"); showItem.addActionListener(e -> showWindow()); MenuItem hideItem = new MenuItem("隐藏到托盘"); hideItem.addActionListener(e -> hideWindow()); MenuItem exitItem = new MenuItem("退出程序"); exitItem.addActionListener(e -> exitApp()); menu.add(showItem); menu.add(hideItem); menu.addSeparator(); menu.add(exitItem); URL resource = TrayManager.class.getResource("/icons/app_16.png"); Image image = resource != null ? Toolkit.getDefaultToolkit().getImage(resource) : Toolkit.getDefaultToolkit().createImage(new byte[0]); trayIcon = new TrayIcon(image, "JavaFX 托盘应用", menu); trayIcon.setImageAutoSize(true); trayIcon.addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { showWindow(); } } }); try { SystemTray.getSystemTray().add(trayIcon); } catch (AWTException ex) { ex.printStackTrace(); } } private static void showWindow() { Platform.runLater(() -> { primaryStage.show(); primaryStage.setIconified(false); primaryStage.toFront(); }); } private static void hideWindow() { Platform.runLater(() -> primaryStage.hide()); } private static void exitApp() { Platform.runLater(() -> { if (trayIcon != null) { SystemTray.getSystemTray().remove(trayIcon); } Platform.exit(); System.exit(0); }); } }这段代码的关键点有两个。第一个是Platform.setImplicitExit(false),如果不调用这个,当你把最后一个窗口隐藏后 JavaFX 检测不到显示窗口,会自动结束 FX 线程,导致程序只剩托盘图标还活着,点击菜单却毫无反应。第二个是PopupMenu里的MenuItem回调不在 FX 线程,所以所有对 Stage 的操作必须包在Platform.runLater()里,让代码回到 FX Application Thread 再执行。
2.2 关闭窗口不等于退出进程
如果你设置了关闭按钮为“隐藏到托盘”,需要在启动类或 Controller 的 initialize 方法里追加窗口关闭事件拦截:
primaryStage.setOnCloseRequest(event -> { event.consume(); primaryStage.hide(); });event.consume()的作用是告诉 JavaFX:这个关闭事件已经被消费掉了,不要执行默认的窗口销毁逻辑。然后手动调用hide()隐藏窗口。配合前面提到的Platform.setImplicitExit(false),程序就会保持后台运行。
这个设计要提前想清楚退出入口。用户如果关闭窗口后找不到退出按钮,会一直驻留内存。所以托盘菜单里“退出程序”必须可靠。我实现里先移除托盘图标,再Platform.exit(),最后System.exit(0),三层保险,避免某些 JDK 版本下 JavaFX 线程退出不干净导致 JVM 残留。
2.3 双击、右键菜单与图标资源
图标这块我踩过一个小坑。刚开始把图标放在src/main/resources/icons/下,但打包后运行时路径大小写反了,导致图标加载失败。建议用TrayManager.class.getResource("/icons/app_16.png")的方式,注意路径开头斜杠,同时打开 IDE 的Build输出目录确认资源真的被复制过去了。
图标文件建议直接用透明背景的 PNG,尺寸准备 16x16 和 32x32 两版。setImageAutoSize(true)会由系统自动缩放。如果你用 JPG,四周白色方块在托盘里会非常显眼,看起来就像贴了一块口香糖,很掉价。
双击事件里我用的是e.getClickCount() == 2判断双击,和右键菜单并不冲突。Windows 上双击托盘图标恢复窗口,右键弹出菜单,这是用户已经养成的习惯,没必要改成单击展开主界面。
3. 中文乱码:一次排查,三个修复层
3.1 先判断是“显示乱码”还是“控制台乱码”
中文乱码这个问题,我接手项目时看到的现象是两处:界面 FXML 里的中文变成问号,控制台输出的日志变成乱码。这两类乱码的根源完全不同。
界面乱码通常是因为源码文件或 FXML 文件保存时用了 GBK,而 Java 编译器按 UTF-8 读取,导致字符解码错误。控制台乱码则是运行时 JVM 的默认字符集和操作系统控制台代码页不一致,典型场景是 Windows 控制台默认 GBK,而 JVM 把日志以 UTF-8 输出。
还有一种容易被忽视的情况:字符本身没坏,但字体不支持中文显示。Jfoenix 的默认字体是 Roboto,它在英文环境下非常漂亮,但不包含中文字形,结果就是中文界面变成一排排方块。这三种情况要用三种方案分别解决,绝不能只改一个地方就指望全部恢复。
3.2 源码层面:统一UTF-8并处理遗留文件
先检查项目里文件的编码。用 IntelliJ IDEA 打开,右下角能看到文件编码,把项目编码、模块编码、属性文件全部设为 UTF-8。操作路径是Settings -> Editor -> File Encodings,把 Global Encoding 和 Project Encoding 都改成 UTF-8,Properties Files 勾选 Transparent native-to-ascii conversion。
如果已经存在历史遗留的 GBK 文件,最稳的办法是用 IDE 批量转换,而不是手动重新输入。IDEA 右下角可以直接点击编码并选择“转换”,也可以写一个小脚本用iconv批量处理:
find src -name "*.java" -exec iconv -f GBK -t UTF-8 {} -o {}.tmp \; -exec mv {}.tmp {} \;这个命令会把src目录下所有 Java 文件从 GBK 转成 UTF-8。执行前务必先备份或提交到版本库,因为iconv遇到无法识别的字符会直接报错并生成空文件。
FXML 文件里的中文乱码也是类似处理。除了文件编码,还要检查 FXML 头部声明,确认encoding="UTF-8",并且文件保存为无 BOM 的 UTF-8。带 BOM 的 UTF-8 在少数 XML 解析器下会报“Content is not allowed in prolog”的错误,虽然 JavaFX 的 FXMLLoader 通常能容忍,但没必要在这样细节上赌运气。
3.3 运行层面:JVM参数与启动脚本
代码和资源文件都改成 UTF-8 后,还需要在运行时指定 JVM 参数。Java 8 到 Java 17 时代,Windows 平台的file.encoding默认是 GBK,如果没有显式指定,读取文件、输出控制台都会按 GBK 处理。
在 IntelliJ 里运行主类时,在VM options加一行:
-Dfile.encoding=UTF-8如果项目用 Maven 或 Gradle 启动,在插件配置里也加上。如果是打 jar 包后用命令行启动,可以写一个启动脚本,顺便把控制台代码页切到 UTF-8:
@echo off chcp 65001 java -Dfile.encoding=UTF-8 -jar "app.jar"chcp 65001是 Windows 切换控制台代码页到 UTF-8 的命令,不加的话,即使 JVM 以 UTF-8 输出,控制台按 GBK 解码还是乱码。这里有一个细节:chcp不要放在 Java 启动之后调用,因为控制台代码页切换影响的是后续输出渲染,必须在 java 命令之前执行。
如果你用jpackage打包成原生安装包,可以在--java-options里带上这个参数:
jpackage --input target --name app --main-jar app.jar --java-options "-Dfile.encoding=UTF-8"从 Java 18 开始,JEP 400 把 UTF-8 设为默认字符集,新项目不需要再手动加参数。但如果你的目标用户还可能用 Java 11 或 Java 17 环境,这个参数必须保留。
3.4 字体层面:Jfoenix控件中文发虚的真凶
解决了乱码问题后,Jfoenix 控件的中文可能还是“发虚”,这通常不是乱码,而是字体选择问题。JFXButton、JFXTextField 默认的-fx-font-family是Roboto,而 Roboto 不包含中文字形,最后 Windows 会用字体回退机制找中文字体,结果大概率是等线或者雅黑,字号小时会显得模糊。
最简单的全局修复方式,是在应用根节点的 CSS 或 Scene 级别设置中文字体:
* { -fx-font-family: "Microsoft YaHei"; }如果你希望在不同系统上表现一致,不依赖用户系统里有没有雅黑,可以把一款开源中文字体打进资源目录,代码里加载:
Font.loadFont(TrayManager.class.getResourceAsStream("/fonts/NotoSansSC-Regular.otf"), 14); root.setStyle("-fx-font-family: 'Noto Sans SC';");注意字体加载要用getResourceAsStream,不要直接new File,否则打成 jar 后找不到文件。字体体积通常会比预期大,几 MB 的 otf 很常见,放进安装包时要有心理准备。开源字体优先选择思源黑体或 Noto Sans SC,它们覆盖的汉字范围完整,并且授权允许嵌入分发。
jfoenix 控件在 JavaFX 8 时代使用 Scene 级别字体设置基本够用,但如果你用 JFXDialog 或 JFXPopup,这些控件会把内容挂在外层容器,全局 CSS 有时覆盖不到,建议除了根样式,再给每个弹窗容器补一次字体设置。
4. 常见问题与排查实录
4.1 问题对照表
下面这张表是我在实现过程中和帮同事排查时遇到的真实问题,按“现象-原因-解法”整理成速查表,方便你直接对照。
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 托盘图标不显示 | 资源路径错误,或图片格式不支持 | 检查getResource路径大小写,换用兼容性好的 PNG,确认资源目录被复制到输出目录 |
| 托盘右键菜单中文乱码 | 系统区域语言或 AWT 菜单字体问题 | Windows 中文系统下先使用时设置-Dfile.encoding=UTF-8,若仍乱码,确认系统装了中文字体 |
| 点击托盘图标窗口不恢复 | 事件回调不在 FX 线程 | 用Platform.runLater()包裹窗口操作,同时在启动处调用Platform.setImplicitExit(false) |
| 关闭窗口后进程还在,但托盘点击无反应 | FX 线程已退出 | 检查关闭事件是否consume(),确认是否调用了Platform.setImplicitExit(false) |
| 打包成 exe 后中文全变问号 | 打包工具未带上 JVM 编码参数 | jpackage 加--java-options "-Dfile.encoding=UTF-8",启动器脚本里先chcp 65001 |
| Jfoenix 按钮文字虚、像方块 | 默认 Roboto 字体不包含中文字形 | 全局设置-fx-font-family: "Microsoft YaHei"或加载自定义中文字体 |
| 控制台日志中文乱码,界面正常 | JVM 输出编码和控制台代码页不一致 | 启动脚本chcp 65001+-Dfile.encoding=UTF-8组合使用 |
4.2 我踩过的坑:托盘、编码、线程一起搞事
有一次在 Linux 上调试,应用启动后托盘图标正常出现,但点击任何菜单项都没反应。排查了很久发现是窗口还没初始化完成就去安装托盘,primaryStage指向了一个尚未 show 的 Stage,后续Platform.runLater只让一个隐藏窗口显示出来。后来我把安装托盘的时机放到Stage.initOwner之后、窗口showDa之后,问题消失。
还有一次是中文乱码,明明所有文件都是 UTF-8,控制台还是乱码。最后发现是 IDE 的Run Configuration里没有继承全局编码设置,单独为那个启动配置加了 VM 参数才解决。这个坑很隐蔽,因为新加一个启动配置容易遗漏,建议在项目级别的.idea模板里统一。
关于 AWT 和 JavaFX 线程协作,补充一个实战经验:如果 TrayIcon 多次添加,会抛java.awt.AWTException: TrayIcon already added。我在某些场景下连续调用install导致重复添加,后来在install方法开头判断trayIcon != null就直接返回,保证只装一次。如果你是做应用内换肤或动态加载模块,要小心这个细节。
另外在模块化项目里,如果用了module-info.java,需要在模块声明里加上requires java.desktop;,否则 AWT 相关类会报java.lang.module错误。JavaFX 模块和 Jfoenix 模块按各自需要声明requires javafx.controls;、requires com.jfoenix;。这一步很多从 classpath 项目转 module 的朋友容易忽略。
最后再分享一个我能保证管用的经验:项目从第一天就把编码、字体、线程这三件事定好规范,比所有问题集中爆发后再返工省力得多。我现在新开 JavaFX 项目,第一件事就是设全局 UTF-8、VM 参数加-Dfile.encoding=UTF-8、根 CSS 直接写中文字体族、并提前规划托盘安装和退出路径。等这套固定流程跑完,后面写业务逻辑就再也不会被这些基础问题打断。系统托盘和中文乱码解决完后,如果你正准备做消息通知、自动升级这类功能,这套架构可以直接复用进去,省掉重复调研的时间。
本文还有配套的精品资源,点击获取