先说结论:它们是两个完全独立的协程上下文元素——一个决定协程"在哪跑",一个决定协程"炸了怎么办"。
| ContinuationInterceptor | CoroutineExceptionHandler | |
|---|---|---|
| 职责 | 决定协程在哪个线程执行 | 处理未捕获异常 |
| 触发时机 | 每次挂起恢复 | 协程因异常终止时 |
| 典型实现 | Dispatchers.IO / Main / Default | 自定义异常日志、上报 |
| 相互关系 | 互不继承、互不覆盖、互不依赖 |
一、类型体系:是"兄弟",不是"父子"
两者都是 CoroutineContext.Element 的直接子接口,平级、无继承关系:
interface CoroutineExceptionHandler : CoroutineContext.Element {
fun handleException(context: CoroutineContext, exception: Throwable)
}
interface ContinuationInterceptor : CoroutineContext.Element {
fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T>
}
关键点:
- 各自持有独立的 Key,在上下文中是两个独立的键值对
- 一个对象不可能同时是两者(除非手动实现两个接口)
- 常用的
CoroutineDispatcher只是ContinuationInterceptor的抽象子类,负责线程调度;异常处理器完全不参与调度逻辑
二、职责分工:调度中枢 vs 崩溃安全网
ContinuationInterceptor:协程的"调度中枢"
- 协程每次从挂起状态恢复时,
interceptContinuation()都会被调用,对续体(Continuation)进行包装 - 决定协程接下来在哪个线程执行
Dispatchers.Main/IO/Default的底层实现就是它,是协程异步调度的"门卫"
CoroutineExceptionHandler:协程的"崩溃安全网"
- 只在协程遇到未捕获异常时才触发
- 不改变线程执行路径,也不参与挂起恢复流程
- 唯一作用:协程因异常终止时提供一个统一的回调入口,避免异常直接裸奔导致崩溃
一句话总结:拦截器决定协程"在哪跑",异常处理器决定协程"炸了怎么办"。
三、执行时序:拦截器贯穿全程,异常处理器最后出场
- 协程启动,拦截器介入,决定协程在指定调度器上运行
- 执行业务逻辑,每次挂起恢复都由拦截器分发线程
- 抛出未捕获异常 → 状态机终止,异常沿父协程链向上传播
- 框架从当前上下文查找
CoroutineExceptionHandler,回调handleException() - Handler 执行日志打印、埋点上报等兜底逻辑
所以别指望异常处理器去改变协程的执行线程——它出场时,协程已经快结束了。
四、上下文组合:不同的 Key,互不覆盖
val context = myExceptionHandler + Dispatchers.IO + Job() + CoroutineName("MyTask")
- Handler 和 Dispatcher 各占一个 Key,共存于同一上下文,与
+号位置无关 - 只有同类型元素才会互相覆盖:两个 Handler 或两个 Dispatcher,右侧覆盖左侧
五、三个最常见的坑
坑 1:以为 Handler 能兜住所有异常
它只捕获"未被捕获且向上传播到父协程"的异常。子协程内部被 try-catch 掉的异常,Handler 根本不会触发。
坑 2:把 Handler 设置在子协程上
普通父协程的子协程抛出异常后,会直接取消父协程,子协程自己设置的 Handler 往往来不及响应。正确做法:在根协程或 SupervisorJob 的直接子协程上设置 Handler。
坑 3:在 Handler 回调里直接操作 UI
Handler 执行时所在线程由当时的拦截器决定,它本身不提供线程切换能力。要弹 Toast,先 withContext(Dispatchers.Main)。
六、总结
CoroutineExceptionHandler 和 ContinuationInterceptor 是协程上下文体系中两个正交、独立、各司其职的核心元素:没有继承关系,没有依赖关系,执行上也不重叠,却共同定义了协程最基础的"运行规则"和"安全边界"。
理解了它们的关系,你才算真正从"会用协程"走到"懂协程"。