现象

做”导入历史数据”功能的时候,解析 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 更好组织,协程取消的时候也能正确清理。

说白了就一句话——耗时的活放后台,要碰 UI 就回主线程,而且尽量攒一批再刷。这条后来成了项目里所有”后台解析 / 加载 → 刷新表格”场景的统一写法,那个异常再没冒过。