在 Kotlin 协程中,异常处理是一个重要且需要仔细理解的话题。协程的异常处理遵循 结构化并发 的原则,异常会沿着协程层次结构向上传播,如果未被妥善处理,可能会导致父协程乃至整个作用域的取消。下面我将从基本规则开始,逐步深入介绍协程的异常处理机制,并给出 Android 开发中的最佳实践。
🧱 结构化并发与异常传播
在结构化并发中,每个协程都有一个父协程(除了根协程)。子协程抛出未捕获的异常时,默认行为是:
- 异常传播给父协程:父协程收到子协程的异常后,会取消自己以及所有其他子协程。
- 异常继续向上传播:如果父协程也没有处理该异常,它会继续向上传播,直到根协程或顶层协程。
- 根协程未处理异常:如果到达顶层协程且未处理,则整个协程树取消,异常会被抛出到线程的未捕获异常处理器(可能导致应用崩溃)。
这种设计的目的是保持并发操作的原子性:如果一个子任务失败,整个任务树都应失败,确保不会处于不一致的状态。
🚀 launch 与 async 的异常行为差异
协程的两种启动方式对异常的处理有显著区别:
1. launch
launch 启动的协程如果内部抛出未捕获异常,会立即传播给父协程,并且父协程会取消所有子协程。示例:
kotlin
fun main() = runBlocking {
val scope = CoroutineScope(Job())
scope.launch {
launch {
delay(100)
throw RuntimeException("child failed")
}
launch {
delay(200)
println("this will not be printed")
}
}
delay(300)
println("scope is still active: ${scope.coroutineContext.job.isActive}") // false
}
输出:子协程异常导致兄弟协程被取消,整个作用域的 job 也变为非活跃。
2. async
async 类似于 launch,但返回一个 Deferred,其异常不会自动传播,而是延迟到调用 await() 时抛出 。如果从未调用 await(),异常会被悄悄吞没。例如:
kotlin
fun main() = runBlocking {
val scope = CoroutineScope(Job())
val deferred = scope.async {
throw RuntimeException("async failed")
}
delay(100) // 不调用 await,异常被吞没
println("scope is still active: ${scope.coroutineContext.job.isActive}") // true
}
如果调用 deferred.await(),则异常会抛出,并且传播到父协程:
kotlin
scope.launch {
try {
deferred.await()
} catch (e: Exception) {
println("Caught: $e")
}
}
重要 :async 通常用于启动一个预期会返回结果的任务,应始终调用 await() 或使用 awaitCatching 等扩展来处理异常。
🛠️ 异常处理机制
1. try-catch 在协程内部使用
在协程代码块内部,可以直接使用 try-catch 捕获异常,防止其传播。例如:
kotlin
scope.launch {
try {
riskyOperation()
} catch (e: Exception) {
// 处理异常
}
}
但注意,try-catch 只能捕获当前协程体内的异常,无法捕获其他协程抛出的异常。如果要捕获多个子协程的异常,需要在父协程处理或使用监督作用域。
2. CoroutineExceptionHandler
CoroutineExceptionHandler 是一个上下文元素,用于处理未捕获的异常 。它只对顶层协程(即直接由作用域启动的协程)生效,且必须安装在协程上下文中。
kotlin
val handler = CoroutineExceptionHandler { _, exception ->
println("Caught $exception")
}
val scope = CoroutineScope(Job() + handler)
scope.launch {
throw RuntimeException("oops")
}
如果异常被 try-catch 捕获,则不会触发 handler。handler 常用于记录未处理异常,避免应用崩溃。
注意 :handler 对 async 无效,因为 async 的异常需通过 await 抛出。
3. 监督作用域 (supervisorScope)
监督作用域改变了异常传播的方向:子协程的失败不会影响父协程和其他兄弟协程。它适用于不希望一个子任务失败就取消整个作用域的场景(如多个独立的 UI 组件)。
kotlin
fun main() = runBlocking {
supervisorScope {
launch {
delay(100)
throw RuntimeException("child 1 failed")
}
launch {
delay(200)
println("child 2 completed")
}
}
println("supervisorScope completed")
}
输出:
php
Exception...
child 2 completed
supervisorScope completed
可以看到,一个子协程失败后,兄弟协程仍正常运行,且作用域在最后完成。
supervisorScope 与 CoroutineExceptionHandler
在 supervisorScope 中,未捕获的异常仍会向上传播,但不会取消父协程。顶层协程的异常仍可通过 handler 捕获。
4. 监督 Job (SupervisorJob)
SupervisorJob 是一个特殊的 Job,它作为父 Job 时,其子 Job 的失败不会影响父 Job 和其他子 Job。与 supervisorScope 的区别是,SupervisorJob 是一个 Job 实现,而 supervisorScope 是一个作用域构建器,它会创建一个新的独立作用域。
kotlin
val supervisor = SupervisorJob()
val scope = CoroutineScope(supervisor + handler)
scope.launch { ... } // 失败不会传播到 supervisor
注意 :SupervisorJob 的失败传播特性只影响其直接子协程。如果一个子协程内部又启动了新的协程(孙协程),孙协程的异常会传播给子协程,但如果子协程本身是普通 Job,则可能导致子协程及其父链传播。
🧪 取消与异常的关系
协程取消会抛出 CancellationException,这是一种特殊的异常,不会被异常处理器捕获,也不会触发异常传播机制。它只是让协程正常结束。例如:
kotlin
scope.launch {
delay(1000)
println("done")
}
scope.cancel() // 导致协程抛出 CancellationException,协程结束,无异常输出
如果我们在协程内捕获了 CancellationException 并阻止其传播,可能会导致协程无法正确取消,应尽量避免捕获此异常或确保重新抛出。
📱 Android 中的协程异常处理最佳实践
在 Android 应用中,通常采用以下模式处理协程异常:
1. ViewModel 中使用 viewModelScope
viewModelScope 默认是 SupervisorJob(),这意味着一个协程失败不会取消其他协程。这很合理,因为 UI 层多个独立任务(如分别加载不同数据)不应相互影响。
kotlin
class MyViewModel : ViewModel() {
fun loadData() {
viewModelScope.launch {
try {
val data = repository.fetch()
_uiState.value = Success(data)
} catch (e: Exception) {
_uiState.value = Error(e.message)
}
}
}
}
2. 在 Repository 或 UseCase 中使用 supervisorScope 隔离失败
当在数据层执行多个并行任务时,使用 supervisorScope 防止单个任务失败影响其他任务:
kotlin
suspend fun fetchData(): Result<CombinedData> = supervisorScope {
val deferred1 = async { fetchUser() }
val deferred2 = async { fetchPosts() }
try {
val user = deferred1.await()
val posts = deferred2.await()
Result.success(CombinedData(user, posts))
} catch (e: Exception) {
deferred1.cancel() // 手动取消未完成的任务
deferred2.cancel()
Result.failure(e)
}
}
3. 使用 CoroutineExceptionHandler 记录未捕获异常
在 Application 层或顶层作用域安装全局异常处理器,用于记录未预期的异常。
kotlin
val appScope = CoroutineScope(SupervisorJob() + CoroutineExceptionHandler { _, throwable ->
CrashReporter.log(throwable)
})
4. 在 UI 层收集 Flow 时使用 repeatOnLifecycle 和 catch 操作符
Flow 本身提供 catch 操作符处理上游异常,确保 UI 层不会因异常崩溃。
kotlin
viewLifecycleOwner.lifecycleScope.launch {
viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.uiState
.catch { e -> emit(ErrorState(e.message)) }
.collect { state -> render(state) }
}
}
⚠️ 常见陷阱
- 异常被吞没 :
async启动协程后不调用await(),导致异常无声消失。 - 在 launch 中抛出异常但未处理:导致父协程取消,可能影响其他任务。
- 在 supervisorScope 中忘记处理异常:虽然不影响兄弟协程,但异常仍会向上传播,可能导致上层作用域取消。
- 错误地捕获 CancellationException:可能导致协程无法及时响应取消,浪费资源。
- 使用 SupervisorJob 作为父 Job,但子 Job 内又启动协程时:如果子 Job 是普通 Job,则孙协程的异常仍会传播到子 Job,导致子 Job 失败(但不会影响 SupervisorJob 的其他子协程)。需要明确异常传播的边界。
📚 总结
| 场景 | 推荐处理方式 |
|---|---|
| 协程内部的可预期异常 | try-catch 包围代码块 |
| 不希望一个子协程失败影响其他子协程 | 使用 supervisorScope 或 SupervisorJob |
| 处理顶层协程未捕获异常 | 设置 CoroutineExceptionHandler |
| 需要等待结果并处理异常 | 使用 async 并安全地 await(),或使用 runCatching 辅助 |
| 取消异常 | 不要捕获或处理,除非特殊需要 |