30 KiB
MooTool Java 启动响应性优化方案
状态:首批实施中(阶段 0–2 + Quick Note / Net P0)
更新日期:2026-07-24
适用范围:MooTool Java / Swing 客户端
主要目标:窗口出现后保持可交互,消除启动期间“界面已显示但像卡死”的体验
1. 背景
MooTool Java 在启动时会创建主窗口、挂载全部功能页,并同时启动大量页面初始化任务。部分任务通过 SwingUtilities.invokeLater 更新界面,部分任务则直接从 main 线程或后台线程读写 Swing 组件。
这会同时引发以下问题:
- 大量初始化任务集中向 AWT Event Dispatch Thread(下文简称 EDT)提交更新,形成事件队列洪峰。
- EDT 忙于创建组件、安装监听器、布局和重绘,鼠标、键盘、窗口拖动等输入事件只能排队等待。
- 后台线程与 EDT 并发访问 Swing 组件,可能竞争组件树锁,并产生不可预测的布局、绘制和状态问题。
- Loading 页面很快被主页面替换,但主页面仍在初始化,用户无法判断应用是否已经可用。
- 当前启动流程一次性初始化所有工具,即使用户本次不会访问其中大部分页面。
SwingUtilities.invokeLater 本身不是卡顿根因。它只是把任务加入 EDT 队列;真正需要治理的是:
- 单个 EDT 任务是否过重;
- 启动期间提交了多少重复 UI 更新;
- 是否在 EDT 执行了文件、数据库、网络、Git、系统命令等阻塞操作;
- 是否从非 EDT 线程操作 Swing;
- 是否可以把非当前页面延迟到首次访问时初始化。
2. 当前实现与问题定位
2.1 启动线程边界不正确
App.main() 中只有字体安装显式调用了 invokeLater,窗口创建和后续大部分 Swing 操作实际在 main 线程执行:
src/main/java/com/luoboduner/moo/tool/App.java:97:字体安装单独排入 EDT。src/main/java/com/luoboduner/moo/tool/App.java:99:主题初始化在main线程执行。src/main/java/com/luoboduner/moo/tool/App.java:105-110:创建、初始化并显示MainFrame。src/main/java/com/luoboduner/moo/tool/App.java:120-139:字体、内容面板、页面初始化和首次启动引导。
结果是 EDT 已经开始绘制和处理事件时,main 线程仍在修改同一套 Swing 组件。
此外,App.sqlSession 在类静态初始化阶段创建:
public static SqlSession sqlSession = MybatisUtil.getSqlSession();
这会影响窗口首次出现时间。它不一定是窗口显示后不可点击的直接原因,但应纳入启动耗时治理。
2.2 所有功能页被提前创建
MainWindow.init() 在启动时调用每个 Form.getInstance(),创建并挂载全部工具页面:
src/main/java/com/luoboduner/moo/tool/ui/form/MainWindow.java:120-144
该过程会集中产生:
- IntelliJ GUI Designer 组件树构造;
- 图标、字体和编辑器组件初始化;
JTabbedPane、分割面板和滚动面板布局;revalidate、updateUI和重绘;- 部分 Form 静态字段初始化及数据库 Mapper 获取。
仅仅把后续 Form.init() 改为异步,无法消除这里的首屏构造成本。
2.3 23 个页面初始化任务并发启动
Init.initAllTab() 通过 ThreadUtil.execute 同时提交 23 个 Form::init:
src/main/java/com/luoboduner/moo/tool/ui/Init.java:232-255
这些 init() 并非纯后台任务。例如:
QuickNoteForm.init()初始化 UI、字体、列表、监听器、文件监视和 Git 调度器;JsonBeautyForm.init()初始化 UI、列表、字体、监听器和 Vault 服务;HostForm.init()初始化系统托盘、UI、列表和监听器;AboutForm.init()修改标签、滚动面板、图片和推荐组件。
上述方法包含大量 Swing 操作,却从后台线程执行,违反 Swing 单线程规则。并发初始化还会让数据库、磁盘、Git、图片解码和 EDT 更新在应用刚打开时同时争抢资源。
2.4 页面切换监听器存在 EDT 阻塞
TabListener.stateChanged() 运行在 EDT,但在进入网络工具时直接执行:
RuntimeUtil.execForStr("ipconfig");
RuntimeUtil.execForStr("ifconfig");
位置:
src/main/java/com/luoboduner/moo/tool/ui/listener/TabListener.java:46-57
系统命令执行期间,整个界面无法处理输入。这类代码即使与首次启动无关,也会造成相同的“点击后卡住”体验,需要采用同一套线程边界进行治理。
2.5 Loading 状态与真实就绪状态不一致
当前流程先显示 LoadingForm,随后立即调用:
mainFrame.setContentPane(MainWindow.getInstance().getMainPanel());
之后才执行 MainWindow.init() 和 Init.initAllTab()。因此用户看到主界面时,后台仍在大量修改页面。mainFrame.remove(loadingPanel) 发生在内容面板已经被替换之后,也不能表达真实的加载阶段。
3. 优化目标
3.1 用户体验目标
- 主窗口一旦显示,菜单、导航、窗口拖动和已就绪页面应持续响应。
- 未就绪页面展示页面级 Loading 或骨架,不阻塞其他页面。
- 加载状态说明具体任务,例如“正在加载便签数据”,避免只有泛化的“Loading”。
- 页面加载失败时允许重试,不使整个应用退出或永久停留在 Loading。
- 非关键服务失败时降级运行,并给出可理解的错误提示。
3.2 技术目标
- 所有 Swing 组件的创建和修改都在 EDT 完成。
- EDT 不执行数据库、文件扫描、Git、网络、图片解码、系统命令等阻塞工作。
- 启动时只创建主框架、导航壳和当前页面。
- 页面初始化采用 single-flight,同一页面不会并发或重复初始化。
- 高频 UI 刷新支持合并,避免相同任务反复进入 EDT 队列。
- 后台任务使用有界线程池,避免启动时无节制并发。
- 启动阶段有可度量的埋点和 EDT 延迟监控。
3.3 暂定性能预算
性能预算需在基准设备上记录改造前基线后最终确认,首轮采用以下暂定目标:
| 指标 | 暂定目标 |
|---|---|
| 主窗口显示后 Loading 状态可见 | 100 ms 内 |
| 窗口显示后导航首次响应 | 200 ms 内 |
| 启动期间单个 EDT 任务 | 原则上小于 50 ms,不允许超过 100 ms 的常态任务 |
| 当前工具为轻量工具时可用 | 窗口显示后 500 ms 内 |
| 当前工具需要 IO 时 | 100 ms 内显示页面级 Loading,完成后自动切换 |
| EDT 延迟 | P95 小于 50 ms,最大值原则上不超过 200 ms |
| 非当前工具初始化 | 不影响首屏,可在首次访问时执行 |
所有时间均需要区分冷启动和热启动,并记录操作系统、CPU、磁盘和数据规模。
4. 设计原则
4.1 EDT 只负责 Swing
必须在 EDT 执行:
- 创建 Swing 组件;
- 修改组件属性和 Model;
- 安装或移除 Swing 监听器;
add/remove组件;revalidate/repaint/updateUI;- 显示、隐藏或销毁窗口和对话框。
必须在后台执行:
- 文件读写和目录扫描;
- SQLite/MyBatis 查询与写入;
- HTTP 请求;
- Git 操作;
- 图片读取、解析和转换;
- OCR;
ipconfig、ifconfig、ping等系统命令;- 大文本解析、格式化和 Diff;
- 可明显感知的 CPU 密集型计算。
后台任务只能返回普通数据对象,不得返回正在被后台线程修改的 Swing Model。
4.2 先显示可交互壳,再加载内容
首屏只包含:
- 已配置的 Look and Feel 与字体;
MainFrame;- 菜单和功能导航;
- 当前工具的轻量容器;
- 页面级 Loading、错误和重试状态。
窗口显示后,启动协调器再开始加载当前页面和延后服务。
4.3 默认按需加载
除当前工具外,其他工具默认不创建 Form、不读取数据、不注册文件监听器、不启动定时器。
如果某个全局服务确实必须启动,需要在注册表中明确声明,而不是借用某个页面的 init() 顺带启动。
4.4 后台计算,单次提交 UI
同一任务应尽量完成数据加载后,通过一次 EDT 任务提交完整结果。避免以下模式:
for (Item item : items) {
SwingUtilities.invokeLater(() -> listModel.addElement(item));
}
推荐先在后台构建不可变结果,再一次性替换 Model:
List<Item> items = repository.loadItems();
SwingUtilities.invokeLater(() -> form.replaceItems(items));
4.5 页面之间故障隔离
Quick Note、Git、网络或 About 页加载失败,不应阻止其他本地工具使用。每个页面独立维护 NEW、LOADING、READY、FAILED 状态。
5. 目标启动模型
flowchart TD
Main["main 线程:读取最小非 Swing 配置"] --> EDT["EDT:安装主题和字体"]
EDT --> Shell["EDT:创建并显示轻量主窗口"]
Shell --> FirstPaint["首帧完成,导航可响应"]
FirstPaint --> Select["确定上次使用或默认工具"]
Select --> Loading["EDT:显示该工具 Loading"]
Loading --> Background["后台:加载数据和执行阻塞工作"]
Background --> Apply["EDT:创建页面、绑定数据、切换到 Ready"]
FirstPaint --> Deferred["延后启动全局非关键服务"]
Deferred --> Update["更新检查"]
Deferred --> Cleanup["临时目录清理"]
Deferred --> GlobalWatcher["确有必要的全局监听器"]
启动状态建议定义为:
NEW
-> SHELL_VISIBLE
-> CRITICAL_LOADING
-> INTERACTIVE
-> DEFERRED_RUNNING
-> READY
INTERACTIVE 表示用户已经可以正常使用当前页面,不要求所有延后服务都完成。非关键任务失败时仍可进入 READY,同时记录降级状态。
6. 建议组件
6.1 StartupCoordinator
职责:
- 管理启动状态;
- 启动当前页面;
- 延后启动非关键服务;
- 记录各阶段耗时;
- 捕获非关键任务异常并降级;
- 应用退出时关闭启动线程池。
不负责:
- 直接实现某个工具的数据加载;
- 在后台线程修改 Swing;
- 替代页面自己的生命周期管理。
示意:
public final class StartupCoordinator {
public void startAfterWindowVisible() {
String initialToolId = resolveInitialTool();
lazyToolManager.ensureInitialized(initialToolId);
deferredExecutor.schedule(this::startDeferredServices, 1500, TimeUnit.MILLISECONDS);
}
}
6.2 LazyToolManager
以 FuncTabCatalog 中的稳定工具 ID 为键,管理页面初始化状态,不能依赖本地化后的 Tab 标题。
建议状态:
public enum ToolLoadState {
NEW,
LOADING,
READY,
FAILED
}
建议能力:
public interface LazyToolInitializer<M> {
// 后台线程:只返回普通数据
M loadData() throws Exception;
// EDT:创建 Swing 页面
JComponent createView();
// EDT:绑定完整数据
void bindData(JComponent view, M data);
// 页面 Ready 后启动页面级 watcher/scheduler
default void startServices() {
}
// 页面或应用释放时停止服务
default void dispose() {
}
}
初始化流程:
public void ensureInitialized(String toolId) {
ToolSlot<?> slot = slots.get(toolId);
if (!slot.beginLoading()) {
return;
}
slot.showLoadingOnEdt();
CompletableFuture
.supplyAsync(slot::loadData, ioExecutor)
.whenComplete((data, error) -> SwingUtilities.invokeLater(() -> {
if (error != null) {
slot.showError(error);
return;
}
slot.createBindAndShow(data);
}));
}
实际实现需满足:
beginLoading()为原子 single-flight;showLoadingOnEdt()在调用方已经是 EDT 时直接执行,避免无意义嵌套排队;- 页面切走后加载可以继续,但只更新自己的容器;
- 可取消任务需要在页面释放或应用退出时取消;
- 失败后可以从
FAILED显式重试; createBindAndShow()中的工作仍需拆小,避免在 EDT 创建超大组件树时形成长任务。
6.3 页面容器
每个工具的现有占位 Panel 改用 CardLayout 管理:
LOADING -> CONTENT
-> ERROR
Loading 只覆盖当前工具内容区域,菜单、导航和其他已就绪页面保持可点击。
建议错误状态至少包含:
- 简短错误说明;
- 重试按钮;
- 可复制的详细信息入口;
- 对全局不可用能力的设置入口,例如 Git 或运行环境配置。
6.4 后台线程池
不要继续用无明确容量和生命周期的全局线程池承载所有启动工作。
建议最少区分:
ExecutorService ioExecutor =
Executors.newFixedThreadPool(3, namedDaemonFactory("mootool-io-"));
ExecutorService computeExecutor =
Executors.newFixedThreadPool(
Math.max(1, Math.min(2, Runtime.getRuntime().availableProcessors() - 1)),
namedDaemonFactory("mootool-compute-"));
ScheduledExecutorService deferredExecutor =
Executors.newSingleThreadScheduledExecutor(
namedDaemonFactory("mootool-deferred-"));
线程数需经实际测量调整。原则是避免启动时同时运行二十多个任务,也不要让 CPU 密集任务占满所有核心而影响 EDT 和系统绘制线程。
6.5 UI 刷新合并器
文件监听、Git 状态和其他高频事件只保留一次待执行刷新:
public final class UiTaskCoalescer {
private final AtomicBoolean queued = new AtomicBoolean();
private final Runnable refresh;
public UiTaskCoalescer(Runnable refresh) {
this.refresh = refresh;
}
public void request() {
if (!queued.compareAndSet(false, true)) {
return;
}
SwingUtilities.invokeLater(() -> {
// 在刷新开始前清除标记,刷新期间的新请求可再排一次。
queued.set(false);
refresh.run();
});
}
}
刷新函数必须从最新状态构建 UI,不能依赖每一个中间事件都被显示。
7. 新启动入口
目标入口示意:
public static void main(String[] args) {
BootstrapConfig bootstrapConfig = loadMinimalConfig();
installPlatformProperties(bootstrapConfig);
EventQueue.invokeLater(() -> {
Init.initTheme();
FlatJetBrainsMonoFont.install();
App.mainFrame = createMainShell();
App.mainFrame.setVisible(true);
// 当前 EDT 任务尽快返回,让首帧和输入事件得到处理。
EventQueue.invokeLater(() ->
StartupCoordinator.getInstance().startAfterWindowVisible());
});
}
实施时需注意:
FlatJetBrainsMonoFont.install()不再与后续组件创建并发执行。MainFrame、菜单、Loading 容器和setVisible全部由 EDT 执行。startAfterWindowVisible()只能提交后台任务,不能自己执行阻塞初始化。- 首次语言和字号引导在窗口稳定后显示,不与页面批量初始化并发。
UpgradeUtil.smoothUpgrade()如果涉及文件或数据库操作,需要拆成后台阶段和 EDT 提示阶段。- 临时目录创建与清理移到后台,必须在依赖该目录的工具使用前提供就绪 Future。
SqlSession改为延迟获取,避免App类加载时触发数据库初始化。
8. 页面改造策略
8.1 兼容现有 Form 的过渡方案
第一阶段不要求一次性重写所有 Form,但必须停止启动时全量并发调用 Form.init()。
过渡规则:
MainWindow.init()只建立轻量工具容器,不调用所有Form.getInstance()。- 当前工具第一次打开时才调用对应
getInstance()。 - 在尚未拆分的页面中,组件创建和现有
init()暂时都在 EDT 执行,以保证线程正确。 - 如果该页面的完整
init()超过 50 ms,必须继续拆分为后台数据加载和 EDT UI 提交。 - 已拆分页面使用新的
LazyToolInitializer,逐步淘汰静态init()。
过渡方案可能让首次打开某个重页面时短暂卡顿,但比启动时并发修改 Swing 更安全。Quick Note、JSON、Host、图片助手等重页面需要优先完成第二阶段拆分。
8.2 页面优先级
| 优先级 | 页面/能力 | 原因 |
|---|---|---|
| P0 | Quick Note | 文件扫描、编辑器、Vault watcher、Git scheduler 较多 |
| P0 | JSON Beauty | 列表、编辑器、Vault、Git 与监听器较多 |
| P0 | Host | 数据库、系统 Hosts 文件和系统托盘 |
| P0 | Net | 系统命令会在页面切换 EDT 中执行 |
| P1 | Image | 目录扫描、图片读取与解码 |
| P1 | About | 网络请求、远程头像和图片 |
| P1 | HTTP / Translation | 数据库与网络依赖 |
| P1 | Hardware Info | 系统信息收集和命令执行 |
| P2 | Cron / Regex / QR / Crypto / Text Diff | 组件较多,部分操作为 CPU 或文件密集型 |
| P3 | 轻量纯文本工具 | 构造和默认初始化成本较低 |
优先级需要用启动采样结果校正。
8.3 服务生命周期
以下服务不能再无条件随应用启动:
QuickNoteVaultWatcher;QuickNoteAutoGitScheduler;QuickNoteAutoPullScheduler;JsonBeautyVaultWatcher;JsonBeautyAutoGitScheduler;JsonBeautyAutoPullScheduler。
建议策略:
- 页面首次 Ready 后启动页面级服务;
- 如果产品要求后台同步,即使页面未打开也必须运行,则把它声明成独立全局服务;
- 全局服务应在应用进入
INTERACTIVE后延迟启动; - watcher 事件通过 UI 合并器提交,不逐事件调用
invokeLater; - 应用退出或配置切换时明确停止旧服务,防止重复注册。
9. invokeLater 使用约束
允许:
- 从后台任务向 EDT 提交最终 UI 更新;
- 在当前 EDT 任务结束后执行依赖布局结果的轻量操作;
- 合并后的单次刷新;
- 显示对话框、提示或错误状态。
不允许:
- 在
invokeLater中执行数据库、文件、网络、Git 或系统命令; - 在循环中逐条提交大量组件更新;
invokeLater内部无条件再调用invokeLater;- 用
invokeLater隐藏一个本应拆分的长 UI 任务; - 把整个应用初始化包装成一个超长
invokeLater; - 用后台线程直接修改 Swing,以“减少 invokeLater 数量”。
统一辅助方法可以避免不必要排队:
public static void runOnEdt(Runnable task) {
if (SwingUtilities.isEventDispatchThread()) {
task.run();
} else {
SwingUtilities.invokeLater(task);
}
}
禁止在常规启动和页面加载流程中使用 invokeAndWait。若确有需要,必须说明不会形成锁反转和死锁,并经过专项评审。
10. 可观测性与诊断
10.1 启动阶段埋点
至少记录:
app.main.enter
bootstrap.config.ready
laf.ready
mainFrame.visible
initialTool.loading
initialTool.ready
app.interactive
deferredServices.started
app.ready
建议日志格式:
startup phase=initialTool.ready elapsedMs=438 tool=quick-note thread=AWT-EventQueue-0
同时记录每个页面:
- 构造耗时;
- 数据加载耗时;
- UI 绑定耗时;
- 是否命中缓存;
- 加载失败原因;
- 初始化触发来源。
10.2 EDT 延迟监控
开发和性能测试版本可以启用 EDT ping。必须保证队列中最多只有一个待执行 ping,避免监控本身在卡顿期间制造事件洪峰。
当延迟超过阈值时记录:
- EDT 延迟;
- 当前启动阶段;
- 当前工具;
- EDT 栈;
- 后台线程池活跃任务数和队列长度。
生产版本默认只保留低成本耗时统计,详细栈采样通过诊断开关启用。
10.3 开发期线程检查
开发版本增加 EDT 断言:
public static void assertEdt() {
if (!SwingUtilities.isEventDispatchThread()) {
throw new IllegalStateException("Swing access outside EDT");
}
}
在核心的 createView、bindData、主窗口切换和 Form UI 初始化入口调用。也可安装自定义 RepaintManager 检测非 EDT Swing 修改,但只用于开发和测试。
10.4 性能分析
每一阶段至少保留一次 Java Flight Recorder 或采样分析结果,重点观察:
AWT-EventQueue-0热点;Container.validate、布局和重绘调用;- IntelliJ GUI Designer 组件构造;
- 图标和图片解码;
- MyBatis/SQLite;
- 文件扫描;
- Git 与系统命令;
- 后台线程数和 CPU 饱和情况。
11. 分阶段实施计划
阶段 0:建立基线与线程护栏
目标:先能准确回答“卡在哪里”,并阻止继续引入明显的线程违规。
- 记录当前冷启动和热启动时间。(已加
StartupMetrics阶段耗时日志,人工基线待补) - 增加启动阶段耗时日志。
- 增加开发期开关控制的 EDT 延迟监控。
- 增加核心 UI 入口的 EDT 断言。
- 记录
AWT-EventQueue-0启动采样。 - 建立 Windows、macOS、Linux 至少各一份基线。
交付标准:
- 能区分窗口出现慢、窗口出现后不可交互、具体页面首次打开慢。
- 能定位超过 100 ms 的 EDT 任务。
阶段 1:修正主启动流程
目标:窗口创建过程线程正确,首屏状态真实。
- 把 Look and Feel、字体、主窗口和菜单创建统一放入 EDT。
- 使用
CardLayout建立主窗口 Loading、内容和错误状态。(页面级ToolContentHost) - 把
SqlSession改为延迟初始化。 - 把临时目录清理、平滑升级中的阻塞工作移出 EDT。
- 首次语言和字号引导延后到主窗口稳定后。
- 删除内容面板已替换后再
remove(loadingPanel)的旧流程。
交付标准:
- 不再从
main线程创建或修改 Swing。 - 窗口显示后 Loading 动画、拖动和菜单能够持续响应。
阶段 2:页面懒加载
目标:启动时不再创建和初始化全部工具。
- 建立
LazyToolManager和页面状态模型。 MainWindow只创建导航和轻量工具容器。- 读取最近工具,优先初始化当前工具。
TabListener通过工具 ID 触发 single-flight 初始化。- 移除
Init.initAllTab()中 23 个并发任务。 - 页面失败支持重试,切换其他页面不受影响。
交付标准:
- 未访问页面的 Form 构造器和
init()不执行。 - 快速切换同一页面不会重复初始化。
- 一个页面加载失败不会阻止其他工具使用。
阶段 3:拆分重页面
目标:重页面首次访问时也不形成明显 EDT 卡顿。
- 拆分 Quick Note 的 UI、数据、watcher 和 Git 服务。(首批:Vault 后台扫描 + 页面级服务启停)
- 拆分 JSON Beauty 的 UI、数据、watcher 和 Git 服务。
- 拆分 Host 的数据库、系统文件和 UI。
- 把 Net 页的系统命令移到后台。
- 把 Image 页目录扫描和图片解码移到后台。
- 把 About 页网络请求和远程图片加载移到后台。
- 继续处理采样中超过 50 ms 的其他页面。
交付标准:
- 重点页面 Loading 能在 100 ms 内出现。
- 后台加载期间可以切换工具、拖动窗口和使用菜单。
- 页面 UI 最终提交没有逐条
invokeLater洪峰。
阶段 4:治理 watcher、刷新与延后服务
目标:启动后长期运行也保持稳定响应。
- 文件 watcher 使用刷新合并器。(防抖 + 后台扫盘 + single-flight)
- Git 状态刷新使用 single-flight 和节流。(AutoGit checkpointInProgress)
- 自动更新检查延后启动,不与首屏争抢网络和 CPU。(
DeferredServices首次 5 分钟) - 所有 scheduler 和 watcher 有清晰启停生命周期。
- 应用退出时关闭自有 Executor 和 watcher。
- 审查按钮、Tab、菜单监听器中的阻塞 IO。(Image 列表解码已异步;Net 已修)
交付标准:
- 高频文件变化不会产生大量 EDT 排队。
- 页面重复打开或配置切换不会重复注册 watcher/scheduler。
- 退出后不残留 MooTool 自有非守护线程。
12. 测试方案
12.1 自动化测试
建议新增:
LazyToolManagerTest- 同一工具并发触发只加载一次;
- 成功进入
READY; - 失败进入
FAILED; - 重试成功;
- 加载结果只绑定一次。
UiTaskCoalescerTest- 多次连续请求只产生一次待执行任务;
- 刷新执行期间的新请求能再执行一次。
- EDT 约束测试
createView和bindData确实在 EDT;loadData不在 EDT;- 关键 Form 不从后台线程修改 Swing。
- 生命周期测试
- watcher/scheduler 只启动一次;
- dispose 后停止;
- 页面加载失败不启动服务。
12.2 人工验收场景
每个平台至少覆盖:
- 全新配置首次启动。
- 已有大量 Quick Note 和 JSON Vault 数据的冷启动。
- 最近页面分别为轻量工具、Quick Note、JSON、Host、Image。
- 启动时断网。
- Git 仓库很大、远端不可达或凭据失效。
- SQLite 数据量较大。
- 启动后立即连续切换多个工具。
- 启动后立即拖动窗口、打开菜单和设置。
- 文件 watcher 在短时间内收到大量事件。
- 页面加载失败后重试。
- 关闭窗口、托盘恢复和应用退出。
人工验收时不得只观察 Loading 动画,需要实际点击菜单、导航、输入框并拖动窗口。
12.3 回归范围
- 主题和系统主题跟随;
- 全局字体和字号;
- 最近工具恢复;
- 分组、顶部、左侧等导航模式;
- 首次语言和字号引导;
- 系统托盘和 macOS 应用菜单;
- 自动更新;
- Quick Note / JSON Vault 自动保存和 Git;
- 页面 i18n 刷新;
- 关闭策略与退出清理。
13. 风险与应对
13.1 静态单例依赖初始化顺序
现有 Form 大量采用静态单例和静态 Mapper。懒加载后,未初始化页面可能被菜单、监听器或 i18n 刷新提前访问。
应对:
- 页面外部通过
LazyToolManager访问,不直接假设Form.getInstance()已完成初始化; - i18n 刷新只处理已创建页面;
- 菜单操作先
ensureInitialized(),完成后执行动作; - Mapper 改为方法内或 Repository 中延迟获取。
13.2 页面切换触发业务逻辑
当前 TabListener 会在页面选中时立即读取组件或执行系统命令。页面懒加载后,这些组件可能尚不存在。
应对:
- 先等待页面进入
READY; - 业务加载交给页面 initializer;
- Tab listener 只记录最近页面和触发初始化;
- 不再通过本地化 Tab 标题判断工具类型。
13.3 EDT 正确但页面首次打开仍慢
把现有 init() 整体放回 EDT 能保证线程正确,却可能造成首次打开页面时卡顿。
应对:
- 该方式只作为过渡;
- 用耗时日志找出超过 50 ms 的部分;
- 优先拆数据、文件、图片、编辑器模型和系统服务;
- 必要时分批构建超大 UI,但不能让组件处于可操作的半初始化状态。
13.4 后台加载完成顺序不确定
用户快速切换页面时,后台任务可能乱序完成。
应对:
- 每个工具只更新自己的 Card 容器;
- 使用工具 ID 和加载代次校验结果;
- 配置发生变化时丢弃旧代次结果;
- 不使用“当前选中页面”作为后台任务结果的唯一归属。
13.5 行为改变
页面 watcher 从应用启动改为首次访问启动,可能改变后台同步语义。
应对:
- 明确区分页面级服务和全局服务;
- 产品要求必须后台运行的同步能力保留为独立延后全局服务;
- 迁移前为每个 scheduler/watcher 记录现有产品语义。
14. 回滚策略
改造按阶段提交,避免主启动流程、全部页面和 watcher 在同一个变更中重写。
建议提供临时配置开关:
startup.lazyTools=true
startup.deferredServices=true
startup.edtDiagnostics=false
要求:
- 开关只用于迁移和问题定位,不长期维护两套架构;
- 页面数据格式、数据库结构和 Vault 文件不随此次优化改变;
- 单个页面懒加载出现严重回归时,可以临时恢复该页面为首屏后预热,但不能恢复后台线程修改 Swing;
- EDT 线程正确性不允许回滚。
15. 完成定义
只有同时满足以下条件,启动响应性优化才视为完成:
- 主窗口和所有 Swing 页面只从 EDT 创建和修改。
- 启动期间不存在全量
Form::init并发。 - 未访问页面不会提前构造。
- 文件、数据库、网络、Git、图片和系统命令不在 EDT 执行。
- 当前页面有 Loading、Ready、Failed 和 Retry 状态。
- watcher 与 scheduler 生命周期清晰且不会重复注册。
- 高频刷新能够合并。
- 三个平台完成冷启动和热启动验收。
- 性能预算有基线、改造后数据和差异说明。
- 启动后立即点击、输入、拖动和切换页面不再出现持续无响应。
- 现有主题、导航、托盘、更新、i18n、Vault 和 Git 行为通过回归。
16. 推荐的首个实现批次
首个批次控制在可独立验证的范围内:
- 增加启动阶段耗时日志和开发期 EDT 断言。
- 重构
App.main(),确保所有 Swing 启动操作在 EDT。 - 用
CardLayout替代当前 Loading 面板直接替换流程。 - 建立
LazyToolManager,先接入一个轻量页面和 Quick Note。 - 删除这两个页面在
Init.initAllTab()中的并发初始化。 - Quick Note 拆出后台数据加载,UI 和监听器在 EDT 安装。
- 修复
TabListener中ifconfig/ipconfig阻塞 EDT。 - 比较改造前后的首屏时间、EDT 延迟和首次页面可用时间。
验证通过后,再批量迁移其余页面。这样可以先验证生命周期模型和线程边界,降低一次性改造全部工具的风险。