Kotlin 协程的异常处理 笔记

在 Kotlin 协程中,异常处理是一个重要且需要仔细理解的话题。协程的异常处理遵循 结构化并发 的原则,异常会沿着协程层次结构向上传播,如果未被妥善处理,可能会导致父协程乃至整个作用域的取消。下面我将从基本规则开始,逐步深入介绍协程的异常处理机制,并给出 Android 开发中的最佳实践。


🧱 结构化并发与异常传播

在结构化并发中,每个协程都有一个父协程(除了根协程)。子协程抛出未捕获的异常时,默认行为是:

  1. 异常传播给父协程:父协程收到子协程的异常后,会取消自己以及所有其他子协程。
  2. 异常继续向上传播:如果父协程也没有处理该异常,它会继续向上传播,直到根协程或顶层协程。
  3. 根协程未处理异常:如果到达顶层协程且未处理,则整个协程树取消,异常会被抛出到线程的未捕获异常处理器(可能导致应用崩溃)。

这种设计的目的是保持并发操作的原子性:如果一个子任务失败,整个任务树都应失败,确保不会处于不一致的状态。


🚀 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 常用于记录未处理异常,避免应用崩溃。

注意handlerasync 无效,因为 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) }
    }
}

⚠️ 常见陷阱

  1. 异常被吞没async 启动协程后不调用 await(),导致异常无声消失。
  2. 在 launch 中抛出异常但未处理:导致父协程取消,可能影响其他任务。
  3. 在 supervisorScope 中忘记处理异常:虽然不影响兄弟协程,但异常仍会向上传播,可能导致上层作用域取消。
  4. 错误地捕获 CancellationException:可能导致协程无法及时响应取消,浪费资源。
  5. 使用 SupervisorJob 作为父 Job,但子 Job 内又启动协程时:如果子 Job 是普通 Job,则孙协程的异常仍会传播到子 Job,导致子 Job 失败(但不会影响 SupervisorJob 的其他子协程)。需要明确异常传播的边界。

📚 总结

场景 推荐处理方式
协程内部的可预期异常 try-catch 包围代码块
不希望一个子协程失败影响其他子协程 使用 supervisorScopeSupervisorJob
处理顶层协程未捕获异常 设置 CoroutineExceptionHandler
需要等待结果并处理异常 使用 async 并安全地 await(),或使用 runCatching 辅助
取消异常 不要捕获或处理,除非特殊需要
相关推荐
锥栗2 小时前
【其他】基于Trae的大模型智能应用开发
android·java·数据库
恋猫de小郭2 小时前
Flutter 2026 Roadmap 发布,未来计划是什么?
android·前端·flutter
zh_xuan6 小时前
kotlin Flow的用法2
android·开发语言·kotlin·协程·flow·被压
zh_xuan6 小时前
kotlin 测试协程嵌套
android·kotlin·协程
Doro再努力7 小时前
【Linux操作系统15】深入理解Linux进程概念:从理论到实践
android·linux·运维
城东米粉儿7 小时前
Android Lifecycle、LifecycleOwner、ViewLifecycleOwner、LifecycleScope、ViewModelScop
android
m0_528749007 小时前
sql基础查询
android·数据库·sql
安卓机器8 小时前
安卓玩机自做小工具------用于ROM修改 解打包boot.img修改小工具
android
独自破碎E8 小时前
BISHI66 子数列求积
android·java·开发语言