协程速通 · Android

9. 异常处理

一、全家福(按使用频率)

频率API是什么典型场景
🔥 极高try / catch在挂起点附近兜住业务异常单次请求失败转 UI 错误态
🔥 极高CoroutineExceptionHandler作用域级兜底,收未捕获异常根作用域的总崩溃防护
🔥 极高supervisorScope / SupervisorJob子任务独立失败,互不连坐多个独立任务,一败不拖垮其他
高ensureActive()检查取消并抛出 CancellationException循环里配合取消
高runCatching { ... }.getOrElse { ... }函数式兜异常,返回 ResultRepository 统一包成 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 层先包一层更省事

把异常在 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

好消息: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 收住。

Kotlin 协程速通 · Android 开发 · 内容基于 kotlinx.coroutines 1.7+