协程进阶:协程上下文与调度器、启动模式、取消与超时、异常处理
说实话,协程的基础用法学完之后,很多人会觉得「也就那样嘛」。但真正到了项目里,你会发现坑全藏在上下文、调度、取消和异常这些细节里。我当年第一次用协程写网络请求,结果用户切后台再回来,协程还在跑,直接内存泄漏——嗯,从那以后我再也不敢小看这些「进阶」内容了。
一、协程上下文与调度器
协程上下文,说白了就是一个「环境包」。它里面装着协程运行所需的各种信息,比如名字、Job、调度器等等。每个协程都有一个上下文,而且可以继承父协程的上下文。
我个人习惯把上下文理解成一个配置集合。你想想看,一个协程要跑起来,总得知道「在哪个线程上跑」「有没有父协程」「出错了怎么办」吧?这些信息全塞在上下文里。
1. 调度器(Dispatcher)
调度器是上下文里最重要的元素。它决定协程跑在哪个线程池上。Kotlin 内置了四种调度器:
| 调度器 | 说明 | 典型场景 |
|---|---|---|
| Dispatchers.Main | 主线程,Android 中用于 UI 更新 | 更新界面、弹 Toast |
| Dispatchers.IO | 专为 IO 操作优化的线程池 | 网络请求、文件读写 |
| Dispatchers.Default | CPU 密集型任务,线程数 = CPU 核心数 | 排序、解析 JSON |
| Dispatchers.Unconfined | 不指定线程,随调用者线程跑 | 极少用,调试时可能用到 |
核心原则:不要在主线程做耗时操作,也不要在 IO 线程更新 UI。用 withContext 切换调度器是最优雅的方式。
// 实际项目中的写法
viewModelScope.launch {
val data = withContext(Dispatchers.IO) {
// 网络请求
api.fetchUserInfo()
}
// 回到主线程更新 UI
textView.text = data.name
}
我在项目中遇到过一个问题:同事在 IO 线程里直接更新了 UI,结果偶尔闪退。排查了半天才发现是忘了切线程。后来我们定了个规矩——所有网络回调回来之后,必须显式切回 Main。
2. 自定义上下文元素
除了调度器,你还可以往上下文里塞自己的东西。比如给协程起个名字,方便调试:
val myContext = CoroutineName("FetchUser") + Dispatchers.IO
launch(myContext) {
// 这个协程的名字叫 FetchUser
}
小技巧:用 + 运算符可以合并多个上下文元素。顺序不重要,因为每个元素类型是唯一的。
二、协程启动模式
启动模式决定了协程什么时候开始执行。Kotlin 提供了四种模式,但说实话,日常开发中常用的就两种。
| 模式 | 行为 | 使用频率 |
|---|---|---|
| DEFAULT | 立即调度,可取消 | ⭐⭐⭐⭐⭐ |
| LAZY | 懒加载,需要手动调用 start() 或 await() | ⭐⭐⭐ |
| ATOMIC | 立即调度,不可取消(直到第一个挂起点) | ⭐ |
| UNDISPATCHED | 立即在当前线程执行,直到第一个挂起点 | ⭐⭐ |
DEFAULT 是最常用的。你写 launch { ... } 时默认就是它。协程会立即被调度,但如果你在调度前调用了 cancel(),它就不会执行了。
LAZY 我偶尔会用。比如某个操作需要用户点击后才执行,但我想提前把协程建好:
val job = GlobalScope.launch(start = CoroutineStart.LAZY) {
// 耗时操作
fetchData()
}
// 用户点击按钮后才真正执行
button.setOnClickListener {
job.start()
}
为什么会有 ATOMIC?我记得有一次排查线上 bug,发现协程在启动前就被取消了,但业务逻辑要求「只要开始就必须执行完」。ATOMIC 模式就是干这个的——它保证协程一旦开始调度,就不会被取消,直到遇到第一个挂起点。
注意:ATOMIC 模式不是「不可取消」,而是「启动阶段不可取消」。一旦协程内部调用了 delay() 或 withContext(),它就可以被取消了。
三、协程取消与超时
协程的取消机制,我觉得是 Kotlin 设计得最巧妙的地方之一。它不像 Java 的 Thread.stop() 那样暴力,而是通过协作式取消——协程自己检查是否被取消了,然后优雅地停下来。
1. 取消的基本用法
val job = launch {
repeat(1000) { i ->
println("Job: I'm working $i ...")
delay(500) // delay 是挂起函数,会自动检查取消
}
}
delay(1300)
println("main: I'm tired of waiting!")
job.cancel() // 取消协程
job.join() // 等待协程结束
println("main: Now I can quit.")
这里的关键是 delay() 内部会检查 isActive 状态。如果协程被取消了,delay() 会抛出 CancellationException,协程就结束了。
2. 计算密集型任务的取消
但如果你在协程里做纯计算,没有调用任何挂起函数,那取消就失效了。你想想看,一个死循环的协程,它根本不检查取消状态,你怎么 cancel 都没用。
解决办法是手动检查 isActive:
val job = launch(Dispatchers.Default) {
var i = 0
while (isActive) { // 每次循环检查是否被取消
// 做计算
i++
}
}
最佳实践:如果你的协程里有循环,记得用 isActive 或 ensureActive() 检查取消状态。这是协程协作式取消的核心。
3. 超时处理
超时其实就是「到时间自动取消」。用 withTimeout 或 withTimeoutOrNull 实现:
// 超时抛出 TimeoutCancellationException
try {
withTimeout(1000) {
// 耗时操作
fetchData()
}
} catch (e: TimeoutCancellationException) {
println("超时了!")
}
// 超时返回 null,不抛异常
val result = withTimeoutOrNull(1000) {
fetchData()
}
if (result == null) {
println("请求超时,请重试")
}
我个人更推荐 withTimeoutOrNull。为什么?因为 withTimeout 抛异常需要 try-catch,写起来啰嗦。而且 TimeoutCancellationException 是 CancellationException 的子类,如果你在父协程里统一 catch 了 CancellationException,可能会误吞。
四、异常处理
协程的异常处理,是新手最容易踩坑的地方。我刚开始用协程时,以为 try-catch 能包住一切,结果发现根本不是那么回事。
1. 异常的传播方式
协程的异常传播分两种:
- 自动传播:
launch启动的协程,异常会直接抛给父协程,父协程也会被取消。 - 用户手动传播:
async启动的协程,异常被封装在Deferred里,只有调用await()时才会抛出。
// launch 的异常会直接抛出
val job = GlobalScope.launch {
throw RuntimeException("出错了")
}
// 这里会崩溃,因为异常没有被处理
// async 的异常被封装
val deferred = GlobalScope.async {
throw RuntimeException("出错了")
}
// 这里不会崩溃,直到调用 await()
deferred.await() // 这里才抛出异常
2. 异常处理的方式
处理协程异常,有三种常用方式:
| 方式 | 适用场景 | 示例 |
|---|---|---|
| try-catch 包裹 | 单个协程内的异常 | launch { try { ... } catch { ... } } |
| CoroutineExceptionHandler | 全局异常处理 | launch(handler) { ... } |
| SupervisorJob | 子协程异常不影响兄弟协程 | SupervisorJob() + handler |
CoroutineExceptionHandler 就像协程的「全局 catch」:
val handler = CoroutineExceptionHandler { _, exception ->
println("捕获到异常: $exception")
}
GlobalScope.launch(handler) {
throw RuntimeException("出错了")
}
但要注意,handler 只在 launch 中有效。对于 async,异常还是要靠 await() 时的 try-catch。
避坑指南:我曾经在项目里用 async 并发请求多个接口,其中一个失败了,结果整个协程树都崩了。后来发现是因为默认的 Job 会让子协程的异常向上传播。解决方案是用 SupervisorJob——子协程的异常不会影响兄弟协程。
// 使用 SupervisorJob,一个子协程失败不影响其他
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
// 这个协程失败了
throw RuntimeException("失败")
}
scope.launch {
// 这个协程不受影响,继续执行
delay(1000)
println("我还在跑")
}
3. 异常处理的层级关系
我画了一张图,帮你理清协程异常处理的层级关系:
五、总结
协程进阶这部分,说白了就是让你从「会用」变成「用好」。我个人的经验是:
- 调度器:IO 和 Main 的切换是基本功,别搞反了。
- 启动模式:DEFAULT 够用,LAZY 按需用,其他两个了解即可。
- 取消与超时:记得在计算密集型任务里检查
isActive,用withTimeoutOrNull更安全。 - 异常处理:
SupervisorJob是你的好朋友,尤其是做并发请求时。
嗯,这些内容看起来多,但你在项目里踩过一两次坑之后,自然就记住了。别怕犯错,每个坑都是成长的阶梯。
公众号:蓝海资料掘金营,微信deep3321