JavaFX启动界面实战:自绘Stage、真实进度与淡出交接

发布时间:2026/11/5 10:23:37
JavaFX启动界面实战:自绘Stage、真实进度与淡出交接

1. 先把启动界面这件事拆干净

JavaFX 启动界面看着是个小事:一个 600×380 的窗口,一张图、一根进度条、一行状态文字,半天画完。真动手做才发现,难的不是画,是它出现和消失的那两个瞬间——窗口出现时不能白屏、不能闪烁、不能盖不住别的窗口;窗口消失时不能卡在 100%、不能一关就把整个应用带退出、不能"闪一下就没了"让人以为程序崩溃。我在两个桌面工具项目里都把这块重写过,前后踩了十来个坑,这篇就把一套能直接抄的方案摊开讲,从选型到窗口外壳、线程模型、样式表、淡出交接,代码都是能跑的完整版本。

适合谁看:已经能用 JavaFX 拼出普通窗口、想让自己的桌面工具冷启动时别那么寒酸的开发者;如果你还没碰过 JavaFX,前三节的思路拆解也能看懂,第四节开始需要一点基础。标题里说的"类似 IDE 启动界面",指的就是那种居中、无边框、深色底、一根细进度条配一行状态文字的组合,下文我统一叫它 IDE 风格启动窗口。

1.1 一个 IDE 风格启动窗口到底由哪几层构成

把任何一个成熟 IDE 的启动界面截图放大看,结构其实高度趋同。视觉层:一块带圆角的深色面板,中间是产品 Logo 加名称,底下一条很细的进度条,进度条下方一行浅灰的状态文字,右下角或者左下角挂个版本号。交互层:窗口不可缩放、不显示系统标题栏、不能被随手关掉、通常会置顶,允许用户拖着跑但松手后不会记忆位置。数据层:进度条背后必须有一个真实的耗时任务链,而不是一个匀速跑的假动画。时序层:窗口先出现,然后在它背后并行做初始化,初始化结束后再切主窗口,中间有一段刻意设计的淡出。

这四层里,最容易被忽视的是第四层。很多人把注意力全放在"怎么让进度条好看"上,结果做出来的东西进度条跑到 100% 停了 800 毫秒,或者主窗口都弹出来了启动窗口还赖着,体验反而更差。我在第二个项目里专门给这块加了一段"最小展示时长 + 淡出"的逻辑,才算是把观感拉回来。

1.2 别忽略一个事实:你做的启动窗口接的是第二棒

这是我最想提前说清楚的一点。JavaFX 应用的冷启动时间线大致是这样:JVM 起来(几百毫秒到一秒)→ 加载 JavaFX 运行时和模块(又是几百毫秒)→ 你的Application.start()被调用 → 第一帧渲染。你自己写的那个 JavaFX 启动窗口,出现在第四步,前面那 1 到 2 秒里,用户看到的是一片空白或者一个还没响应的图标。

所以如果你追求的是"双击图标之后立刻有反馈",只有 JavaFX 层的启动窗口是不够的。可行的补充手段是在 JVM 层面挂一张静态启动图,让系统在进程启动阶段就显示出来,等到 JavaFX 窗口出现时它自动消失。这套组合拳我在第 5 节会写清楚。想清楚这件事,你就不会把期望全压在一个 Stage 上,也就不会为了"让 JavaFX 尽早出现"去做各种激进的懒加载,反而把启动搞崩。

2. 三条技术路线,我为什么选了最"笨"的那条

JavaFX 圈子里做启动界面,主流有三条路:一是官方给的Preloader机制,二是 JVM 的-splash参数,三是自己new一个Stage出来当启动窗口。三条路各有各的适用场景,不是谁一定比谁好,但在我这个需求下结论很明确。

2.1 三条路线的能力对照

维度自绘 StageJavaFX PreloaderJVM-splash
出现时机JavaFX 初始化完成后在 Application 之前进程启动阶段
视觉自由度完全自由,任意布局与动效较高,但受 Preloader 生命周期约束只能是一张静态图
能否显示真实进度能能,但需要跨进程/线程通信不能
能否做淡出交接能,完全可控有限,BEFORE_START时直接隐藏自动消失,无过渡
调试难度低,就是个普通窗口中,配置错一次很难查低但信息少
打包后可靠性高中,依赖启动参数平台差异明显

