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 异常的聚合与传播:到底是怎么传的?

这部分稍微有点绕,但理解了就一通百通。协程的异常传播,遵循两条规则:

  1. 向上传播:子协程的异常会传给父协程,父协程再传给它的父协程,直到根Scope
  2. 聚合传播:如果多个子协程同时抛异常,它们会被聚合成一个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)
        }
    }
}

这个方案的好处是:

  • 全局异常有兜底,不会漏掉
  • 并行任务互不影响,一个失败不影响其他
  • 局部异常可以精细处理,不影响整体流程

最后说一句:协程的异常处理,核心就三件事——知道异常怎么传播、知道怎么拦截、知道怎么隔离。把这三点搞明白了,你写的协程代码就会稳如老狗。

协程异常处理知识体系 try-catch CoroutineExceptionHandler SupervisorJob 异常聚合与传播 异常 使用场景 • launch内部直接捕获 • async通过await获取 • 局部异常精细处理 ⚠ 不能捕获所有场景 全局兜底 • 只对launch有效 • async异常需await • 用于日志上报 ⚠ 不能替代try-catch 隔离异常 • 子协程互不影响 • 异常不向上传播 • 适合并行独立任务 ✓ 推荐在Scope中使用 传播规则 • 向上传播至根Scope • 多个异常聚合为 CancellationException ⚠ supervisorScope不聚合 最佳实践:try-catch做局部处理 + CoroutineExceptionHandler做全局兜底 + SupervisorJob隔离并行任务 + 理解传播规则避免踩坑 公众号:蓝海资料掘金营,微信deep3321