JavaFX+SpringBoot脚手架:启动顺序与依赖注入实践
简介基于JavaFx与SpringBoot的快速开发脚手架设计源码包面向需要同时构建桌面客户端与后端服务的Java开发者尤其适合在JDK11环境下希望降低初始配置成本的中高级程序员。整套资源共28个文件压缩包约921KB包含7个Java源文件、4张PNG图片、2个Markdown文档、2个ICO图标、配置属性文件、YAML配置、Git忽略文件、JAR包以及Maven Wrapper脚本等并额外附有界面截图、演示动图和部署目录便于直观了解项目结构与运行效果。当前已有289人学习/下载。脚手架特别兼容非SpringBoot版本目录针对源码、测试、资源、部署做了清晰划分配合简洁说明文档与Gitee工单/合并请求模板方便团队协作和二次开发界面素材与FXML布局文件也一并提供便于调整客户端外观。整体上这套源码包可作为JavaFx与SpringBoot整合的轻量级基座既适合个人快速搭建桌面应用原型也适合作为团队内部项目起步模板能明显缩短从零创建项目和配置运行环境的时间。1. 为什么 JavaFX SpringBoot 脚手架要先解决“启动顺序”JavaFX 和 Spring Boot 是两套独立生命周期JavaFX 的Application管理窗口、场景和 FXMLSpring Boot 管理 Bean、配置和依赖注入。硬拼在一起时最常见的是Autowired的字段在 Controller 里是 null或者 Controller 必须用new创建业务逻辑全都绑死在界面类里。脚手架的价值不是把两套代码放进同一个 Maven 工程而是把启动顺序和装配方式固定下来让后面的人只写业务不需要每次研究FXMLLoader和ApplicationContext怎么握手。一套能称为设计源码的 JavaFx SpringBoot 脚手架至少要回答四个问题谁先启动、Controller 由谁创建、业务 Bean 如何暴露给界面层、打包后还能不能正常加载 FXML。这四个问题不解决仓库里的代码只是跑通一个空白窗口加第二个页面就开始来回传参。这篇从启动顺序讲起给出 pom 配置、控制层代码、自动配置和打包命令最后用一个登录校验例子验证注入链路。适合写内部工具、桌面客户端和需要把 Spring 生态复用给 JavaFX 的开发者。2. 架构设计让 Spring 容器接管 JavaFX 的启动与装配2.1 启动顺序Spring Boot 先建容器JavaFX 再显示窗口桌面应用里我最常踩的坑是先SpringApplication.run再Application.launch结果launch阻塞主线程Spring Boot 的ApplicationReadyEvent还没触发窗口已经在闪。反过来先launch然后在init()里启动 Spring Boot可以让容器先准备完成start()只负责渲染界面这个顺序对用户观感和排错都更友好。SpringBootApplication public class MainLauncher { public static void main(String[] args) { // 跳过 Spring Boot 对 java.awt.headless 的自动判断 System.setProperty(java.awt.headless, false); Application.launch(FxBootApplication.class, args); } }public class FxBootApplication extends Application { private ConfigurableApplicationContext context; Override public void init() { // 在 JavaFX 界面线程启动前把 Spring 容器建起来 context new SpringApplicationBuilder(MainLauncher.class) .headless(false) .run(getParameters().getRaw().toArray(new String[0])); } Override public void start(Stage stage) throws IOException { // controller 由 Spring 容器托管构造阶段就完成依赖注入 FXMLLoader loader new FXMLLoader(getClass().getResource(/view/main.fxml)); loader.setControllerFactory(context::getBean); Parent root loader.load(); stage.setTitle(JavaFx SpringBoot 脚手架); stage.setScene(new Scene(root, 1280, 800)); stage.show(); } Override public void stop() { context.close(); } }这段代码的关键是new SpringApplicationBuilder(MainLauncher.class)它把标注了SpringBootApplication的启动类作为配置源组件扫描从该类所在包开始。getParameters().getRaw()把Application.launch收到的命令行参数原样交给 Spring Boot后面写--server.port、--app.foobar都能被正常解析。.headless(false)必须显式设置Spring Boot 在无图形环境下会自动打开java.awt.headlesstrue某些依赖 AWT 的桌面场景会出问题。init()在 JavaFX 应用线程创建之前执行适合放耗时初始化不会阻塞界面。start()里loader.setControllerFactory(context::getBean)是最关键的桥接FXMLLoader不再自己反射创建 controller而是问 Spring 容器要实例。2.2 Spring 容器与 JavaFX 生命周期如何握手FXMLLoader默认用反射机制创建 Controller这个实例并不存在于 Spring 容器中Autowired属性自然拿不到值。使用controllerFactory后Spring 会先按类型找 Bean找不到就抛NoSuchBeanDefinitionException。因此 Controller 必须能被组件扫描覆盖通常直接加Component或Controller。下面这张表对应了 JavaFX 生命周期和脚手架的职责JavaFX 方法执行线程脚手架里做的事情init()JavaFX 启动线程创建 Spring 容器读取application.ymlstart(Stage)JavaFX Application Thread加载 FXML设置主窗口并显示stop()JavaFX Application Thread关闭 Spring 容器释放数据库连接和线程池这个顺序能保证界面事件触发时Service、Mapper、配置对象都已经在容器里就绪。如果改成在start()里启动 Spring虽然也能跑但init()里不能做的事会被推迟到 UI 线程窗口出现前反而多等一次启动过程。2.3 模块划分让设计源码不是一团乱麻脚手架源码和普通 demo 的最大区别是模块边界。我一般按四个 Maven 模块拆demo-framework放自动配置和窗口基类demo-ui放 FXML 和 Controllerdemo-service放业务逻辑demo-launcher放启动入口和配置文件。模块依赖关系关键内容demo-framework不依赖业务JavaFxAutoConfiguration、WindowPropertiesdemo-ui依赖 frameworkFXML、Controller、CSSdemo-service依赖 frameworkService、Repository、DTOdemo-launcher依赖 ui 和 serviceMainLauncher、application.yml依赖方向是 UI 调 ServiceService 不碰 JavaFX 的Stage、Scene类型。这样写单元测试时不需要启动桌面环境后续想换掉 JavaFX 只改 UI 和 framework 两层。反过来如果 Controller 里直接写 SQL脚手架就只剩一个空壳。3. 最小可跑脚手架核心代码与关键参数3.1 pom.xml 依赖与版本参数脚手架的第一步是把依赖和版本参数固定下来。我的最小pom.xml通常这样parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version17/java.version javafx.version17.0.6/javafx.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency dependency groupIdorg.openjfx/groupId artifactIdjavafx-controls/artifactId version${javafx.version}/version /dependency dependency groupIdorg.openjfx/groupId artifactIdjavafx-fxml/artifactId version${javafx.version}/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-configuration-processor/artifactId optionaltrue/optional /dependency /dependencies这里只引入spring-boot-starter没有引入spring-boot-starter-web。桌面应用不需要内嵌容器引入 web starter 会白白多出 Tomcat 和一堆自动配置启动慢且日志噪音大。javafx-controls和javafx-fxml的版本必须一致并且与 JDK 大版本对齐JDK 17 配 JavaFX 17JDK 21 配 JavaFX 21。spring-boot-configuration-processor是可选依赖不加也能跑但 IDE 对application.yml的提示会丢失。3.2 启动类不要写成两个 main第一次写 JavaFX Spring Boot 的人常会建一个 Spring Boot 主类再写一个 JavaFX 主类两个都有main运行时完全看运气。我的约定是只有MainLauncher有main它负责把 JVM 交给 JavaFXFxBootApplication负责运行期桥接。SpringBootApplication public class MainLauncher { public static void main(String[] args) { System.setProperty(java.awt.headless, false); Application.launch(FxBootApplication.class, args); } }public class FxBootApplication extends Application { private ConfigurableApplicationContext context; Override public void init() { context new SpringApplicationBuilder(MainLauncher.class) .headless(false) .run(getParameters().getRaw().toArray(new String[0])); } // start、stop 与上文相同 }IDE 的 main 类始终是MainLauncherSpring Boot 插件的启动类也指向它。命令行调试时不用区分先启动谁因为Application.launch会回调init()Spring Boot 容器随即建立。3.3 Spring Controller 与 JavaFX Controller 绑定Controller 需要被 Spring 管起来才能把LoginService这样的 Bean 注入进去。Component public class LoginController { FXML private TextField usernameField; FXML private PasswordField passwordField; Autowired private LoginService loginService; FXML private void onSubmit() { boolean ok loginService.check(usernameField.getText(), passwordField.getText()); passwordField.setText(ok ? 验证通过 : 验证失败); } }Component让类进入 Spring 容器FXML字段和onSubmit方法由 JavaFX 在加载 FXML 时完成绑定Autowired由 Spring 在 Bean 创建时完成注入两套机制互不干扰。要注意 FXML 里的fx:controller必须写成该类的全限定名例如com.example.ui.LoginController。如果发现注入为 null先查 controller 是不是被 Spring 扫描到再查loader.setControllerFactory是否真的被调用。3.4 配置项窗口尺寸和 FXML 路径怎么读桌面应用也有可配置的东西窗口大小、标题、FXML 根路径、缓存开关。用application.yml统一管理app: window: width: 1280 height: 800 title: 脚手架示例 ui: fxml-root: /view对应读取配置的类Component ConfigurationProperties(prefix app.window) public class WindowProperties { private int width 1024; private int height 768; private String title JavaFX App; // getter / setter 省略 }ConfigurationProperties会把app.window.width自动绑定到属性上。没有EnableConfigurationProperties时类上必须带Component才能被扫描到。下面的表格是三个常用参数参数默认值作用app.window.width1024设置窗口初始宽度app.window.height768设置窗口初始高度app.window.titleJavaFX App设置窗口标题可在start()中读取注意 Spring Boot 2.7 与 3.x 对ConfigurationProperties扫描机制有差异3.x 推荐配合ConfigurationPropertiesScan。如果发现配置没生效检查这一步。4. 把脚手架做成“脚手架”自动配置、资源加载与打包4.1 自动配置与条件装配脚手架要有“拿来即用”的感觉不能每次把FxBootApplication复制一遍。常见做法是把窗口属性、页面路由、启动自检做进一个starter模块然后通过自动配置暴露。AutoConfiguration ConditionalOnClass({FXMLLoader.class, Stage.class}) public class JavaFxAutoConfiguration { Bean ConditionalOnMissingBean public WindowProperties windowProperties() { return new WindowProperties(); } Bean ConditionalOnMissingBean public FxmlViewResolver fxmlViewResolver(WindowProperties props) { return new FxmlViewResolver(props); } }ConditionalOnClass表示只有 classpath 里有 JavaFX 类时才装配ConditionalOnMissingBean允许业务工程用自己的实现覆盖默认值。Spring Boot 2.7 之后自动配置类要写在META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports文件里一行一个全限定类名com.example.framework.JavaFxAutoConfiguration如果是 2.7 之前的版本则写在META-INF/spring.factories。这一步决定你的基础能力能否被业务工程直接感知而不是靠ComponentScan去碰运气。还有一点要注意自动配置类不应放在启动类同包的扫描路径下否则会被同时通过ComponentScan和AutoConfiguration.imports加载Bean 会被创建两次。4.2 资源加载FXML 和 CSS 放在哪里最稳JavaFX 的资源加载有个小坑IDE 里直接跑没问题一旦打成模块化镜像getResource的路径可能变。我一般把 FXML 放在src/main/resources/view然后通过 Spring 的ClassPathResource加载ClassPathResource fxmlResource new ClassPathResource(view/main.fxml); FXMLLoader loader new FXMLLoader(fxmlResource.getURL());view/main.fxml是 classpath 根目录下的相对路径不要写成/view/main.fxml也尽量不要硬编码本地磁盘路径。CSS 和图片同理放在resources下打包后由 fat jar 内部的 classpath 资源提供。这样 jpackage 制作 app-image 时不需要额外复制资源目录。4.3 用 jpackage 打包成免环境客户端JavaFX 版本 17 之后项目可以用jpackage一键打包省掉用户手动装 JRE 的环节。我的打包命令通常分两步mvn clean package -DskipTests JAVA_FX_HOME/path/to/javafx-sdk-17 jpackage \ --type app-image \ --input target \ --name demo-client \ --main-jar demo-client-1.0.jar \ --main-class com.example.MainLauncher \ --module-path $JAVA_FX_HOME/lib \ --add-modules javafx.controls,javafx.fxml \ --java-options -Xmx512m--input target目录下要有可执行 fat jar--main-jar指向它--main-class必须与启动类一致。--module-path指向 JavaFX SDK 的 lib--add-modules指定要带上哪些模块。Windows 上把--type app-image改成--type msi可以生成安装包但需要额外下载 WiX 工具。参数含义常见错误--input待打包目录漏掉依赖 jar运行时 NoClassDefFoundError--module-pathJavaFX SDK 路径路径错误导致 JavaFX 类找不到--add-modules需要链接的 JavaFX 模块漏javafx.fxml加载 FXML 失败打包出来的是一个目录形式的 app-image可以先直接运行可执行文件确认再决定是否做安装包。这个过程能验证脚手架的资源加载和 Spring Boot 启动是否真的脱离了 IDE 环境。5. 进阶与排错从最小 demo 走向可维护工程5.1 版本不兼容的三类典型问题症状原因处理UnsupportedClassVersionErrorjavafx.version 与 java.version 不匹配将两者对齐到同一 major versionNoClassDefFoundError: javafx/...打包时没有带上 JavaFX 模块jpackage 添加--add-modulesLoadException/ FXML 头异常FXML 头部版本与实际运行版本不一致统一 javafx 版本并 clean 后重打包还有一类比较隐蔽springboot 版本太高而某些第三方 JavaFX 集成库还停留在旧 API。解决办法是自己用controllerFactory做注入不依赖那些“SpringBoot 集成 JavaFX”的中间包减少兼容面。Spring Boot 从 2.7 升级到 3.x 后自动配置文件名变了旧脚手架里的spring.factories也要一并迁移。5.2 线程边界不在 UI 线程里查数据库JavaFX 要求所有 UI 修改都发生在 JavaFX Application Thread 上。直接在事件方法里执行耗时的服务调用窗口会卡死。Spring Boot 的 Service 通常是同步方法所以要让后台任务自己切线程TaskUser task new Task() { Override protected User call() { // 后台线程执行耗时查询 return userService.findById(userId); } }; task.setOnSucceeded(e - userLabel.setText(task.getValue().getDisplayName())); new Thread(task).start();call()运行在新的后台线程setOnSucceeded的回调自动回到 JavaFX Application Thread不需要手动Platform.runLater。如果是简单场景也可以直接在后台线程里Platform.runLater(() - userLabel.setText(user.getDisplayName()));但频繁调用Platform.runLater会让事件队列堆积所以能合并成一次 UI 更新的时候尽量用Task的回调。这个习惯写在脚手架基类里后续所有人写异步逻辑都不会跑偏。5.3 验证脚手架是否健康脚手架搭建完成后需要一条快速验证链路。我通常在启动入口附近放一个自检组件Component Slf4j public class RuntimeHealthIndicator { EventListener(ApplicationReadyEvent.class) public void ready() { // 容器启动完成后打印关键信息方便确认链路 log.info(UI 脚手架 ready, thread: {}, Thread.currentThread().getName()); log.info(Beans count: {}, SpringContextHolder.beanCount()); } }ApplicationReadyEvent是 Spring Boot 启动完成的标志在监听器里打印 Bean 数量和当前线程名能一眼看出容器是否在工作。还可以在启动命令加--debug看自动配置的匹配过程排查某个ConditionalOnXxx为什么没生效。如果不想每次看到 Spring Boot 启动横幅把spring.main.banner-mode设为off需要定制时也可以用在线 banner 生成器做一份 ASCII 图案放到src/main/resources/banner.txt里启动日志立刻就能识别版本和环境。6. 用一个登录校验例子验证脚手架的依赖注入6.1 Spring Service 贯穿 JavaFX 的登录动作前面几章的代码都是为了一个目标Controller 里能直接拿到 Spring 管理的 Service。下面这个例子是最小验证也是实际系统里的常见模块。Component public class LoginController { FXML private PasswordField passwordField; Autowired private LoginService loginService; FXML private void onSubmit() { boolean ok loginService.check(admin, passwordField.getText()); passwordField.setText(ok ? 登录成功 : 认证失败); } }对应的LoginService不需要任何 JavaFX 依赖Service public class LoginService { public boolean check(String username, String password) { // 这里可以换成调用 Spring Data JPA 或 Redis return admin.equals(username) password.equals(password); } }运行MainLauncher后输入账号密码并点击提交只要看到界面文字变成“登录成功”说明loader.setControllerFactory(context::getBean)已经起作用JavaFX 负责按钮事件Spring 负责把LoginService注入到LoginController。如果这一步出现NullPointerException先看LoginController类上是否有Component再看 FXML 的fx:controller是否写成了带包名的完整路径。同一个类如果同时被Component和 FXML 反射创建Spring 容器里的 Bean 不会自动替换 FXML 实例必须通过controllerFactory打通。更稳妥的做法是把onSubmit返回值设计成只写 UI 的固定接口例如让 Service 返回LoginResult数据对象Controller 只负责把数据放进控件。这样LoginService的单元测试完全不依赖桌面环境脚手架的业务边界也就立住了。本文还有配套的精品资源点击获取