Preloader的定位其实是给"应用启动前要做重活"的场景设计的:它会在主Application实例化之前被拉起,然后通过PreloaderNotification双向通信。听着很美好,实际用起来有两个现实问题。第一,它必须靠启动参数或者构建插件显式指定类名,一旦打包方式变了(比如从 javafx-maven-plugin 切到 jpackage),这条链很容易断,而且断了之后往往静默降级成"没有启动窗口",你根本不知道发生了什么。第二,Preloader 拿到的Stage生命周期由框架管着,你想在它上面做复杂的淡出动画、想控制它和主窗口的交接时机,会发现处处受限。

2.2 自绘 Stage 的优势与代价

自绘 Stage 说白了就是:Application.start()里先show()一个启动窗口,同时起一个后台线程干活,干完活再show()主窗口、关掉启动窗口。它没有任何魔法,好处是每一个环节都在你手里——什么时候出现、什么时候消失、出现多久之后再淡出、淡出多少毫秒,全是显式代码,出问题一眼能定位。

代价有两个。第一,它比 Preloader 晚出现,因为start()本身就要等 JavaFX 初始化完。第二,你要自己处理"关闭启动窗口会不会把应用退出"这种 JavaFX 的隐式行为(下一节细讲)。这两个代价我认为完全可接受:第一个用 JVM 启动图补,第二个就一行Platform.setImplicitExit(false)。

所以我的方案是:JVM 启动图兜底 + 自绘 Stage 做主角 + Preloader 作为可选增强。这个组合既保证了从双击图标开始就有反馈,又保留了完整的视觉与动效控制权。

3. 核心机制:线程模型和进度回传

这一节是整个方案的地基。JavaFX 的线程模型理解歪了,后面所有代码都会在你意想不到的地方出问题。

3.1 JavaFX 只有一根 UI 线程,这是硬约束

JavaFX 的所有 UI 操作必须在JavaFX Application Thread上执行,也就是Application.start()被调用的那根线程。你在别的线程里改Label.setText(),运气好不报错但界面不刷新,运气差直接抛IllegalStateException: Not on FX application thread。而那根线程同时还在负责渲染循环(pulse),你让它做耗时操作,整个窗口就冻住——这正是"启动界面白屏"的最常见原因。

正确的分工是:UI 线程只负责摆控件和收消息,耗时任务全部丢到后台线程,通过线程安全的属性回传状态。JavaFX 为这个模式准备了一套现成的工具:Task和Service。Task的progressProperty()和messageProperty()都是可以从任意线程update的,内部会自动转到 FX 线程,UI 侧直接bind上去就行。这套机制比我早年用Platform.runLater手动往 UI 线程塞消息要干净得多,也避免了高频runLater把事件队列刷爆。

3.2 进度不是装饰品,别用假动画糊弄

见过不少实现,进度条绑一个Timeline,2 秒匀速从 0 走到 1,跟背后真实发生了什么毫无关系。这么做的问题不是"不真实"这么玄乎,而是它会骗人:如果初始化实际只用了 400 毫秒,用户会盯着那根条走完 2 秒,觉得这软件又慢又假;如果实际用了 8 秒,进度条到 100% 之后卡住 6 秒,用户第一反应是"卡死了",第二反应是强杀进程。

我的做法是把初始化拆成若干个命名清晰的阶段,每个阶段结束时报一次进度。阶段数量不一定要多,6 个足够,重点是每一次进度变化都对应一件真实做完的事。这样进度条的推进节奏天然就有快有慢,看起来真实,用户心理预期也稳。如果某个阶段确实没法细分(比如一个阻塞的网络请求),那就让进度条停在那一段不动,靠状态文字告诉用户"正在等某某响应",也比假动强。

3.3 隐式退出这个坑,一次说透

JavaFX 有个默认行为:当最后一个窗口关闭时,自动调用Platform.exit()结束整个应用。这条规则在普通应用里很贴心,在启动界面场景里就是个陷阱。你想的是"关掉启动窗口,然后打开主窗口",实际执行的是"关掉启动窗口 → 最后一个窗口没了 → 应用退出 → 主窗口根本没机会出现"。

两种解法。第一种是在显示启动窗口之前调用Platform.setImplicitExit(false),等主窗口显示稳定之后再改回true,这样中间任何一次窗口关闭都不会触发退出。第二种是不关窗口,先show()主窗口再关启动窗口,保证任意时刻至少有一个窗口存活。我两个都用:默认走第一种,同时保证代码里先显示主窗口再关启动窗口,双保险。别小看这个细节,我第一次踩的时候现象是"程序启动后什么都没发生,进程自己没了",查了半小时才发现是这个。

