现象

做“导入历史数据”功能时,解析 Excel 是耗时操作,很自然放到后台线程。解析完直接往绑定了 TableView 的 ObservableList 里 add,结果运行时炸了:

Exception in thread "pool-1-thread-1" java.lang.IllegalStateException:
Not on FX application thread; currentThread = pool-1-thread-1

原因

JavaFX 有一条铁律:所有对 Scene Graph 的修改(包括 ObservableList、Property 的变更)必须在 JavaFX Application Thread 上完成。ObservableList 的变更会触发 UI 监听器刷新表格,后台线程改它就破坏了线程安全,所以框架直接抛异常拦截。

错误做法

最开始图省事,每解析一条就 Platform.runLater 包一下 add:

thread {
val parsed = parseRow(row) // 耗时,后台OK
Platform.runLater {
items.add(parsed) // 切回主线程塞
}
}

单条没问题,但批量导入几百条时,每条都 runLater 一次,主线程被高频小任务打爆,UI 明显卡顿。

正确做法

后台只做纯计算,主线程只做一次批量更新。把后台解析结果收集到一个普通 List,最后一次切回主线程 setAll

viewModel.launch(Dispatchers.IO) {
val result = mutableListOf<RowVo>()
parseExcel(file) { row -> result += toVo(row) } // 纯数据,后台
withContext(Dispatchers.Main) {
items.setAll(result) // 一次批量提交
}
}

关键点:

  • 后台线程绝不碰 ObservableList,只操作普通集合。
  • 主线程用 setAll 一次性替换,而不是循环 add,触发的列表变更事件从 N 次降为 1 次。
  • 配合 Kotlin 协程的 withContext(Dispatchers.Main)Platform.runLater 更好组织,协程取消时也能正确清理。

小结

JavaFX 多线程就一句话:耗时放后台,碰 UI 回主线程,且尽量批量。这条规则后来成了项目里所有“后台解析 / 加载 → 刷新表格”场景的统一写法,再没出过这个异常。