简介:Spring Boot与JavaFX的集成实例Demo,面向希望将Spring生态带入桌面应用开发的Java工程师,清晰解决两大框架协同启动、Bean管理与界面控制器注入等关键问题,同时覆盖从项目搭建到界面渲染的完整链路。资源共86个文件,压缩包仅98KB,包含41个Java源码、12个FXML界面布局、10个XML、9个YAML与4个Properties配置,还附有CSS样式、Maven包装器及README说明,目录按part_1至part_8划分多个子工程,覆盖不同集成环节,便于逐段研读与复用。已有1411人学习下载,适合具备Spring基础、正在探索JavaFX桌面端落地方式的开发者,也是学习桌面端依赖注入与界面解耦的直观样例。Demo中可完整观察Spring Boot自动配置与JavaFX生命周期的桥接方式,包括FXMLLoader加载FXML、Spring容器管理控制器、application.yml设置窗口标题与尺寸、多模块Maven组织依赖等实战细节;通过分析源码可进一步理解Spring管理非Web界面组件的设计思路,是快速上手桌面端Spring开发的实用参考。
1. Spring Boot 与 JavaFX 集成:这份 Demo 源码解决的是两套生命周期的冲突
Spring Boot 与 JavaFX 集成,听起来是把后端框架和桌面框架拼在一起,实际动手后你会发现,真正的麻烦不在 API 怎么调,而在它们各自掌管一条生命周期:Spring 容器不知道界面窗口何时创建,JavaFX 也不知道 Controller 里的 Bean 该去哪里找。这份集成 Demo 源码的价值,就是先把生命周期顺序理清,再把 FXML 控制器接入 Spring 容器,让 Service、Mapper 这类依赖在界面层直接注入。适合正在给内部系统做桌面工具、或者打算把现有 Spring Boot 服务复用到 JavaFX 前端的人拿去做骨架,而不是从头踩一遍集成坑。
2. 两套生命周期谁先启动:Spring 容器必须在 JavaFX 线程就绪前就位
2.1 为什么不能直接 new ApplicationContext
先看两个框架各自的启动方式。Spring Boot 的入口是main()里的SpringApplication.run(),跑完以后返回一个ConfigurableApplicationContext,之后 Bean 的创建、代理、装配都归它管。JavaFX 的入口是继承了Application的启动类,JavaFX Launcher 会先调用init(),再调用start(Stage),最后进入事件循环。这里有个容易忽略的细节:init()在 JavaFX Application Thread 启动之前执行,而start()里所有 UI 操作都运行在 FX 线程上。
很多集成 Demo 翻车,就是因为在main()里同时启动 SpringBoot 和 JavaFX,两个容器各自初始化,互相不知道对方存在。比如在 JavaFX 的start()里new FXMLLoader(),Controller 中@Autowired的 Service 全是空指针,因为 FXMLLoader 默认用反射创建 Controller 实例,和 Spring 容器毫无关系。常见做法是在 JavaFX 的init()阶段把 Spring 容器拉起来,容器就绪后再进入界面初始化,顺序对了,后面所有注入才能成立。
2.2 推荐启动顺序:init 里建容器,start 里拿 Bean
集成方式有三种,我拆过的模拟项目 X 用的是第一种,也是我通常会推荐的:
第一种,在 JavaFXApplication.init()里启动 Spring,把ApplicationContext存为静态或实例字段,start()里从容器拿 Scene 和 Controller。优点是 Spring 的启动过程不占用 FX 线程,初始化慢一点也无所谓,还避开了headless模式的干扰。第二种,在 Spring Boot 的main()里调用Application.launch(),逻辑上没问题,但 JavaFX 启动之后 Spring 还在做自动配置,两者并行会让排错变复杂。第三种,手工创建容器然后在 JavaFX 启动后手动装配,代码自由度最高,但也最容易遗漏关闭逻辑。
推荐第一种还有个实际原因:Spring Boot 默认会在无显示环境下切换成 headless 模式,如果容器在 JavaFX 之后启动,可能出现找不到图形设备或者字体加载异常。反过来,在init()里先启动,setHeadless(false)明确关闭无头模式,桌面环境就能正常工作。
2.3 最小骨架:一个能启动的启动类长什么样
下面是最小可跑的集成骨架。
public class Launcher extends Application { private ConfigurableApplicationContext context; @Override public void init() { // 在 JavaFX 线程就绪之前先把容器拉起来 SpringApplication app = new SpringApplication(AppConfig.class); app.setHeadless(false); context = app.run(); } @Override public void start(Stage primaryStage) throws Exception { // 从容器拿 loader,不是 new 一个裸 FXMLLoader SpringFxmlLoader loader = context.getBean(SpringFxmlLoader.class); Parent root = loader.load("/fxml/main.fxml"); primaryStage.setTitle("Spring Boot + JavaFX Demo"); primaryStage.setScene(new Scene(root, 1280, 800)); primaryStage.show(); } @Override public void stop() { // 窗口关闭后顺手关容器,避免非守护线程卡住 JVM 退出 context.close(); } public static void main(String[] args) { launch(args); } }逻辑说明:init()在 JavaFX Application Thread 启动之前被 Launcher 调用,适合做重量级初始化,Spring 容器在这里创建,后续start()直接复用。setHeadless(false)是让 Spring Boot 明确知道自己跑在桌面环境,避免自动探测到无显示器后禁用图形相关能力。stop()里关闭容器是个容易被忽略的动作,不关的话,数据库连接池或定时任务线程会让进程退出不干净。
参数说明:Scene(root, 1280, 800)里的尺寸可以根据目标机器调整,开发期建议用 1280x800,方便调试时摆放日志窗口;/fxml/main.fxml必须以/开头,表示从 classpath 根目录查找资源文件。launch(args)来自 JavaFX,它是真正的启动入口,千万不要在main()里再写一遍SpringApplication.run()。
配套的 Maven 依赖是另一个关键点。
<dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-controls</artifactId> <version>17.0.2</version> </dependency> <dependency> <groupId>org.openjfx</groupId> <artifactId>javafx-fxml</artifactId> <version>17.0.2</version> </dependency>逻辑说明:JDK 11 以后 JavaFX 已经从 JDK 中剥离,必须先单独引入依赖。javafx-controls提供 UI 控件和 Scene API,javafx-fxml提供 FXML 加载能力,两个都必须有。如果你用 JDK 17,17.0.2这个版本是稳定的;JDK 11 也可以跑,但模块系统限制更少,直接用 17 省事。
参数说明:如果用在打包场景,还需要考虑当前运行平台的 classifier,比如win、linux、mac,Maven 会根据系统自动选择,但交叉打包时要手动指定。
3. FXML 控制器接进 Spring:ControllerFactory 一行代码解决 Bean 注入
3.1 裸 FXMLLoader 拿不到 Spring Bean 的原因
先看一个典型错误。很多人写 JavaFX 项目时,会在代码里直接FXMLLoader.load(getClass().getResource("/fxml/main.fxml")),如果 FXML 里声明了fx:controller="com.example.MainController",Loader 就会用无参构造器反射创建这个 Controller,然后给@FXML标注的控件字段赋值。问题是,Spring 的@Autowired、构造器注入在这一步完全不会生效,Controller 里那些@Autowired的 Service 全是 null。运行时表现就是:界面能弹出来,但点击按钮调 Service 的时候直接抛空指针。
根本原因在于,FXMLLoader 和 Spring 容器之间没有桥。要让 Spring 来创建 Controller,就必须让 FXMLLoader 在需要 Controller 实例时去问 Spring 要,而不是自己new一个。这个桥就是setControllerFactory,它接收一个Callback,参数是需要实例化的类,返回值是 Spring 容器给出的实例。
3.2 实现方式:setControllerFactory 接 ApplicationContext
我一般会把这一步封装成一个SpringFxmlLoader组件,而不是每次都写一遍设置逻辑。
@Component public class SpringFxmlLoader { private final ConfigurableApplicationContext context; public SpringFxmlLoader(ConfigurableApplicationContext context) { this.context = context; } public Parent load(String fxmlPath) throws IOException { FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath)); // 关键点:让 Spring 容器接管 Controller 的创建 loader.setControllerFactory(type -> { try { return context.getBean(type); } catch (NoSuchBeanDefinitionException e) { // 不是 Spring Bean 的类,回退到默认反射创建 return createDefault(type); } }); return loader.load(); } private Object createDefault(Class<?> type) throws IOException { // 简单反射,保证 FXML 里如果声明了非受管类也能跑 return null; } }代码省略了反射实现的细节,重点是context.getBean(type)。逻辑说明:setControllerFactory设置之后,FXML 里fx:controller声明的类不再由 Loader 直接反射,而是走 Spring 容器。Spring 会完成依赖注入、AOP 代理等一系列工作,Controller 里就能放心写@Autowired了。捕获NoSuchBeanDefinitionException是为了提高容错,比如某些纯视图工具类没有注册成 Bean,回退默认路径还能跑,不至于整个页面加载失败。
参数说明:fxmlPath统一用/开头的 classpath 路径;getClass().getResource()拿到 URL,传给 FXMLLoader 构造函数之后,loader.load()内部会基于这个 URL 解析相对路径的资源。要注意的是,setControllerFactory必须在load()之前调用,顺序反了,前面加载出来的 Controller 还是走反射创建。
3.3 单例与原型:Controller 的 Scope 该怎么设
Controller 的 Bean 作用域是个容易埋雷的地方。Spring 默认所有 Bean 都是单例,但 JavaFX 中同一个 FXML 可能被加载多次,比如主窗口和设置窗口都引用同一个 Controller 类。如果它是单例,第一次加载后实例被缓存,第二次加载时拿到的还是同一个对象,界面字段会被互相覆盖,最典型的表现是打开设置窗口后,主窗口的表单被设置页的数据带乱。
我的处理习惯是给 FXML 对应的 Controller 显式标注原型作用域。
@Scope("prototype") @Component public class MainController { @Autowired private UserService userService; @FXML private TableView<UserModel> userTable; }逻辑说明:@Scope("prototype")告诉 Spring 每次获取该类型时都创建新实例,避免多窗口之间共享内部状态。Service、Repository 这类无状态组件保持默认单例就行,它们本来就该全局共享。参数说明:如果你用@Controller语义觉得别扭,JavaFX 场景下用@Component更直观,Spring 对名字没有硬性要求,关键是类型能被容器识别。
这里有个隐藏细节:loader.getController()拿到的对象和context.getBean(MainController.class)拿到的是同一个吗?在设置了setControllerFactory的前提下,是同一个,因为 Loader 的 Controller 就是通过容器创建的。如果你没设置工厂,getController()返回的是反射对象,和容器里的实例没有任何关系。所有依赖注入和状态共享的代码,都只在设置了工厂的前提下成立。
4. 多场景切换与后台任务:别再用裸 FXMLLoader,统一走 Spring 上下文
4.1 多场景切换的 View 由谁统一加载
一个完整的桌面应用很少只有一个窗口,登录页、主面板、详情页,每个页面都有自己的 FXML 和 Controller。如果每个页面切换的地方都写FXMLLoader.load(),就会出现三种典型问题。第一,ControllerFactory 设置不统一,有的地方忘了设,Spring 注入时灵时不灵。第二,多个 Loader 各自管理实例,页面之间的全局状态没法共享。第三,切换页面的代码重复,后期维护时想加一个全局 CSS 或者统一的窗口标题都要到处改。
正确做法是封装一个SceneManager,所有页面切换都通过它走,ControllerFactory 只写一次,Controller 的获取和 Spring 容器保持一致。这样无论是@Autowired注入、事件发布还是 AOP 拦截,都能正常工作。
4.2 封装 SceneManager 的传参与注意事项
下面是一个简化版 SceneManager。
@Component public class SceneManager { private final ConfigurableApplicationContext context; private Stage primaryStage; public SceneManager(ConfigurableApplicationContext context) { this.context = context; } public void switchTo(String fxmlPath, String title, double width, double height) throws IOException { FXMLLoader loader = new FXMLLoader(getClass().getResource(fxmlPath)); loader.setControllerFactory(type -> context.getBean(type)); Parent view = loader.load(); Scene scene = new Scene(view, width, height); // 全局样式,切换页面时保持视觉风格一致 scene.getStylesheets().add(getClass().getResource("/css/app.css").toExternalForm()); primaryStage.setTitle(title); primaryStage.setScene(scene); primaryStage.show(); } public void bindPrimaryStage(Stage stage) { this.primaryStage = stage; } }逻辑说明:switchTo接收 FXML 路径、窗口标题、宽高四个参数,每次切换都创建一个新 Scene,通过setScene替换当前视图。和直接new FXMLLoader的区别是,这里的 ControllerFactory 统一从 Spring 拿 Bean,页面间的跳转不破坏依赖注入链路。scene.getStylesheets().add()是加全局样式的标准位置,切页只需要改一处。
参数说明:width和height我建议在应用启动时读配置文件,而不是散落在各个调用点,方便后续适配不同屏幕分辨率。bindPrimaryStage在主启动类里调用一次,让 SceneManager 持有 Stage 引用,之后切页就不用把 Stage 传来传去了。如果页面需要回传数据,可以通过 Spring 事件或者上下文对象,不建议直接把 Controller 实例暴露给调用方,原因在 3.3 里说过。
4.3 后台任务与 Platform.runLater 的使用边界
桌面应用里耗时操作很常见:导入 Excel、调用远程接口、生成报表。如果在 FX 线程里直接跑,界面会直接卡住,窗口拖不动,操作系统还提示程序未响应。JavaFX 提供的解法是Platform.runLater(Runnable),它会把你给的代码块提交到 FX 线程队列里执行,耗时操作放到后台线程,结果更新塞回runLater。
@Service public class ImportService { public void importAsync(String filePath) { new Thread(() -> { List<Row> rows = readRows(filePath); // 更新 UI 必须回到 FX 线程 Platform.runLater(() -> { statusLabel.setText("完成,共 " + rows.size() + " 行"); }); }).start(); } }逻辑说明:耗时逻辑和 UI 更新被拆到了两个线程,后台线程处理数据,Platform.runLater负责切回 FX 线程操作控件。注意不要在这里直接用 Spring 的@Async注解而不配合@EnableAsync,不然它还是同步执行,等于白写。参数说明:new Thread(...).start()是最朴素的后台任务写法,项目里如果已经有线程池,可以用ExecutorService代替,原理一致。
这里有个集成层面的坑:直接提交runLater时如果窗口已经关闭,Platform.runLater会异常,需要先判断Platform.isFxApplicationThread()或者捕获异常。另一个坑是线程里访问statusLabel这种@FXML字段必须等 FXMLLoader 加载完成,否则对象还没创建就是空指针。所有@FXML控件的赋值都发生在initialize()之后,后台线程回传数据也一样,确保先有界面再有回调。
5. 集成实战常见问题排查:四条踩坑记录与对应修法
5.1 容器为 null:start 里还在用主线程 new context
现象:启动类在start()里调用context.getBean(),结果拿到的context是 null,界面还没弹出来就直接空指针崩溃,日志里只有一行NullPointerException,没有任何 Spring 启动的 Banner。
原因:有人把 Spring 容器启动写在start()里,而start()是在 FX 线程上执行的,容器启动需要扫描配置类和加载 Bean,这个过程还没跑完,start()后面跟着的代码就已经在调getBean()了。更早的启动顺序错误是在main()里先执行SpringApplication.run(),再执行launch(),两者各自的线程边界没对齐,容器对象和 JavaFX 生命周期完全脱节。
修法:把容器创建挪到Application.init()里,init()在 JavaFX 的 Launcher 线程上执行,且早于start(),Spring 容器在这里初始化完毕后,start()里直接使用字段引用就行。
5.2 Service 注入为 null:ControllerFactory 设晚了一步
现象:Controller 里的@Autowired UserService编译正常,运行时点击按钮调userService.listUsers()抛 NPE。断点打在 Controller 的initialize()里,看到userService确实为 null,但@FXML控件字段是正常赋值。
原因:FXML 加载时没有调用setControllerFactory,Loader 走了默认反射流程。默认流程只负责创建 Controller 实例和填充@FXML字段,Spring 的依赖注入完全没机会执行。这种问题在界面上看不出异常,界面能正常渲染,因为@FXML字段由 JavaFX 维护,一到业务方法就露馅。
修法:在loader.load()之前调用setControllerFactory(type -> context.getBean(type)),并且确认context已非空。建议把这一句收敛到SpringFxmlLoader里统一处理,不要在每个调用点手写。
5.3 JDK 11 以后 JavaFX 不再是标配依赖
现象:代码在 JDK 8 上运行正常,换到 JDK 17 后启动直接报NoClassDefFoundError: javafx/application/Application,或者 IDE 里 JavaFX 相关类标红,编译都过不去。
原因:JDK 11 起 JavaFX 从 JDK 中移除,不再是标准库的一部分。如果你的项目没有在 Maven/Gradle 中显式引入 JavaFX 依赖,运行环境里就找不到javafx.application.Application这个类。这和 Spring Boot 版本无关,纯粹是平台依赖缺失。
修法:引入org.openjfx:javafx-controls和org.openjfx:javafx-fxml,版本和你使用的 JDK 大版本对齐。如果用了模块化构建,module-info.java里还要加requires javafx.controls;和requires javafx.fxml;。非模块化项目依赖声明就够了。
5.4 资源路径问题:FXML 找不到 / 打包后白屏
现象:开发环境运行正常,用 Maven 打成可执行 Jar 后双击运行,界面白屏,控制台报NullPointerException或Resource not found: /fxml/main.fxml。
原因:FXML 和 CSS 文件在开发时由 IDE 直接读源码目录,打包后它们会被压进 Jar 的根目录。如果加载路径写成相对路径,比如"fxml/main.fxml",从 Jar 里加载时定位不到资源。另外,如果Launcher类和 FXML 不在同一个包,相对路径的计算结果也可能不对。
修法:统一用/fxml/main.fxml这种以/开头的绝对 classpath 路径,开发和生产环境行为一致。打包时确认src/main/resources下的 fxml、css、图片资源被包含进 Jar,Maven 默认包含,但如果你对resources配了 exclude,就会被漏掉。检查命令是解压 Jar 后看BOOT-INF/classes目录下有没有对应文件。另一个容易被忽视的是,Spring Boot 的 fat Jar 结构比较特殊,JavaFX 加载getResource走的是嵌套 Jar,某些打包插件需要额外的资源处理,建议用maven-shade-plugin或jpackage这类方案直接生成可运行包,别指望java -jar一条路走到底。
6. 进阶技巧:用 Spring 事件把后台结果安全推回 JavaFX 界面
6.1 事件推送的代码结构
写桌面应用时,我最受益的一个技巧,是让 Spring 的事件机制来桥接后台任务和 UI 更新。Platform.runLater只是最底层的工具,真正代码多了以后,到处塞runLater会变得很乱,而且 Service 层不该直接依赖 JavaFX 的类。用 Spring 事件把两者解耦,Service 只负责发事件,UI 监听器拿到事件后统一回 FX 线程更新界面。
// 事件类 public class ImportFinishedEvent { private final int rowCount; public ImportFinishedEvent(int rowCount) { this.rowCount = rowCount; } public int getRowCount() { return rowCount; } } // 监听器 @Component public class ImportEventListener { @EventListener public void onImportFinished(ImportFinishedEvent event) { Platform.runLater(() -> statusLabel.setText("导入完成:" + event.getRowCount() + " 行")); } }逻辑说明:ImportFinishedEvent是普通 POJO,Service 处理完数据后发布它,ImportEventListener收到事件后把 UI 更新逻辑包在Platform.runLater里。Service 不认识 JavaFX 的类型,监听器也不碰业务逻辑,两边只对接一个事件。这个模式的额外好处是,多个窗口可以同时监听同一事件,同一个后台任务可以刷新多个界面区域。
6.2 一个必须养成的自检习惯
用这套架构后,我给自己定了个习惯:每次开工前先跑一遍启动链路自检,确认容器能创建、ControllerFactory 生效、资源路径正确,再谈界面布局。具体动作是启动应用后立刻查看launcher.log里 Spring 的 Banner 是否早于窗口标题输出,如果是,顺序就是对的;断点到 Controller 的initialize(),看userService是否已经注入。如果 Controller 工厂没接对,这个断点看到的永远是 null。从那以后,我每次做 Spring Boot 加 JavaFX 的集成工程,都会强制先过一遍「启动顺序到 ControllerFactory 到场景切换」这条链路,再开始写界面,集成问题基本不会留到上线前才爆出来。希望帮到你。
本文还有配套的精品资源,点击获取