Kotlin协程的异常处理,最核心的痛点是:结构化并发下的异常传播机制与常规同步代码的直觉完全不同。很多开发者从线程模型迁移过来,习惯性地在协程体外套一个try-catch,结果发现根本捕获不到异常,程序直接崩溃。这不是Bug,而是协程设计上对“异常透明性”和“取消传播”的刻意约束。要写好异常安全的协程代码,必须先理解CoroutineScope、Job和异常传播路径这三者之间的关系。

顶层协程与根协程的异常传播差异

在协程世界,异常能否被外部捕获,取决于你启动协程的方式。使用GlobalScope.launch启动的协程没有父Job,它属于独立作用域,内部抛出的未捕获异常会直接分发给线程的未捕获异常处理器,最终可能导致应用崩溃。而通过某个CoroutineScope启动的协程,异常会沿着Job层级向上传播,直到到达根协程。关键区别在于launch和async这两种构建器:launch构建的协程在遇到异常时会立即取消自身,并将异常传播给父协程,父协程收到异常后会取消所有子协程,然后继续向上传播;async构建的协程则不同,它不会自动向上传播异常,除非你显式调用await(),异常才会被重新抛出。这意味着如果你用async启动了一个协程却忘记调用await,异常就会被默默吞掉,造成极其隐蔽的逻辑错误。

// 错误示例:async的异常不会自动传播
suspend fun fetchData() = coroutineScope {
    val deferred = async {
        throw RuntimeException("网络请求失败")
    }
    // 如果没有调用deferred.await(),异常永远不会被抛出
    // 程序继续执行,数据为空,难以排查
}

// 正确做法:要么调用await,要么用launch
suspend fun fetchDataSafe() = coroutineScope {
    val deferred = async {
        throw RuntimeException("网络请求失败")
    }
    try {
        val result = deferred.await()
    } catch (e: Exception) {
        // 异常在这里被捕获处理
        handleError(e)
    }
}
SupervisorJob:防止异常传播的隔离墙

默认的Job行为是:一个子协程失败,所有兄弟协程都会被取消。这在很多场景下过于粗暴。比如你同时发起三个独立的网络请求,其中一个因为超时失败,另外两个明明可以正常返回的数据也被一并取消了,用户体验极差。SupervisorJob正是为解决这个问题而生。当使用SupervisorJob作为父Job时,子协程的异常不会传播给父协程,也不会影响其他子协程。在Android开发中,viewModelScope内部就是通过SupervisorJob实现的,这就是为什么你在ViewModel中同时启动多个协程,一个崩溃不会导致其他协程被取消。但需要特别注意:SupervisorJob只阻止向上传播,不阻止向下传播。如果你在SupervisorJob下创建了一个普通Job作为子协程的作用域,那个子协程内部的异常仍然会按默认规则传播。

// 使用SupervisorJob隔离异常
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

scope.launch {
    // 这个协程的异常不会影响其他协程
    launch {
        throw IOException("文件读取失败")
    }
    launch {
        delay(1000)
        println("我仍然可以正常执行")
    }
}
coroutineScope与supervisorScope的异常语义

这两个挂起函数是构建结构化并发的基石,但它们的异常处理语义截然不同。coroutineScope是一个挂起函数,它会等待内部所有协程执行完毕。如果任何一个子协程失败,coroutineScope会立即取消所有子协程,并将异常重新抛出给调用者。这意味着你可以在调用处用try-catch包裹coroutineScope来集中处理异常。supervisorScope则基于SupervisorJob,单个子协程失败不会导致其他子协程取消,也不会自动向调用者抛出异常。你需要单独为每个子协程处理异常。选择哪个取决于业务需求:如果你需要“全有或全无”的事务性操作,用coroutineScope;如果你需要“部分成功也可接受”的并行任务,用supervisorScope。

suspend fun transactionalOperation() {
    try {
        coroutineScope {
            launch { updateDatabase() }
            launch { updateCache() }
            launch { sendAnalytics() }
        }
        // 三个操作全部成功才会执行到这里
    } catch (e: Exception) {
        // 任何一个失败都会进入这里,所有操作被回滚
        rollbackAll()
    }
}

suspend fun partialSuccessOperation() {
    supervisorScope {
        launch {
            try {
                fetchUserProfile()
            } catch (e: Exception) {
                // 单独处理用户信息获取失败
            }
        }
        launch {
            try {
                fetchRecommendations()
            } catch (e: Exception) {
                // 推荐失败不影响整体页面展示
            }
        }
    }
}
CancellationException的特殊地位

协程的取消机制依赖于CancellationException。当你调用job.cancel()时,协程内部会抛出CancellationException,但这个异常有特殊待遇:它不会传播给父协程,也不会触发SupervisorJob的异常处理逻辑。协程框架会静默地处理它,只是用来触发协程的取消流程。这导致了一个常见陷阱:如果你在catch块中捕获了所有Exception,然后做了某些清理工作,但忘记重新抛出CancellationException,协程就会变成“不可取消”状态。正确的做法是,要么只捕获非取消异常,要么在catch到CancellationException后务必重新抛出。

// 危险写法:吞掉了取消异常
launch {
    try {
        while (isActive) {
            doWork()
        }
    } catch (e: Exception) {
        // 这里把CancellationException也捕获了
        cleanupResources()
        // 没有重新抛出,协程无法被取消
    }
}

// 安全写法:只处理业务异常
launch {
    try {
        while (isActive) {
            doWork()
        }
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        cleanupResources()
        handleBusinessException(e)
    }
}
异常聚合与suppressed异常

