9. 异常处理
一、全家福(按使用频率)
| 频率 | API | 是什么 | 典型场景 |
|---|---|---|---|
| 🔥 极高 | try / catch | 在挂起点附近兜住业务异常 | 单次请求失败转 UI 错误态 |
| 🔥 极高 | CoroutineExceptionHandler | 作用域级兜底,收未捕获异常 | 根作用域的总崩溃防护 |
| 🔥 极高 | supervisorScope / SupervisorJob | 子任务独立失败,互不连坐 | 多个独立任务,一败不拖垮其他 |
| 高 | ensureActive() | 检查取消并抛出 CancellationException | 循环里配合取消 |
| 高 | runCatching { ... }.getOrElse { ... } | 函数式兜异常,返回 Result | Repository 统一包成 Result |
| 中 | catch(Flow 操作符) | 在 Flow 链中兜异常 | 数据流中段的异常恢复 |
| 中 | coroutineContext[Job] 判取消 | 区分「业务失败」与「被取消」 | finally 里判断是否要回滚 |
| 低 | getCancellationException() | 拿到标准化的取消异常再抛 | 包装回调式取消 |
二、try / catch(最常用,ViewModel)
把一次请求包在 try/catch 里,失败转成 UI 状态。下面写在 ViewModel 的方法中。
class UserViewModel(private val repo: UserRepository) : ViewModel() {
private val _state = MutableStateFlow<UiState>(UiState.Loading)
val state: StateFlow<UiState> = _state.asStateFlow()
fun loadUser(id: String) = viewModelScope.launch {
try {
val u = repo.getUser(id) // 可能抛 IOException / HttpException
_state.value = UiState.Success(u)
} catch (e: CancellationException) {
throw e // 必须原样抛出,保留取消信号
} catch (e: IOException) {
_state.value = UiState.Error(e.message ?: "网络错误")
}
}
}
把异常在 Repository 转成 Result<T> 或密封类,ViewModel 就不用每个调用都写 try/catch:when(val r = repo.getUser(id)) { is Result.Success -> ...; is Result.Error -> ... }。
三、CoroutineExceptionHandler(作用域级兜底)
子协程抛了异常但没人 try/catch,异常会一路上抛到根作用域。在根作用域挂一个 Handler 做「最后防线」,避免直接崩 App。代码写在 ViewModel 里。
class MainViewModel : ViewModel() {
private val handler = CoroutineExceptionHandler { _, e ->
Log.e("Coroutine", "未捕获: ${e.message}") // 上报埋点 / 弹 Toast
}
fun riskyJob() = viewModelScope.launch(handler) {
throw RuntimeException("boom") // 作用域级兜底会收到
}
}
重要限制:Handler 只会接住「未捕获」的异常;已经被 try/catch 兜住的不归它管。而且 子协程的异常默认会先取消父作用域,Handler 收到时协程树往往已崩——所以它更多用于「上报 / 兜底日志」,而不是「继续运行」。
四、supervisorScope / SupervisorJob(互不连坐)
普通 coroutineScope 中任一子协程失败,其他兄弟会被一起取消。要「互不连坐」,用 supervisorScope 或 SupervisorJob。下面写在 Repository 或 UseCase 里。
class WorkRepository {
// 两个子任务互不影响:一个失败不会把另一个也取消
suspend fun loadParallel(): ResultData = supervisorScope {
val userJob = async { fetchUser() } // A 失败不影响 B
val newsJob = async { fetchNews() } // B 照常运行
ResultData(
userJob.awaitOrNull(), // 各自 try/catch 转默认值
newsJob.awaitOrNull()
)
}
}
好消息:viewModelScope 内部就是 SupervisorJob,所以你在 ViewModel 里 launch 两个独立任务,一个崩不会拖垮另一个。但 coroutineScope { } 块内不是——那里面仍然是「连坐」语义。
五、真实开发中会踩的坑
踩坑 1:async 的异常延迟到 await 才抛:async 块里抛异常不会立刻冒出来,要等调用 await()。所以「两个 async 并行,其中一个挂了但另一个先 await 成功」时,失败那一个会在你 await 它的瞬间才炸。调试时别被「报错位置」骗了——真正的根因在另一个任务。
踩坑 2:CancellationException 被当成业务错误:catch (e: Exception) 会连取消异常一起抓。一旦你把它「吞掉」当业务失败,协程就无法被正常取消,父作用域以为它还活着,导致内存泄漏 / 状态被旧任务覆盖。原则:CancellationException 一律 throw e 原样上抛。
踩坑 3:以为 Handler 能「救活」崩溃的协程:Handler 只是「通知你发生了未捕获异常」,不会阻止协程树被取消。想让部分任务失败时其他继续跑,必须用 supervisorScope / SupervisorJob + 各自 try/catch,而不是指望 Handler。
踩坑 4:异常被「静默吞掉」:根作用域(如 GlobalScope)抛出的未捕获异常,某些环境只打日志不崩,看起来「没事」实则逻辑没执行。给关键根作用域挂 Handler 并上报,至少能发现问题。
踩坑 5:在 finally 里又抛异常覆盖原错误:清理 / 落库逻辑放在 finally 时,如果它抛异常,会覆盖 try 块里原本的异常,导致真实错误丢失。finally 里只做幂等清理,必要时单独 try/catch 收住。