4. 从零写一个能用的启动窗口

前面铺垫完了,下面上代码。我把整个东西拆成四个文件:入口LauncherApp、启动窗口SplashWindow、启动任务BootTask、样式表splash.css。这样拆是为了后面替换任何一块都不牵连其他部分。

4.1 工程结构与依赖

src/main/java/com/example/tool/ LauncherApp.java // 入口,负责编排时序 SplashWindow.java // 启动窗口外壳 BootTask.java // 后台初始化任务 MainWindow.java // 主界面占位 src/main/resources/ css/splash.css img/logo.png img/app.png fonts/xxx.ttf

依赖上只需要 JavaFX 本体(javafx-controls、javafx-graphics、javafx-base),如果你用 Maven,javafx-maven-plugin就够启动调试了。有个小建议:不要为了启动窗口去引第三方 UI 库,这东西总共就几十行代码,引一个库反而增加打包体积和类加载时间,跟"让启动更快"的初衷背道而驰。

4.2 窗口外壳:StageStyle 和透明场景

启动窗口的观感核心在于"没有系统标题栏 + 圆角 + 阴影"。这三件事的实现链条是:StageStyle.TRANSPARENT去掉系统边框 →Scene.setFill(Color.TRANSPARENT)让场景底色透明 → 自己画一个带-fx-background-radius的容器 → 给容器套DropShadow。少任何一环,效果都会打折。

