协程里最贵的 bug 不是崩溃,是"静默地不干活"。上一节把 Job 的取消传播讲清了,本节顺着异常这条线往下走:异常从协程抛出后经过哪些环节、到哪儿被接住、CancellationException 为什么要特殊对待、以及 CoroutineExceptionHandler 究竟在什么位置生效。这题在面试里的分层非常明显——只答"用 try/catch"是入门,能把传播路径和取消语义讲完整,才算过了这一关。
先把结论放在前面:协程异常处理有三条主线。①传播规则:launch 的异常会取消父 Job 并向上抛,async 的异常被 Deferred 持有、需 await() 才会重新抛出;②取消语义:CancellationException 是协程的正常终止信号,不该被 catch 吞掉,吞掉会导致 Job 一直停在 Cancelling 状态(协程"卡住不结束");③兜底位置:CoroutineExceptionHandler 只对根协程(没有父 Job 的)生效,给子协程装它不起作用。
机制拆解
先看异常是怎么移动的。协程体里 throw 一个异常 → 异常被包装成 JobCancellationException 之类的形式交给当前 Job → Job 调 cancel(cause) → 沿子节点向下广播取消信号、同时通知父 Job → 父 Job 按"自己有没有父"决定:没有父就交给 CoroutineExceptionHandler,有父就继续往上抛,直到根协程由 handler 兜底。
这里有个容易误解的点:CoroutineScope(Dispatchers.IO + CoroutineExceptionHandler()) 看起来像"给整个作用域装了 handler",但它只对直接以该 scope 为根的协程生效。如果在 scope 里 launch 出子协程,子协程的异常走的是父 Job 的传播链,而父 Job 是 scope 自己的 Job,最终仍会到 handler——但如果是 launch 内部自己 try/catch 住了,handler 根本看不到。所以 handler 是"最后一道网",不是"每条协程的错误日志"。
第二条线是取消语义。CancellationException 继承自 IllegalStateException,如果写成 catch (e: Exception),它会被一起捕获。后果是:协程体跑完了但 Job 状态没走到 Cancelled,父 Job 等它"完成"等不到,withTimeout 也可能超时异常。规范写法是先判后吞:
try {
doWork()
} catch (e: CancellationException) {
throw e // 取消信号原样抛出,保持协作式取消语义
} catch (e: Exception) {
report(e) // 真正的业务异常才上报
}
工程落地:真实项目里怎么用
场景一:详情页用 viewModelScope.launch { repository.load() } 拉数据,网络异常直接抛出去,App 崩了。修法不是"到处 try/catch",而是分层:Repository 层把网络异常翻译成 Result<T> 密封类型(用 runCatching 兜住 IOException),ViewModel 层用 when 处理 Success/Failure 两种分支去驱动 UI。好处是错误处理变成类型的一部分,编译器帮你检查有没有漏。
场景二:uploadFlow() 里 flow { emit(...) }.collect { upload(it) },某次 upload 抛异常。默认行为是异常取消整个收集器,界面上"正在上传"的进度条一直停住,没有任何提示——因为 LaunchedEffect 的异常不会自动显示。这里需要在 catch 里把状态置为失败态并提示,同时保留 CancellationException 的抛出。
场景三:supervisorScope { launch { a() }; launch { b() } } 里 a() 抛异常且没兜底。子协程异常会交给根协程的 handler,如果没装 handler 就走默认 Thread.UncaughtExceptionHandler 崩溃。所以用 supervisorScope 的地方,块内每条协程都要有兜底——这一点上一节已经强调过,本节从异常视角再确认一次。
这些坑的正确绕法
在 catch 里把 CancellationException 一并吞掉,取消流程被打断出现幽灵协程。 表现是协程不再响应取消、Job 长期停在 Cancelling、viewModelScope 的任务越积越多。修法:catch 块第一行先 if (e is CancellationException) throw e。
其次是用 runCatching 一次性兜住所有 Throwable,把 CancellationException 一起包进 Result.failure,导致取消传播断链。修法:runCatching 的结果要在使用处再判一次类型,或在封装层做 onFailure { if (it is CancellationException) throw it }。
还有一个更隐蔽的坑:给子协程单独装 CoroutineExceptionHandler 却以为能收到日志,实际它对有父 Job 的协程无效,异常已被父链路接管。修法:handler 只装在根协程/scope 上,子协程用 try/catch 自行处理。
代码里见真章
看一段能直接跑的代码,把上面的机制落到具体写法上:
// 1) 分层错误模型:Repository 翻译异常,ViewModel 决定 UI
sealed interface DataResult<out T> {
data class Ok<T>(val value: T) : DataResult<T>
data class Err(val code: Int, val msg: String) : DataResult<Nothing>
}
class DetailRepo(private val api: Api) {
// 非 CancellationException 才归一化为 Err;取消信号原样上抛
suspend fun load(id: String): DataResult<Detail> = try {
DataResult.Ok(api.detail(id))
} catch (e: CancellationException) {
throw e
} catch (e: IOException) {
DataResult.Err(code = e.message?.hashCode() ?: -1, msg = "网络不可用")
}
}
// 2) 根协程装 handler(只这里有效)
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO +
CoroutineExceptionHandler {
_, e -> log("uncaught", e) })
// 3) 子协程自己兜底:流式上传逐条处理,单条失败不影响整条流
fun CoroutineScope.upload(items: List<Item>) = launch {
items.forEach {
item ->
try {
uploadOne(item)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_state.value = Progress(item.id, failed = true, error = e.message)
}
}
}
关键行解读:DataResult.Err(val code: Int, ...) 里的 Nothing 让 Err 可以和任意 Ok<T> 统一成 DataResult<T>,这是 sealed 层次的关键;CoroutineExceptionHandler 装在 scope 上只兜根协程;forEach 里的 try/catch 保证单条失败不终止整批。
这题在面试里怎么问、怎么答
"launch 和 async 的异常处理差别?" 答:launch 异常会取消父并向上传播(未捕获时最终由根协程 handler 或崩溃处理);async 异常被 Deferred 持有,只有 await() 时才抛出,不 await 就等于吞掉。
"SupervisorJob 的异常怎么上报?" 答:子协程之间不传播,每个子协程要么自己 catch,要么由根协程的 CoroutineExceptionHandler 处理;不 catch 就会走到默认的未捕获异常处理(Android 上崩溃)。
"为什么 CancellationException 要特殊对待?" 答:它代表协作式取消的正常终止,不是错误。吞掉它会让 Job 状态机停在中间态,破坏父对子完成的等待,也让超时/取消语义失效。
"怎么让协程异常可观测?" 答:根协程装 CoroutineExceptionHandler + 第三方 crash SDK 捕获;业务层用 Result/sealed 显式建模;再配合结构化并发日志打点(自定义 CoroutineContext 元素或拦截 Thread.setDefaultUncaughtExceptionHandler)。
给正在准备面试的你
1. 定一条项目级错误规范:Repository 层返回 DataResult<T>(或 Kotlin Result),不允许把原始异常直接抛到 UI 层。这一条比任何 try/catch 数量都重要。 2. 在 code review 里把"catch 块首行是否处理 CancellationException"列为固定检查项。可以写脚本/规则把裸 catch (e: Exception) 标出来人工确认。 3. 用 kotlinx.coroutines 提供的 SupervisorJob + handler 做全局兜底时,明确它的作用范围只覆盖根协程,别指望它兜住所有子协程。 4. 自动化预防:为并发块写"单点失败"单测,断言其他分支仍完成;为上传/同步这类批处理写"取消后能否及时退出"的单测,这类 bug 在手工测试里几乎发现不了。
把这条链画成一条竖着的箭头,从上到下依次写:throw → 当前 Job 的 cancel(cause) → 向下广播取消(子协程退出) → 通知父 Job → 有父则继续上抛 / 无父则交给 CoroutineExceptionHandler。在"通知父 Job"旁边分一条支线画 async:被 Deferred 拦下,等 await() 才抛。最后在旁边贴一张红框写"取消语义:CancellationException 原样上抛"。
再补一个高频追问的完整答案:CoroutineExceptionHandler 为什么对子协程无效——因为异常在子协程里抛出后,先被其 Job 捕获并交给父 Job 链路,handler 只在异常抵达根协程、且无人处理时作为最后兜底被调用。给子协程装 handler 属于"错误的位置",正确位置是根 scope。
复习时别孤立刷题:Job 与 SupervisorJob——上一节讲取消如何在 Job 树里传播,本节讲失败与异常如何在这棵树上流动,两者合起来才是完整的异常模型。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:Job-与-SupervisorJob:异常传播的分水岭
下一篇预告:Flow-入门:冷流、热流与背压
有任何问题欢迎在评论区留言交流。