内容提要
轻糖iOS版冷启动3秒必现崩溃,根因是共享层ViewModel的Flow订阅异常未捕获,在Kotlin/Native上触发SIGABRT。修复方案是在每个订阅外层加try/catch,CancellationException需rethrow,异常记日志并写入UiState。沉淀三条军规:订阅必须兜底、取消异常优先处理、异常只进日志和UiState。
延伸解读
为什么 Android 不崩 iOS 崩?
同样的共享层代码,在 Android 上异常会被 JVM 的崩溃捕获体系兜住,表现为可捕获的 Java Crash;而在 Kotlin/Native 上,未捕获的协程异常会触发 propagateExceptionFinalResort,直接调用 abort() 导致 SIGABRT。这种平台差异意味着,在 Android 上“没 catch 也没事”的代码,迁移到 iOS 后可能变成致命崩溃,是 KMP 共享层开发中需要特别警惕的系统性陷阱。
全局异常处理器为何不是万能药?
给 viewModelScope 安装全局 CoroutineExceptionHandler 虽然能防止进程崩溃,但无法解决用户体验问题:它拿不到订阅上下文,不知道是哪个页面、哪条订阅失败,也无法更新对应的 UiState,页面可能永远卡在加载状态。此外,它对 async 启动的协程无效,因为异常被封装在 Deferred 中。因此,局部 try/catch 仍是必须的,它能在业务现场处理异常,将错误转化为用户可见的提示。
修复模式的核心要点
修复方案是在每个 viewModelScope.launch 内部用 try/catch 包裹整个 Flow 收集链,但必须注意三点:CancellationException 必须单独捕获并 rethrow,否则会破坏协程取消语义,导致泄漏;异常不能静默吞掉,要记录日志并写入 UiState,让用户看到错误提示;兜底位置在 collect 外侧即可,因为 combine/flatMapLatest 链中任何异常都会传播到 collect 所在协程,无需为每个上游 Flow 单独处理。
Q&A
轻糖iOS版冷启动后3秒必现崩溃的根因是什么?
根因是共享层ViewModel中的Flow订阅异常未被捕获,在Kotlin/Native上触发SIGABRT导致进程崩溃。具体是viewModelScope没有安装CoroutineExceptionHandler,异常传播到根部后,Kotlin/Native通过propagateExceptionFinalResort直接abort。
为什么同样的代码在Android上不崩溃而在iOS上崩溃?
因为Android有成熟的崩溃捕获体系,未捕获异常会交给Thread.uncaughtExceptionHandler,表现为普通Java Crash;而Kotlin/Native对协程根部未处理异常会调用propagateExceptionFinalResort,直接abort进程,导致SIGABRT。
修复方案是什么?具体如何操作?
修复方案是在每个viewModelScope.launch内部用try/catch包裹Flow收集和业务调用。具体要点:CancellationException必须单独catch并rethrow;其他异常记日志并写入UiState的errorMessage;兜底位置在collect外侧,一个外层try/catch即可覆盖整个链路。
为什么不能只安装一个全局CoroutineExceptionHandler?
因为全局CoroutineExceptionHandler解决不了用户看到什么:它拿不到上下文,不知道是哪条订阅挂了、用户在哪个页面、该给哪个UiState写errorMessage,可能导致页面卡在loading。此外它对async无效,且不适合作为业务逻辑处理。局部try/catch能在业务现场处理异常,将异常翻译成errorMessage并复位isLoading。
共享层异常处理的三条军规是什么?
1. viewModelScope.launch里collect Flow必须有try/catch兜底;2. catch (e: CancellationException) { throw e }必须是第一个catch分支;3. 异常只进两个出口:AppLogger和UiState.errorMessage,不允许静默吞掉或逃逸出协程。
为什么异常恰好发生在启动3秒时?
因为HomeViewModel在init里同时启动了三个Flow订阅,启动后数据库查询、缓存读取集中发生,异常在数据加载窗口内被引爆,3秒正好是第一轮Flow发射到达的时间。