
简介JavaFXJfoenix系列学习笔记十配套源码面向需要掌握桌面托盘交互与中文乱码处理的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不直接给个API1.1 JavaFX的“桌面集成”短板JavaFX 定位是富客户端界面框架它在窗口、控件、动画、CSS 样式上做得非常到位但托盘属于“操作系统级”的桌面集成能力JavaFX 官方一直没有提供跨平台封装。如果你翻过源码会发现javafx.stage.Stage可以设置图标、设置窗口类型但没有任何一个方法能让你往系统通知栏塞一个常驻图标。早期很多项目用 Swing 的SystemTray因为 Swing 和 AWT 绑在一起java.awt.SystemTray也是 JDK 里唯一官方支持的托盘入口。JavaFX 项目引入 AWT 类并不冲突两者可以共存只要注意线程调度即可。我见过不少人为了避免 AWT 引入额外复杂性用纯 JavaFX 加第三方库去实现托盘反而踩更多坑。实际项目里最稳妥的方案还是java.awt.SystemTrayjava.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-8Properties 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 头部声明确认encodingUTF-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.encodingUTF-8如果项目用 Maven 或 Gradle 启动在插件配置里也加上。如果是打 jar 包后用命令行启动可以写一个启动脚本顺便把控制台代码页切到 UTF-8echo off chcp 65001 java -Dfile.encodingUTF-8 -jar app.jarchcp 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.encodingUTF-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.encodingUTF-8若仍乱码确认系统装了中文字体点击托盘图标窗口不恢复事件回调不在 FX 线程用Platform.runLater()包裹窗口操作同时在启动处调用Platform.setImplicitExit(false)关闭窗口后进程还在但托盘点击无反应FX 线程已退出检查关闭事件是否consume()确认是否调用了Platform.setImplicitExit(false)打包成 exe 后中文全变问号打包工具未带上 JVM 编码参数jpackage 加--java-options -Dfile.encodingUTF-8启动器脚本里先chcp 65001Jfoenix 按钮文字虚、像方块默认 Roboto 字体不包含中文字形全局设置-fx-font-family: Microsoft YaHei或加载自定义中文字体控制台日志中文乱码界面正常JVM 输出编码和控制台代码页不一致启动脚本chcp 65001-Dfile.encodingUTF-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.encodingUTF-8、根 CSS 直接写中文字体族、并提前规划托盘安装和退出路径。等这套固定流程跑完后面写业务逻辑就再也不会被这些基础问题打断。系统托盘和中文乱码解决完后如果你正准备做消息通知、自动升级这类功能这套架构可以直接复用进去省掉重复调研的时间。本文还有配套的精品资源点击获取