轻糖的 KMP 实战:一次冷启动 3 秒必现崩溃,与 viewModelScope 异常逃逸的代价
第三篇文章收尾时,我们说迁移完成后的日常是"新功能默认 KMP-first"。这句话没错,但它隐藏了一个前提:共享层代码在两端的运行环境并不对等——同样的代码,在 Android 上是一次可捕获的异常,在 iOS 上可能是整个进程的直接死亡。
这篇文章记录一次真实事故:轻糖 iOS 版冷启动 3 秒必现 SIGABRT,堆栈指向一个陌生的符号 propagateExceptionFinalResort。根因不在 iOS 侧的任何一行 Swift 代码,而在 shared 层 ViewModel 里几个看似无害的 Flow 订阅——它们的异常从未被捕获,在 Kotlin/Native 上直接杀死了进程。
💥 事故现场:启动 3 秒,进程消失
现象非常规律:iOS 冷启动后,首页数据刚开始加载,大约 3 秒,App 闪退。必现,不分机型,不分系统版本。
崩溃报告里能看到的信息:
- 信号是 SIGABRT,不是常见的 SIGSEGV / SIGTRAP;
- 栈底出现
propagateExceptionFinalResort这个符号——它不是我们的代码,也不是 Swift 运行时的,而是 Kotlin/Native 协程运行时的"最终手段"; - Android 侧同样的版本、同样的数据,完全不崩。
“Android 不崩 iOS 必崩"这个组合,基本排除了业务逻辑错误(比如空指针、数组越界),指向了平台运行时的行为差异。而 propagateExceptionFinalResort 这个名字已经把事情说得很明白了:有异常一路传播到了协程的最顶端,没有人接住它,运行时只能把进程杀掉。
🔍 根因:没有 CoroutineExceptionHandler 的 viewModelScope
回顾第三篇文章里的 KMP ViewModel 范式:
class HomeViewModel : ViewModel() {
private val _uiState = MutableStateFlow(viewModelScope, HomeUiState())
init {
viewModelScope.launch { observeDailyData() } // 订阅每日数据
viewModelScope.launch { observeWeekData() } // 订阅周数据
viewModelScope.launch { observeBloodSugarUnit() } // 订阅血糖单位
}
}observeDailyData() 内部是一条典型的响应式链路:combine 5 个数据库 Flow,flatMapLatest 切换日期,最后 collect 进 UiState。问题就藏在这条链路里——Flow 的收集过程中,上游任何一个环节抛异常(数据库查询失败、网络抖动、反序列化出错),异常都会顺着协程向上传播。
在 Android 上,这样的异常最终到达 Thread.uncaughtExceptionHandler,表现为一次普通的 Java Crash,崩溃 SDK 能完整捕获堆栈,运气好还有兜底逻辑。但这里有两个致命差异:
- KMP-ObservableViewModel 的
viewModelScope没有安装CoroutineExceptionHandler。它就是一个裸的SupervisorJob + Dispatchers.Main,异常传到根部没有任何处理者。 - Kotlin/Native 对"协程根部未处理异常"的默认处置是
propagateExceptionFinalResort——名字直译就是"最终手段传播异常”:在主线程上直接abort(),进程收到 SIGABRT,立即死亡。没有 Java 层的异常栈,没有崩溃 SDK 的从容上报,就是一刀毙命。
于是整条事故链条清晰了:
Room / 网络层抛出异常
→ Flow collect 中无人捕获
→ 沿 viewModelScope.launch 的 Job 树上传到根部
→ 根部没有 CoroutineExceptionHandler
→ Kotlin/Native: propagateExceptionFinalResort
→ abort() → SIGABRT → 进程消失为什么恰好是启动 3 秒?因为 HomeViewModel 在 init 里同时启动了三个 Flow 订阅,启动后数据库查询、缓存读取集中发生,异常在数据加载窗口内被引爆——3 秒正好是第一轮 Flow 发射到达的时间。
这也解释了为什么事故在迁移完成后才出现:迁移前这些订阅逻辑写在 iOS 本地的 StateHolder 里,for await 循环外面有 Swift 的 do/catch 兜底;迁移后逻辑下沉到 shared,兜底没有跟着下沉。
🔧 修复:给每个订阅一个兜底的"地"
修复方案很朴素:在每个 viewModelScope.launch 内部,用 try/catch 把 Flow 收集和业务调用整个包起来。以 observeDailyData() 为例:
private suspend fun observeDailyData() {
// viewModelScope 无 CoroutineExceptionHandler,Flow 收集中的任何异常
// 都会经 propagateExceptionFinalResort 在 iOS 上直接 abort,必须在此兜底
try {
combine(
_uiState.map { it.selectedDate }.distinctUntilChanged(),
currentUserIdFlow(),
) { date, uid -> date to uid }
.flatMapLatest { (date, uid) ->
combine(
getDailyBloodSugarRecordsFlow(dateTs, uid),
getDailyExerciseRecordsFlow(dateTs, uid),
getDailyFoodRecordsWithItemsFlow(dateTs, uid),
getDailyMedicationRecordsFlow(dateTs, uid),
getDailyInsulinRecordDtosFlow(dateTs, uid),
) { bsDtos, exDtos, foodDtos, medDtos, insulinDtos ->
enrichExerciseReferences(exDtos)
assembleStateChunk(bsDtos, exDtos, foodDtos, medDtos, insulinDtos)
}
}
.collect { chunk ->
_uiState.update { state -> chunk(state) }
}
} catch (e: CancellationException) {
throw e // 取消语义必须照常传播
} catch (e: Exception) {
AppLogger.e("HomeViewModel", e) { "订阅每日数据失败" }
_uiState.update { it.copy(isLoading = false, errorMessage = "加载失败: ${e.message}") }
}
}这套写法有三个不可省略的要点:
CancellationException必须 rethrow。协程取消本身就靠抛CancellationException实现,如果被catch (e: Exception)吞掉,视图销毁后订阅还会继续跑,就是第三篇里我们刚刚消灭掉的泄漏问题。所以它必须单独 catch 并原样抛出,保证取消语义完整。- 记日志,写 UiState。异常不再逃逸,但也不能静默吞掉——
AppLogger.e留排查线索,同时把isLoading = false和errorMessage写进 UiState,用户看到的是"加载失败"而不是整个 App 消失。崩溃和错误提示之间,隔着的就是这一个 catch。 - 兜底位置在 collect 外侧,不是上游每个 Flow。
combine/flatMapLatest链中任何一环抛出的异常都会传播到collect所在的协程,一个外层try/catch就能全部兜住,不需要给每个上游 Flow 单独包。
这次修复一共覆盖了 5 个 ViewModel:
| 文件 | 兜底位置 | 改动量 |
|---|---|---|
HomeViewModel.kt | 每日数据、周数据、血糖单位三个启动期订阅 | +67 -44 |
ProfileViewModel.kt | 用户访问状态订阅、用户资料订阅 | +40 -23 |
AIFoodScanViewModel.kt | AI 识谱的挂起调用 | +32 -22 |
AddFoodRecordViewModel.kt | AI 识餐提交 | +28 -19 |
AddMedicationRecordViewModel.kt | 药品名称订阅 | +17 -8 |
模式完全一致:try 包住整个订阅或调用,CancellationException rethrow,其余异常记日志 + 写 errorMessage。没有例外,没有变体。
🤔 为什么不装一个全局 CoroutineExceptionHandler?
第一反应可能是:既然根部缺处理器,那给 viewModelScope 装一个全局 CoroutineExceptionHandler 不就一劳永逸了?
// 看起来很美,但我们没有选这条路
val handler = CoroutineExceptionHandler { _, throwable ->
AppLogger.e("VM", throwable) { "未捕获异常" }
}原因是 CoroutineExceptionHandler 解决的是"进程不死",解决不了"用户看到什么":
- 它拿不到上下文。异常到达根部处理器时,只剩下一个
Throwable和一个CoroutineContext——不知道是哪条订阅挂了、用户在哪个页面、应该给哪个 UiState 写errorMessage。进程保住了,但页面永远卡在 loading 转圈。 - 它对
async无效。async的异常被封装进Deferred,要等await()才抛出,根本不经过根部处理器。 - 它是最后防线,不是业务逻辑。把"加载失败请重试"这种本该属于 UiState 的状态交给全局处理器,等于让错误处理脱离了状态管理的单轨——而我们整个架构的根基就是"UiState 是 UI 的唯一事实来源"。
局部 try/catch 的价值恰恰在于它在业务现场:知道失败的是哪个操作,能把异常翻译成该页面的 errorMessage,能把 isLoading 复位。全局处理器兜的是"万一漏了"的底,局部 catch 管的是"每个操作失败了怎么办"。两者不冲突,但后者是必须做的。
📌 沉淀:共享层异常处理的三条军规
事故修复后,我们把这次的教训固化成了共享层的编码约定:
viewModelScope.launch里 collect Flow,必须有try/catch兜底。这是本次事故的直接根因。Flow 的上游是数据库、网络、平台桥接——都是不可信边界,异常一定会发生,问题只是发生在哪台设备上。catch (e: CancellationException) { throw e }永远是第一个 catch 分支。取消语义是协程的命根子,吞掉它等于制造泄漏。这条写在最前面,形成肌肉记忆。- 异常只进两个出口:
AppLogger和UiState.errorMessage。不允许静默吞掉,也不允许逃逸出协程。用户可见的错误走 UiState 单轨,排查用的细节走日志——和整个架构"UiState 是唯一事实来源"的原则保持一致。
回头看,这类问题其实是 KMP 共享层的一个系统性陷阱:Kotlin 代码在 Android 上的容错惯性,不能直接平移到 iOS。JVM 世界有成熟的崩溃捕获体系托底,很多"没 catch 也没事"的代码就这样活了下来;而 Kotlin/Native 的 propagateExceptionFinalResort 对这种代码零容忍——未捕获,即死刑。共享层每下沉一分,这类平台行为差异就要多敬畏一分。
🍬 关于轻糖
轻糖(SugarLite) 是一款专注于血糖管理的 App,帮助糖尿病及血糖关注人群记录和分析血糖、饮食、运动、用药与胰岛素数据,并提供 AI 识餐、AI 食谱、统计报告等智能功能。iOS 与 Android 双端基于 Kotlin Multiplatform 构建,业务逻辑与状态管理全量共享。
- 官网:https://sugarlite.top
- App Store:下载轻糖
📚 系列文章与参考资料
- 第一篇:轻糖的 KMP 渐进式迁移实践
- 第二篇:轻糖的 KMP 渐进式迁移实践(二):ViewModel、本地存储与平台抽象
- 第三篇:轻糖的 KMP 渐进式迁移实践(三):KMPObservableViewModel 直连
- KMP-ObservableViewModel
本文基于轻糖项目的真实事故排查过程撰写。
相关内容
如果你觉得这篇文章对你有所帮助,欢迎赞赏~
赞赏