目录

轻糖的 KMP 实战:一次冷启动 3 秒必现崩溃,与 viewModelScope 异常逃逸的代价

第三篇文章收尾时,我们说迁移完成后的日常是"新功能默认 KMP-first"。这句话没错,但它隐藏了一个前提:共享层代码在两端的运行环境并不对等——同样的代码,在 Android 上是一次可捕获的异常,在 iOS 上可能是整个进程的直接死亡。

这篇文章记录一次真实事故:轻糖 iOS 版冷启动 3 秒必现 SIGABRT,堆栈指向一个陌生的符号 propagateExceptionFinalResort。根因不在 iOS 侧的任何一行 Swift 代码,而在 shared 层 ViewModel 里几个看似无害的 Flow 订阅——它们的异常从未被捕获,在 Kotlin/Native 上直接杀死了进程

现象非常规律:iOS 冷启动后,首页数据刚开始加载,大约 3 秒,App 闪退。必现,不分机型,不分系统版本。

崩溃报告里能看到的信息:

  • 信号是 SIGABRT,不是常见的 SIGSEGV / SIGTRAP;
  • 栈底出现 propagateExceptionFinalResort 这个符号——它不是我们的代码,也不是 Swift 运行时的,而是 Kotlin/Native 协程运行时的"最终手段"
  • Android 侧同样的版本、同样的数据,完全不崩。

“Android 不崩 iOS 必崩"这个组合,基本排除了业务逻辑错误(比如空指针、数组越界),指向了平台运行时的行为差异。而 propagateExceptionFinalResort 这个名字已经把事情说得很明白了:有异常一路传播到了协程的最顶端,没有人接住它,运行时只能把进程杀掉

回顾第三篇文章里的 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 能完整捕获堆栈,运气好还有兜底逻辑。但这里有两个致命差异:

  1. KMP-ObservableViewModel 的 viewModelScope 没有安装 CoroutineExceptionHandler。它就是一个裸的 SupervisorJob + Dispatchers.Main,异常传到根部没有任何处理者。
  2. Kotlin/Native 对"协程根部未处理异常"的默认处置是 propagateExceptionFinalResort——名字直译就是"最终手段传播异常”:在主线程上直接 abort(),进程收到 SIGABRT,立即死亡。没有 Java 层的异常栈,没有崩溃 SDK 的从容上报,就是一刀毙命。

于是整条事故链条清晰了:

Room / 网络层抛出异常
  → Flow collect 中无人捕获
  → 沿 viewModelScope.launch 的 Job 树上传到根部
  → 根部没有 CoroutineExceptionHandler
  → Kotlin/Native: propagateExceptionFinalResort
  → abort() → SIGABRT → 进程消失

为什么恰好是启动 3 秒?因为 HomeViewModelinit 里同时启动了三个 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}") }
    }
}

这套写法有三个不可省略的要点:

  1. CancellationException 必须 rethrow。协程取消本身就靠抛 CancellationException 实现,如果被 catch (e: Exception) 吞掉,视图销毁后订阅还会继续跑,就是第三篇里我们刚刚消灭掉的泄漏问题。所以它必须单独 catch 并原样抛出,保证取消语义完整。
  2. 记日志,写 UiState。异常不再逃逸,但也不能静默吞掉——AppLogger.e 留排查线索,同时把 isLoading = falseerrorMessage 写进 UiState,用户看到的是"加载失败"而不是整个 App 消失。崩溃和错误提示之间,隔着的就是这一个 catch
  3. 兜底位置在 collect 外侧,不是上游每个 Flowcombine / flatMapLatest 链中任何一环抛出的异常都会传播到 collect 所在的协程,一个外层 try/catch 就能全部兜住,不需要给每个上游 Flow 单独包。

这次修复一共覆盖了 5 个 ViewModel:

文件兜底位置改动量
HomeViewModel.kt每日数据、周数据、血糖单位三个启动期订阅+67 -44
ProfileViewModel.kt用户访问状态订阅、用户资料订阅+40 -23
AIFoodScanViewModel.ktAI 识谱的挂起调用+32 -22
AddFoodRecordViewModel.ktAI 识餐提交+28 -19
AddMedicationRecordViewModel.kt药品名称订阅+17 -8

模式完全一致:try 包住整个订阅或调用,CancellationException rethrow,其余异常记日志 + 写 errorMessage。没有例外,没有变体。

第一反应可能是:既然根部缺处理器,那给 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 管的是"每个操作失败了怎么办"。两者不冲突,但后者是必须做的。

事故修复后,我们把这次的教训固化成了共享层的编码约定:

  1. viewModelScope.launch 里 collect Flow,必须有 try/catch 兜底。这是本次事故的直接根因。Flow 的上游是数据库、网络、平台桥接——都是不可信边界,异常一定会发生,问题只是发生在哪台设备上。
  2. catch (e: CancellationException) { throw e } 永远是第一个 catch 分支。取消语义是协程的命根子,吞掉它等于制造泄漏。这条写在最前面,形成肌肉记忆。
  3. 异常只进两个出口:AppLoggerUiState.errorMessage。不允许静默吞掉,也不允许逃逸出协程。用户可见的错误走 UiState 单轨,排查用的细节走日志——和整个架构"UiState 是唯一事实来源"的原则保持一致。

回头看,这类问题其实是 KMP 共享层的一个系统性陷阱:Kotlin 代码在 Android 上的容错惯性,不能直接平移到 iOS。JVM 世界有成熟的崩溃捕获体系托底,很多"没 catch 也没事"的代码就这样活了下来;而 Kotlin/Native 的 propagateExceptionFinalResort 对这种代码零容忍——未捕获,即死刑。共享层每下沉一分,这类平台行为差异就要多敬畏一分。


轻糖(SugarLite) 是一款专注于血糖管理的 App,帮助糖尿病及血糖关注人群记录和分析血糖、饮食、运动、用药与胰岛素数据,并提供 AI 识餐、AI 食谱、统计报告等智能功能。iOS 与 Android 双端基于 Kotlin Multiplatform 构建,业务逻辑与状态管理全量共享。


本文基于轻糖项目的真实事故排查过程撰写。

相关内容