第092篇 协程异常处理:从 try-catch 到 CoroutineExceptionHandler

简介: 协程最危险的Bug不是崩溃,而是“静默不干活”——根源常是误吞`CancellationException`。本文详解异常传播路径:`launch`向上抛、`async`延迟至`await`;强调`CancellationException`必须原样重抛,否则协程卡在Cancelling态;明确`CoroutineExceptionHandler`仅对根协程生效。附分层错误建模与避坑实践。

协程里最贵的 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-入门:冷流、热流与背压

有任何问题欢迎在评论区留言交流。

相关文章
|
21天前
|
存储 弹性计算 人工智能
2026年阿里云优惠活动汇总:新用户大促特惠,阿里云活动怎么参与?
综合来看,参与2026年阿里云新用户大促,核心流程就是核验账号资格、完成实名认证、找到活动入口、领取权益、按需选购资源。新用户福利的核心优势就是极低的入门成本,适合首次上云的用户快速搭建业务。但所有首购特惠都带有严格的限购规则,一次下单即消耗新客资格,所以采购前要做好规划。在下单之后,借助CLI工具查看实例状态、核对账单,定期巡检闲置资源,及时释放不再使用的按量实例与存储资源,避免产生额外扣费。只要熟悉活动规则,合理利用新人福利,就能用较低成本完成业务上云,把资源支出控制在预算范围内。
117 1
|
24天前
|
数据采集 API 开发者
港股数据源选型:踩一个回测就假(富途/IBKR/TickDB)
本文揭示港股数据接入四大隐形陷阱:代码格式错致样本缺失、成交量单位不统影响流动性判断、时间断点未对齐产生幻影K线、前复权引入未来信息。实测基于Python 3.11/Ubutnu 22.04,强调数据质量验证是工程问题而非投资分析。(239字)
|
24天前
|
传感器 前端开发 关系型数据库
为什么要加OVP芯片:后级充电IC、MCU和电池管理电路耐压有限
OVP芯片即过压保护芯片,串联于电源输入端,实时监测电压;超阈值时纳秒级关断MOS,切断高压通路,专防快充误插、电源异常等持续过压风险,保护后级IC与MCU。非降压器件,不可被TVS/ESD替代。
|
传感器 缓存 安全
第150篇 Lifecycle 组件:生命周期感知的标准化
Lifecycle本质是“状态机+观察者”,含`LifecycleOwner`(状态持有者)、`Lifecycle`(状态管理与事件分发)和`LifecycleObserver`(响应者)三大核心。事件**同步、主线程分发**,严格遵循状态迁移时序(如`ON_PAUSE`后状态才变`STARTED`),`DESTROYED`不可逆。推荐用`DefaultLifecycleObserver`与`repeatOnLifecycle`,避坑`lifecycleScope`误用与泄漏。
89 0
|
消息中间件 安全 Java
第145篇 Handler 内存泄漏:为什么匿名内部类最危险
Handler内存泄漏本质是“引用链未断”:GC Root → MessageQueue(永不销毁)→ Message → Handler → Activity。关键在Message.target强引用Handler,而内部类Handler又隐式持有Activity。清理核心是移除队列中消息,而非仅置空Handler。
99 0
|
JSON API 数据库
第131篇 BroadcastReceiver:静态注册与动态注册的存亡
本文深入剖析 BroadcastReceiver 的三层认知:API 层(能收广播)、机制层(知限制规则)、工程层(懂替代方案)。详解静态/动态注册、`goAsync()` 与 ANR 避坑、隐式广播限制(Android 8+)、有序广播淘汰原因及 `WorkManager` 等现代替代方案,助你真正掌握广播本质。
83 0
|
24天前
|
调度 Android开发 容器
第126篇 Fragment 事务与回退栈:add、replace、show、hide
Fragment事务异步队列执行,非调用即生效;回退栈存操作记录而非实例。`show/hide` 快但常驻内存、不可回退;`replace`+栈可回退但重建视图。选型关键:是否需回退?是否敏感内存?——是判断题,非知识点。
81 1
|
存储 安全 API
第130篇 WorkManager:后台任务的最终归宿
WorkManager 专解“保证完成+尊重系统调度”问题,非实时执行方案。核心能力:持久化任务队列、约束条件(如网络/电量/存储)、可组合工作链。选型关键:可延迟但必须成功——用它;需即时响应、用户可见——用前台服务或推送。
95 0
|
24天前
|
安全 Java 定位技术
第117篇 Kotlin 阶段面试通关地图:高频题怎么连成网
这是Kotlin面试的结构化复习地图,非知识点罗列,而是将60+篇内容按「六大知识块×三层深度」(会用/懂机制/能取舍)分层归因,直击面试区分度核心。助你快速定位保底分与拉分点,掌握高频题答题骨架、交叉跳转逻辑及三大避坑指南,真正把知识织成可检索、可推演、可落地的活网络。
81 1
|
24天前
|
前端开发 IDE 编译器
第112篇 Kotlin 2.x 演进:K2 编译器带来了什么
本节聚焦Kotlin 2.0编译器与工具链演进:核心是**K2前端重写(基于FIR)**,带来**编译提速、类型推断更准、插件生态更健壮**;同步推进KMP稳定化及上下文接收者、`guard`等语言实验特性。升级关键在**插件兼容性检查**与**隐式类型推断回归验证**。
102 0

热门文章

最新文章