一句话总结:
launch中的未捕获异常会遵循其父Job的“责任制”:在默认Job下,它会触发“连带责任”,导致整个协程家族被取消;在SupervisorJob下,它遵循“独立责任”,不会影响兄弟协程。但无论哪种情况,未被try-catch的异常最终都会冒泡到根作用域寻求CoroutineExceptionHandler的处理。
一、根本原则:在Job的世界里,未捕获的异常即“取消”信号
在默认的结构化并发模型中,父Job和子Job形成了一个责任共同体。任何一个子协程的失败(未捕獲異常),都被视为整个父任务的失败。
val scope = CoroutineScope(Job())
scope.launch { // 父协程 (Job)
launch { // 子协程 A
delay(100)
println("子协程 A 完成")
}
launch { // 子协程 B
throw Exception("子协程 B 失败") // 💥
}
}
// 结果:子协程 B 的异常会立即取消父协程,进而导致子协程 A 也在完成前被取消。
// 整个 launch 任务族群失败。这是设计特性,保证了任务的原子性。
二、致命陷阱的深度解析与纠正
陷阱1:try-catch为何“包不住”launch?
初学者常犯的错误:
// 这种写法通常无效
try {
scope.launch {
delay(100)
throw Exception("异常发射!")
}
} catch (e: Exception) {
// ❌ 异常几乎永远不会在这里被捕获
println("捕获失败")
}
原因:launch是非阻塞的,它会立即返回一个Job实例,然后try-catch代码块就执行结束了。当100ms后子协程抛出异常时,try-catch早已“不在现场”。
如何才能捕获? 必须阻塞等待协程完成:
// 这种写法可以捕获,但将异步变为了同步
runBlocking {
val job = launch {
throw Exception("异常发射!")
}
try {
job.join() // 阻塞等待 job 完成
} catch (e: Exception) {
// ✅ join() 会重新抛出协程的异常
println("捕获成功: $e")
}
}
结论:try-catch应该用在协程体内部,而不是外部。
陷阱2:CoroutineExceptionHandler何时会触发?
错误观念:只有顶级协程的异常才能触发Handler。
正解:任何在Job层级中未被try-catch捕获的、最终导致根作用域(Scope)失败的异常,都会触发Handler。
val handler = CoroutineExceptionHandler { _, e -> println("Handler捕获: $e") }
val scope = CoroutineScope(Job() + handler)
scope.launch { // 父协程
launch { // 子协程
throw Exception("子协程的异常") // 💥
}
}
// 真实流程:
// 1. 子协程抛异常,自己失败。
// 2. 异常向上传播给父协程。
// 3. 父协程因孩子的失败而失败(取消)。
// 4. 父协程是根协程,它的失败将由其上下文中的 Handler 处理。
// ✅ Handler 会被触发!
三、正确的防御体系:“三道防线”模型
第一道防线:try-catch(内部消化)
最优先、最局部的防御。在launch代码块内部使用,可以完全阻止异常的传播。
scope.launch {
try {
// ... 危险操作 ...
} catch (e: Exception) {
// 异常被完全处理,不会影响父协程或兄弟协程
}
}
第二道防线:supervisorScope(隔离兄弟)
当你希望一组并发任务“各司其职、互不牵连”时,使用supervisorScope。
val handler = CoroutineExceptionHandler { _, e -> println("Handler捕获: $e") }
val scope = CoroutineScope(Job() + handler)
scope.launch {
supervisorScope {
launch {
throw Exception("任务A失败") // 💥
}
launch {
delay(100)
println("任务B不受影响,继续执行") // ✅
}
}
}
// 结果:
// 1. "任务B..." 会被打印。
// 2. 任务A的异常不会取消supervisorScope,但会继续向上传播。
// 3. 最终,异常被顶层的 handler 捕获。
第三道防线:CoroutineExceptionHandler(最终报告)
它是作用域的最后一道防线,用于处理所有未被try-catch捕获的异常。它的核心职责是日志记录、错误上报,而不是业务逻辑的恢复。
四、Android viewModelScope 最佳实践
-
天生优势:
viewModelScope默认配置了SupervisorJob()。这意味着在viewModelScope中直接launch的多个协程,它们之间是兄弟关系,一个失败不会影响另一个。// 在ViewModel中 fun loadData() { // 请求用户信息(可能失败) viewModelScope.launch { api.fetchUser() } // 请求产品列表(可能失败) viewModelScope.launch { api.fetchProducts() } } // fetchUser失败,不会影响fetchProducts的执行。 -
推荐模式:
- 直接使用
viewModelScope.launch启动独立的任务。 - 在每个
launch的内部,使用try-catch来处理各自的业务异常(如,请求失败时更新UI显示错误信息)。 - (可选)可以在创建
ViewModel时,为其注入一个自定义的CoroutineExceptionHandler,用于统一记录那些意料之外的、未被try-catch的异常。
- 直接使用
通过这套清晰的模型,你可以准确地预测和控制launch中的异常流,构建出真正健壮的并发应用。