5、协程异常处理:try-catch在协程中的使用、CoroutineExceptionHandler、SupervisorJob与监督作用域、异常的聚合与传播
协程的异常处理,说实话,是很多Android开发者容易踩坑的地方。我刚用协程那会儿,以为跟Java线程一样,try-catch包一下就行了。结果呢?有些异常抓到了,有些直接崩了,还有些悄无声息地消失了。嗯,今天我们就来彻底搞明白这件事。
核心认知:协程的异常处理机制,跟传统的try-catch有本质区别。它有一套自己的传播和聚合规则。不理解这套规则,你写的协程代码就是一颗定时炸弹。
5.1 try-catch 在协程中的正确姿势
先说说最基础的。在协程里用try-catch,你得知道它什么时候管用,什么时候不管用。
// 这样写没问题,异常能被捕获
viewModelScope.launch {
try {
val result = apiService.fetchData()
// 处理结果
} catch (e: Exception) {
// 处理异常
Log.e("TAG", "请求失败", e)
}
}
// 这样写就有问题了
viewModelScope.launch {
try {
apiService.fetchData() // 假设这是个挂起函数
} catch (e: Exception) {
// 这个catch可能抓不到所有异常
}
}
为什么第二个例子有问题?因为协程内部的异常传播机制,跟普通代码不一样。我遇到过这样一个场景:一个同事在launch里包了try-catch,结果子协程抛异常了,父协程直接崩了。他一脸懵地问我:“我明明catch了啊?”
其实原因很简单——launch是“即发即忘”的。它不会把异常返回给调用方。如果你在launch内部没有正确处理,异常就会沿着协程树向上传播。
我的习惯:在launch内部,尽量把try-catch放在最外层。如果逻辑复杂,我会把可能抛异常的部分抽成一个单独的函数,用Result
// 更安全的写法
viewModelScope.launch {
val result = safeCall {
apiService.fetchData()
}
result.onSuccess { data ->
// 更新UI
}.onFailure { error ->
// 显示错误
}
}
suspend fun <T> safeCall(block: suspend () -> T): Result<T> {
return try {
Result.success(block())
} catch (e: Exception) {
Result.failure(e)
}
}
5.2 CoroutineExceptionHandler:全局的异常兜底
try-catch搞不定所有情况。比如你启动了一个协程,但忘了写catch。或者协程在某个深层嵌套的地方抛异常了。这时候就需要CoroutineExceptionHandler来兜底。
说白了,它就像一个全局的异常捕获器。但注意——它只对launch有效,对async无效。为什么?因为async的异常是通过await()传递的,你可以在调用方处理。
val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
Log.e("TAG", "未捕获的协程异常: ${throwable.message}")
// 这里可以做上报、弹窗等操作
}
viewModelScope.launch(exceptionHandler) {
// 这里抛异常会被handler捕获
throw RuntimeException("测试异常")
}
我曾经踩过的坑:把CoroutineExceptionHandler加到了async的协程上下文中。结果发现异常根本没被捕获,程序直接崩了。后来才明白,async的异常必须通过await()来获取,handler只管launch。
还有一个细节:CoroutineExceptionHandler只在子协程没有其他异常处理器时生效。如果子协程自己catch了,handler就不会被调用。这跟Android的View事件分发有点像——事件被消费了,就不会继续传递。
5.3 SupervisorJob:让异常不“连坐”
这是协程异常处理里最核心的概念之一。默认情况下,协程的异常是会传播的——一个子协程挂了,父协程和兄弟协程都得遭殃。这叫做“连坐机制”。
但很多时候,我们不希望这样。比如你同时发三个网络请求,其中一个失败了,另外两个应该继续跑完。这时候就需要SupervisorJob。
// 默认行为:一个失败,全部取消
val scope = CoroutineScope(Job())
scope.launch {
launch {
delay(100)
throw RuntimeException("子协程1挂了")
}
launch {
delay(200)
// 这个协程会被取消,因为兄弟抛异常了
}
}
// 使用SupervisorJob:互不影响
val scope = CoroutineScope(SupervisorJob())
scope.launch {
launch {
delay(100)
throw RuntimeException("子协程1挂了")
}
launch {
delay(200)
// 这个协程正常执行,不受影响
}
}
你想想看,这个机制在实际项目中多有用。我做过一个图片加载的模块,需要同时加载缩略图、原图、水印图。如果用默认的Job,一张图加载失败,其他两张也跟着取消,用户体验极差。换成SupervisorJob后,各管各的,失败的就显示占位图,成功的正常展示。
关键区别:
- Job:子协程异常会向上传播,取消父协程和兄弟协程
- SupervisorJob:子协程异常不会传播,只影响自己
5.4 监督作用域:supervisorScope
除了SupervisorJob,Kotlin还提供了supervisorScope。它俩的区别在于:SupervisorJob是作用在协程构建时的,而supervisorScope是作用在代码块级别的。
viewModelScope.launch {
supervisorScope {
launch {
delay(100)
throw RuntimeException("子任务1失败")
}
launch {
delay(200)
// 这个任务不受影响,正常执行
}
}
// 注意:supervisorScope内部的异常不会传播到这里
}
我个人的习惯是:在需要并行执行多个独立任务时,优先用supervisorScope。因为它更灵活,可以精确控制异常的作用范围。而SupervisorJob适合用在全局的Scope定义上。
一个小技巧:如果你用supervisorScope,记得它本身不会捕获异常。异常会继续向上传播。所以最好在supervisorScope外面再包一层try-catch,或者用CoroutineExceptionHandler兜底。
5.5 异常的聚合与传播:到底是怎么传的?
这部分稍微有点绕,但理解了就一通百通。协程的异常传播,遵循两条规则:
- 向上传播:子协程的异常会传给父协程,父协程再传给它的父协程,直到根Scope
- 聚合传播:如果多个子协程同时抛异常,它们会被聚合成一个CancellationException,里面包含所有子异常
// 异常的聚合
viewModelScope.launch {
coroutineScope {
launch {
delay(100)
throw RuntimeException("异常A")
}
launch {
delay(200)
throw RuntimeException("异常B")
}
}
}
// 最终抛出的异常会包含A和B两个cause
为什么会这样?因为协程的设计哲学是“要么全成功,要么全失败”。当多个子协程同时失败时,父协程需要知道所有失败的原因,而不是只看到第一个。
我记得有一次排查线上bug,日志里只看到一个CancellationException,没有具体原因。后来才发现,是因为多个子协程同时抛异常,它们被聚合了,但日志只打印了外层异常。从那以后,我处理异常时都会把cause链完整打印出来。
注意:异常的聚合只在coroutineScope中有效。在supervisorScope中,子协程的异常不会聚合,因为它们不会传播给父协程。
5.6 实战:一个完整的异常处理方案
说了这么多,我们来总结一个实际项目中可用的异常处理方案。这是我目前在用的模式:
// 1. 定义全局异常处理器
val globalExceptionHandler = CoroutineExceptionHandler { _, throwable ->
// 上报异常
CrashReport.postException(throwable)
// 显示通用错误提示
Toast.makeText(context, "操作失败,请稍后重试", Toast.LENGTH_SHORT).show()
}
// 2. 创建带SupervisorJob的Scope
val appScope = CoroutineScope(SupervisorJob() + Dispatchers.Main + globalExceptionHandler)
// 3. 在ViewModel中使用
class MyViewModel : ViewModel() {
private val scope = viewModelScope + SupervisorJob() + globalExceptionHandler
fun loadData() {
scope.launch {
supervisorScope {
// 并行加载,互不影响
launch { loadUserInfo() }
launch { loadConfig() }
launch { loadBanner() }
}
}
}
private suspend fun loadUserInfo() {
try {
val user = apiService.getUser()
// 更新UI
} catch (e: Exception) {
// 局部处理,比如显示默认头像
Log.w("TAG", "加载用户信息失败", e)
}
}
}
这个方案的好处是:
- 全局异常有兜底,不会漏掉
- 并行任务互不影响,一个失败不影响其他
- 局部异常可以精细处理,不影响整体流程
最后说一句:协程的异常处理,核心就三件事——知道异常怎么传播、知道怎么拦截、知道怎么隔离。把这三点搞明白了,你写的协程代码就会稳如老狗。