package com.example.tool; import javafx.event.Event; import javafx.geometry.Insets; import javafx.geometry.Pos; import javafx.scene.Node; import javafx.scene.Scene; import javafx.scene.control.Label; import javafx.scene.control.ProgressBar; import javafx.scene.effect.DropShadow; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.layout.Priority; import javafx.scene.layout.Region; import javafx.scene.layout.StackPane; import javafx.scene.layout.VBox; import javafx.scene.paint.Color; import javafx.scene.shape.Rectangle; import javafx.stage.Stage; import javafx.stage.StageStyle; public class SplashWindow { private static final double PANEL_W = 640; private static final double PANEL_H = 380; private static final double SHADOW_PAD = 24; private final Stage stage = new Stage(); private final StackPane panel = new StackPane(); private final Label statusLabel = new Label("正在启动…"); private final ProgressBar bar = new ProgressBar(0); private double dragOffsetX; private double dragOffsetY; public SplashWindow() { build(); } private void build() { ImageView logo = new ImageView( new Image(getClass().getResourceAsStream("/img/logo.png"))); logo.setFitWidth(76); logo.setPreserveRatio(true); logo.setSmooth(true); Label title = new Label("某开发者工具箱"); title.getStyleClass().add("splash-title"); Label version = new Label("v1.0.0"); version.getStyleClass().add("splash-version"); bar.setPrefWidth(420); bar.getStyleClass().add("splash-bar"); statusLabel.getStyleClass().add("splash-status"); Region spacer = new Region(); VBox.setVgrow(spacer, Priority.ALWAYS); VBox content = new VBox(10, logo, title, version, spacer, bar, statusLabel); content.setAlignment(Pos.CENTER); content.setPadding(new Insets(38, 44, 28, 44)); panel.getStyleClass().add("splash-panel"); panel.setPrefSize(PANEL_W, PANEL_H); panel.setMaxSize(PANEL_W, PANEL_H); panel.setMinSize(PANEL_W, PANEL_H); panel.getChildren().add(content); // 圆角裁剪:内容不溢出圆角区域 Rectangle clip = new Rectangle(PANEL_W, PANEL_H); clip.setArcWidth(28); clip.setArcHeight(28); panel.setClip(clip); // 外层负责投影,内层负责裁剪,职责分开 StackPane wrapper = new StackPane(panel); DropShadow shadow = new DropShadow(); shadow.setRadius(20); shadow.setOffsetY(8); shadow.setColor(Color.rgb(0, 0, 0, 0.5)); wrapper.setEffect(shadow); StackPane root = new StackPane(wrapper); root.setPadding(new Insets(SHADOW_PAD)); root.setStyle("-fx-background-color: transparent;"); Scene scene = new Scene(root, PANEL_W + SHADOW_PAD * 2, PANEL_H + SHADOW_PAD * 2); scene.setFill(Color.TRANSPARENT); scene.getStylesheets().add( getClass().getResource("/css/splash.css").toExternalForm()); enableDrag(root); stage.initStyle(StageStyle.TRANSPARENT); stage.setScene(scene); stage.setResizable(false); stage.setAlwaysOnTop(true); stage.getIcons().add(new Image(getClass().getResourceAsStream("/img/app.png"))); stage.centerOnScreen(); stage.setOnCloseRequest(Event::consume); // 禁止用户关闭 } private void enableDrag(Node node) { node.setOnMousePressed(e -> { dragOffsetX = e.getScreenX() - stage.getX(); dragOffsetY = e.getScreenY() - stage.getY(); }); node.setOnMouseDragged(e -> { stage.setX(e.getScreenX() - dragOffsetX); stage.setY(e.getScreenY() - dragOffsetY); }); } public void show() { stage.show(); } }

这里有两个设计决定值得说一下。第一,为什么裁剪和投影分在两层:DropShadow是节点效果,Clip也是节点级的,两者作用在同一个节点上时,裁剪会把阴影也算进去一起裁掉,结果就是阴影没了。把裁剪放内层panel、投影放外层wrapper,各管各的,效果才对。第二,为什么给panel设三种尺寸:StackPane在透明窗口里如果没有明确的尺寸约束,会被子内容的偏好尺寸撑开,圆角裁剪的Rectangle就跟不上了,四角会出现内容漏出来的毛边。

提示:StageStyle.TRANSPARENT在部分 Linux 桌面环境下,如果窗口管理器没有开启合成(compositing),透明区域会被渲染成纯黑。这是环境问题不是代码问题,后面第 6 节给了降级方案。

4.3 视觉层:进度条和状态文字的布局细节

进度条这块有个小陷阱。JavaFX 默认的ProgressBar高度是跟着字号走的,直接塞进去大概是 20 像素左右的一坨,跟 IDE 那种细线完全不是一个东西。正确做法是在 CSS 里同时锁死-fx-pref-height、-fx-min-height、-fx-max-height三个值,只设 pref 是不够的,因为VBox拉伸时会按 min/max 重新算。

状态文字的位置我放在进度条正下方,跟进度条左对齐,字号比正文小一号,颜色用偏灰的中性色。这时候有个排版问题:不同阶段的状态文字长度不一样,"正在读取本地配置…"和"准备就绪"宽度差一大截。如果居中对齐,文字会左右跳;如果左对齐但容器宽度固定,就稳了。所以我给进度条和状态文字都设了固定宽度(420),左对齐,视觉上它们是一条竖线对齐的。

版本号我放在标题正下方而不是角落。原因是这个窗口只有 380 高,角落放个小字很容易被 Logo 抢走注意力,反而显得脏。放在标题下面,字号 11 到 12,颜色比状态文字再淡一档,形成"主标题 → 版本 → 大片留白 → 进度"的纵向节奏,视觉上更接近成熟产品的处理方式。

4.4 样式表:把默认控件改造成深色风格

JavaFX 的默认主题是浅色的,直接套到深色面板上会非常刺眼——进度条的轨道是浅灰、文字默认黑色。下面这份 CSS 覆盖了所有需要的部分。

/* splash.css */ .root { -fx-font-family: "PingFang SC", "Microsoft YaHei", sans-serif; -fx-font-smoothing-type: gray; } .splash-panel { -fx-background-color: linear-gradient(to bottom right, #2c3038, #1e2126); -fx-background-radius: 14; -fx-border-color: rgba(255, 255, 255, 0.07); -fx-border-radius: 14; -fx-border-width: 1; } .splash-title { -fx-text-fill: #eceff4; -fx-font-size: 20px; -fx-font-weight: bold; } .splash-version { -fx-text-fill: #6f7782; -fx-font-size: 12px; } .splash-status { -fx-text-fill: #9aa3ad; -fx-font-size: 12px; } .splash-bar { -fx-pref-height: 6; -fx-min-height: 6; -fx-max-height: 6; } .splash-bar > .track { -fx-background-color: #363b43; -fx-background-radius: 3; -fx-background-insets: 0; } .splash-bar > .bar { -fx-background-color: linear-gradient(to right, #3d8bfd, #5fd0ff); -fx-background-radius: 3; -fx-background-insets: 0; -fx-padding: 0; }

写这份 CSS 踩过的两个点。一是.bar的 padding:默认主题里.progress-bar > .bar是有内边距的,你不设成 0,进度条虽然只有 6 像素高,但填充块会被这个 padding 挤变形,看起来像条驼背的线。二是-fx-background-insets:默认值是多段式的(为了做出立体感),在细进度条上会露出底层轨道,出现一条诡异的第三条线,必须归零。

字体这块单独提一句。中文字体在不同系统上差异很大,Windows 上微软雅黑、macOS 上苹方,-fx-font-family里按顺序写多个是有效果的,JavaFX 会从前往后找第一个可用的。如果你对字体有强要求(比如商标字体),那就得Font.loadFont(getResourceAsStream(...), size)显式加载,然后确保打包时字体文件进了资源目录——这一点在打成安装包之后特别容易翻车。

4.5 后台任务:把初始化拆成可汇报的步骤

BootTask是整个启动窗口的"燃料"。核心原则前面说过:每个阶段都要有名字、有真实工作量、有结束信号。

package com.example.tool; import javafx.concurrent.Task; public class BootTask extends Task<Void> { private static final int STEPS = 6; @Override protected Void call() throws Exception { updateMessage("正在读取本地配置…"); updateProgress(0, STEPS); loadConfig(); Thread.sleep(260); updateMessage("正在加载主题与字体…"); updateProgress(1, STEPS); loadTheme(); Thread.sleep(220); updateMessage("正在连接数据源…"); updateProgress(2, STEPS); connectDataSource(); Thread.sleep(400); updateMessage("正在预加载资源…"); updateProgress(3, STEPS); preloadResources(); Thread.sleep(300); updateMessage("正在恢复上次的工作区…"); updateProgress(4, STEPS); restoreWorkspace(); Thread.sleep(280); updateMessage("准备就绪"); updateProgress(STEPS, STEPS); return null; } private void loadConfig() { /* 读配置文件、校验、兜底 */ } private void loadTheme() { /* 加载字体、主题变量 */ } private void connectDataSource() { /* 建连接池或本地库 */ } private void preloadResources() { /* 图标、资源索引 */ } private void restoreWorkspace() { /* 上次会话状态 */ } }

updateProgress(0, STEPS)这个写法要留意:Task内部的progress初始值是-1,表示"不确定"。如果你不主动设一个 0,进度条一上来会进入 indeterminate 状态,跑一段来回循环的动画,等第一个真实进度到了才切成确定态,视觉上会"跳"一下。主动给个 0 就稳了。另外updateProgress的两个参数是workDone和max,内部会算成 0 到 1 的比例,别传成百分比。

还有一点:Thread.sleep在生产代码里当然要删掉,我这里留着是为了让你第一次跑就能看到完整的进度推进。真实场景里,如果某个阶段快得肉眼不可见(比如读一个几 KB 的配置文件),进度条会"嗖"地跳过去,看起来像卡顿。我的处理是在任务里加一个阶段最小停留时间:每个阶段结束前检查耗时,不够 150 毫秒就补足。这不算造假,因为它保证的是"让用户看得清",而不是"假装有活干"。

4.6 交接:绑定、淡出、最小展示时长

最后是把所有部件串起来的入口类。这段代码负责三件事:绑定进度、控制淡出时机、切换主窗口。

package com.example.tool; import javafx.animation.FadeTransition; import javafx.animation.PauseTransition; import javafx.application.Application; import javafx.application.Platform; import javafx.scene.Node; import javafx.stage.Stage; import javafx.util.Duration; public class LauncherApp extends Application { private static final long MIN_VISIBLE_MS = 900; private static final long FADE_MS = 260; @Override public void start(Stage primaryStage) { Platform.setImplicitExit(false); SplashWindow splash = new SplashWindow(); splash.show(); BootTask boot = new BootTask(); splash.bindTask(boot); long begin = System.currentTimeMillis(); boot.setOnSucceeded(e -> { long used = System.currentTimeMillis() - begin; long remain = Math.max(0, MIN_VISIBLE_MS - used); splash.fadeOut(remain, () -> { MainWindow main = new MainWindow(); main.show(); Platform.setImplicitExit(true); }); }); boot.setOnFailed(e -> { Throwable ex = boot.getException(); splash.showError(ex == null ? "初始化失败" : ex.getMessage()); }); Thread worker = new Thread(boot, "boot-worker"); worker.setDaemon(true); worker.start(); } }

splash.bindTask()的实现就是两行绑定,注意绑定之前先unbind,否则重复调用会抛异常:

public void bindTask(Task<?> task) { bar.progressProperty().unbind(); bar.progressProperty().bind(task.progressProperty()); statusLabel.textProperty().unbind(); statusLabel.textProperty().bind(task.messageProperty()); }

淡出这段是我调整过最多次的地方,最终形态是"先停顿,再整窗淡出":

public void fadeOut(long delayMillis, Runnable onFinished) { PauseTransition wait = new PauseTransition(Duration.millis(delayMillis)); wait.setOnFinished(e -> { Node root = stage.getScene().getRoot(); FadeTransition fade = new FadeTransition(Duration.millis(260), root); fade.setFromValue(1.0); fade.setToValue(0.0); fade.setInterpolator(javafx.animation.Interpolator.EASE_BOTH); fade.setOnFinished(ev -> { stage.close(); onFinished.run(); }); fade.play(); }); wait.play(); }

为什么要"先停顿再淡出"而不是"到 100% 立刻淡出"?因为进度条冲到 100% 的瞬间,用户的注意力正集中在条上,这时候画面立刻开始消失,会给人一种"被抢走"的感觉。停 200 到 400 毫秒,让用户看清"准备就绪"四个字,再淡出,节奏就舒服了。而MIN_VISIBLE_MS = 900是为了解决另一个问题:如果你的应用初始化只要 200 毫秒,启动窗口一闪而过,反而像闪屏故障,不如让它稳稳待够 900 毫秒。

主窗口的show()放在淡出回调里,意味着淡出动画播放期间主窗口还没出现。如果你想让两者交叉过渡(启动窗口淡出、主窗口淡入同时进行),把main.show()提到fadeOut之前即可。两种方案我都试过,交叉过渡在慢机器上容易出现两个窗口同时接受焦点的尴尬,所以我最终选了串行。

5. 备选和增强:两个能补短板的手段

自绘 Stage 虽好,但它接的是第二棒。下面两个手段用来补前面的空白期。

5.1 JVM 原生启动图:让第一帧提前出现

在启动参数里加一个-splash:指向一张 PNG,JVM 在进程初始化阶段就会把这张图居中显示出来,等 JavaFX 窗口出现后它会自动退场(或者你手动调SplashScreen.getSplashScreen().close())。

java -splash:src/main/resources/img/boot.png -jar tool.jar

这张图的选型有几个讲究。尺寸不要太大,800×500 左右足够,太大会拖慢加载;背景色要和你的 JavaFX 启动窗口一致,这样从原生图切到 JavaFX 窗口时不会出现颜色跳变;不要在图里放进度条,因为它是完全静态的,放了反而露馅。理想的做法是一张只有 Logo 和产品名的极简图,等 JavaFX 窗口接管后再补上进度条。

它也有局限:某些平台和某些启动方式下这个参数会被忽略,而且它只在 JVM 启动阶段有效,如果你的应用是通过安装包里的启动器拉起的,中间可能还有别的环节。所以把它当"锦上添花",不要当"必须生效"来设计。

5.2 Preloader:什么时候它才是正解

如果你的场景是"主Application的构造过程本身就很重"(比如在构造函数里读了大量配置、建了一堆静态对象),那Preloader确实比自绘 Stage 更合适,因为它能在主类被实例化之前就显示出来。配置方式是两个地方二选一:启动参数-Djavafx.preloader=com.example.tool.AppPreloader,或者在构建插件里指定preloaderClass。

写一个AppPreloader需要实现Preloader的三个方法:start(Stage)里搭 UI 并show(),handleProgressNotification接收进度,handleStateChangeNotification在BEFORE_START时收尾。主应用侧则通过notifyPreloader(new ProgressNotification(0.3))主动汇报。

我的建议是:先写自绘 Stage,如果发现主窗口构造确实太重,再把 Preloader 叠加上去,而不是一上来就用 Preloader。原因很实在——Preloader 的调试成本高得多,你很难在 IDE 里单步进去看它到底有没有被拉起,而自绘 Stage 就是个普通窗口,断点随便打。

6. 常见问题与排查表

下面这些现象,基本涵盖了我这两年在不同机器、不同打包方式下遇到的所有问题。

现象最可能的原因排查手段处理方式
启动窗口白屏,几秒后才出内容UI 线程上做了耗时操作在start()里打时间戳,看哪一段卡住耗时逻辑全部搬进Task
圆角处内容溢出,出现方形白边容器没设 clip,或 Scene 没设透明 fill注释掉 clip 看是否变化内层 clip + 外层阴影分层
透明窗口显示成纯黑底桌面环境未开合成换个窗口管理器试降级为UNDECORATED+ 不透明底色
关掉启动窗口后应用直接退出隐式退出规则触发看进程是否在关窗瞬间消失Platform.setImplicitExit(false)
进度条卡在 100% 不切换成功回调里跨线程操作了 Stage打印当前线程名回调内用Platform.runLater
打包后图标、字体、CSS 全找不到用了File路径而非 classpath看 jar 内资源是否存在一律用getResourceAsStream
进度条比设计的高一倍只设了-fx-pref-height检查 min/max 是否继承三个高度值全部写死
高 DPI 屏幕上窗口模糊图片被放大渲染换 2x 图源测试提供多倍图或改用矢量绘制

6.1 白屏的本质是 pulse 被堵住

JavaFX 的渲染循环(pulse)跑在 UI 线程上,大约每秒 60 次。你在start()里做了一件耗时 2 秒的事,这 2 秒里 pulse 一次都没跑,窗口虽然被创建了,但一帧都没画出来,用户看到的就是白屏。很多人第一反应是"窗口显示有延迟",去加Thread.sleep或者调整show()的时机,全是南辕北辙。

判断方法很简单:在start()的第一行和最后一行各打一个时间戳,如果两者差了 1 秒以上,就说明 UI 线程被堵了。修法只有一个——把耗时的部分挪走。挪的时候注意,凡是碰 UI 控件的代码都不能跟着挪,得用Platform.runLater包回来。

6.2 进度到了 100% 却没反应

这个现象背后通常有两种情况。情况一:setOnSucceeded回调里直接调了stage.close()或者main.show(),但回调执行的线程不是 FX 线程(Task的完成回调默认在 FX 线程,但如果你手动new Thread包了一层就未必了),导致 UI 没更新。情况二:Task的progress到了 1.0,但你在call()里return之后还有一段finally里的清理逻辑卡住了,setOnSucceeded根本没被触发。

排查顺序是:先在setOnSucceeded第一行打日志,确认它有没有被调用;如果有日志但界面没动,那就查线程;如果没有日志,那就查call()是不是卡住了。

6.3 打包之后的资源路径

开发环境跑得好好的,mvn package之后图标全丢,这是新手最容易栽的跟头。根源在于代码里写了new Image("file:src/main/resources/img/logo.png")——这个路径在 IDE 里相对工作目录是有效的,打进 jar 之后src目录根本不存在。

铁律是:所有资源一律走 classpath,也就是getClass().getResourceAsStream("/img/logo.png"),路径以/开头表示从 classpath 根开始。同时确认构建配置里src/main/resources被正确包含进产物。还有一个隐蔽的坑:路径大小写。Windows 上不区分,打进 jar 之后由文件系统决定,Linux 上就区分了,本地测试全绿、部署到别的环境就炸。养成全小写命名的习惯能省很多事。

6.4 平台差异:透明窗口不是所有地方都行

StageStyle.TRANSPARENT在 Windows 和 macOS 上表现稳定,在部分 Linux 桌面环境下,如果窗口管理器没开合成,透明区域会被画成黑色。这个没法用代码完全规避,只能做降级:启动时检测一下,如果环境不支持就用StageStyle.UNDECORATED配一个不透明的圆角背景,牺牲一点观感换可用性。

检测的土办法是:先用透明样式创建窗口,读取stage.getStyle(),如果拿回来的不是TRANSPARENT(说明被系统降级了),就切到备选方案。这个技巧不完美,但比让用户看到一个黑框要好。另一种更稳的做法是加一个启动参数,让用户手动指定渲染模式,默认走透明、出问题就关掉。

7. 打磨:让它看起来"专业"的那些细节

代码跑通只是及格线,真正让启动界面显得专业的,是一堆没法写进文档的小设计。

7.1 动效节奏:慢比快难

淡出动画我调了不下十次。最开始用的是 400 毫秒EASE_IN,效果是"啪"一下就没了;改成 200 毫秒EASE_OUT,又显得太急,像被强制关掉。最后落在 260 毫秒Interpolator.EASE_BOTH——前段慢起、中