当多个子协程同时失败时,协程框架会将第一个异常作为主异常抛出,后续的异常会通过addSuppressed方法附加到主异常上。这意味着你在捕获异常时,需要检查suppressed异常数组来获取完整的错误信息。这在调试时尤为重要,因为只看主异常可能会遗漏真正的根因。比如两个子协程分别因为网络超时和JSON解析失败而崩溃,你捕获到的可能是网络超时异常,而JSON解析异常被附加为suppressed。如果你只记录了主异常,就会丢失关键的错误上下文。

suspend fun handleMultipleFailures() {
    try {
        coroutineScope {
            launch { throw IOException("网络超时") }
            launch { throw ParseException("JSON格式错误") }
        }
    } catch (e: Exception) {
        println("主异常: ${e.message}")
        e.suppressed.forEach { suppressed ->
            println("被抑制的异常: ${suppressed.message}")
        }
        // 输出:
        // 主异常: 网络超时
        // 被抑制的异常: JSON格式错误
    }
}
CoroutineExceptionHandler的正确用法

CoroutineExceptionHandler是协程框架提供的全局异常捕获机制,但它只对根协程生效,对子协程无效。而且它只能捕获那些没有被内部try-catch处理的异常。很多开发者把它当作“兜底方案”到处使用,结果发现根本捕获不到异常。正确用法是:将CoroutineExceptionHandler安装在CoroutineScope的上下文或根协程的上下文中,用于处理那些已经传播到顶层且无法被调用者捕获的异常。它适合做最后的日志记录、崩溃上报或用户提示,但不应该作为业务异常处理的主要手段。另外,CoroutineExceptionHandler在async构建器的协程中也不会被触发,因为async的异常需要通过await来获取。

val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
    // 记录崩溃日志,上报到监控系统
    logErrorToCrashReport(throwable)
    // 展示用户友好的错误提示
    showUserFriendlyMessage("操作失败,请稍后重试")
}

val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler)

scope.launch {
    // 这个异常会被exceptionHandler捕获
    throw RuntimeException("致命错误")
}

scope.launch {
    val deferred = async {
        throw RuntimeException("这个异常不会被exceptionHandler捕获")
    }
    try {
        deferred.await()
    } catch (e: Exception) {
        // 必须通过await来捕获
    }
}
Flow中的异常处理模式

Kotlin Flow的异常处理与协程体内部的异常处理有显著区别。Flow的终端操作符collect在遇到异常时会直接抛出,上游的流操作会被取消。Flow提供了专门的catch操作符来处理上游异常,但它只能捕获上游的异常,不能捕获下游collect代码块中的异常。如果你需要在collect中处理异常,必须在collect内部使用try-catch。另一个关键点是retry操作符,它可以在流发生异常时重新订阅上游,非常适合处理瞬态错误,比如网络抖动导致的请求失败。但retry需要配合catch使用,否则重试耗尽后异常仍然会抛出。

fun fetchDataFlow(): Flow = flow {
    emit(loadFromNetwork())
}.retry(3) { cause ->
    // 只在网络异常时重试
    cause is IOException
}.catch { e ->
    // 重试耗尽后,在这里处理异常
    emit(loadFromCache()) // 降级到缓存数据
}

// collect端的异常需要单独处理
viewModelScope.launch {
    fetchDataFlow().collect { data ->
        try {
            processData(data) // 可能抛出异常
        } catch (e: Exception) {
            handleProcessingError(e)
        }
    }
}
实际项目中的异常安全架构建议

构建健壮的协程异常处理体系,需要从架构层面统一规划。第一层,在ViewModel或Presenter中使用viewModelScope,它内置SupervisorJob,保证单个协程失败不影响其他业务。第二层,在每个协程内部,对可预见的业务异常使用try-catch进行精确处理,并根据异常类型决定是重试、降级还是提示用户。第三层,在创建自定义CoroutineScope时安装CoroutineExceptionHandler作为兜底,确保任何逃逸的异常都能被记录和上报。第四层,对于网络请求等IO密集型操作,使用Flow配合retry和catch构建弹性数据管道。第五层,建立统一的错误密封类体系,将技术异常(如网络错误、序列化错误)转换为业务领域异常,让上层代码只关心业务逻辑。这样的分层设计,既能保证异常的精准处理,又能避免异常处理代码散落在各处导致维护困难。

// 统一错误密封类
sealed class AppError {
    data class NetworkError(val cause: IOException) : AppError()
    data class ServerError(val code: Int, val message: String) : AppError()
    data class LocalError(val cause: Exception) : AppError()
}

// 统一异常转换
suspend fun  safeApiCall(call: suspend () -> T): Result {
    return try {
        Result.success(call())
    } catch (e: IOException) {
        Result.failure(AppError.NetworkError(e))
    } catch (e: HttpException) {
        Result.failure(AppError.ServerError(e.code(), e.message()))
    } catch (e: Exception) {
        if (e is CancellationException) throw e
        Result.failure(AppError.LocalError(e))
    }
}

// ViewModel中的使用
class MyViewModel : ViewModel() {
    fun loadData() {
        viewModelScope.launch {
            when (val result = safeApiCall { repository.fetchData() }) {
                is Result.Success -> updateUI(result.data)
                is Result.Failure -> handleError(result.error)
            }
        }
    }
}

协程的异常安全处理不是靠记忆几个API就能掌握的,它需要对结构化并发的底层模型有清晰认知。理解Job树的传播规则、区分launch与async的异常语义、善用SupervisorJob做异常隔离、正确处理CancellationException、建立分层异常处理架构,这五点是构建稳定协程应用的基石。在实际编码中,每当你启动一个协程,都应该下意识地问自己:这个协程的异常会传播到哪里?被谁捕获?如果发生异常,其他协程是否应该被取消?把这些问题想清楚,协程异常处理就不再是玄学。