Scope 到底在管什么?
很多人刚开始用协程时,会有一个错觉——launch 和 async 随便调,反正能跑就行。
但真正一到线上,问题就来了:
- 请求发出去,页面销毁了,回调还在执行
- OkHttp 的 Response 泄漏,底层 Socket 迟迟不释放
- 甚至出现野指针、空指针,崩溃率飙升
这时候你才意识到——没有 Scope,协程就是脱缰的野马。
我个人习惯,接手任何项目第一件事,就是先看它的协程是挂在哪儿的。如果到处是 GlobalScope 或者 runBlocking,那基本可以断定,泄漏只是时间问题。
Scope 的三个核心职责
我们把这层概念拆开来看,其实就三件事:
- 为协程提供「上下文环境」——它携带了调度器、Job、异常处理器等关键信息。
- 管理子协程的结构化关系——父 Scope 取消,所有子协程一起取消;子协程异常,传播给父 Scope。
- 决定协程的「存活时长」——Scope 结束,协程自动清理,不需要你手动调 cancel。
说白了,Scope 就像 Android 里的 View 树——父 View 消失,子 View 跟着销毁。你想想看,如果你在 Fragment 销毁后还去更新 UI,不崩才怪。协程也一样。
viewModelScope 或 lifecycleScope,而不是自己 new Scope。框架帮你管理生命周期,比你手动维护可靠得多。
结构化并发——到底「结构」在哪?
我第一次看「结构化并发」这个词,也觉得有点虚。后来在实际项目中调试一次网络请求泄漏,才真正搞明白。
什么叫结构化?就是:
- 父协程等待所有子协程完成,才进入完成状态
- 子协程抛异常,父协程可以选择取消或继续
- 整个协程树有一个清晰的「层级关系」
没有结构化并发,你写的就是「裸协程」——launch 出去就管不了了。
举个例子,我在一个项目里遇到过这样的代码:
fun loadData() {
// 错误示范:GlobalScope 启动,没人管死活
GlobalScope.launch {
val response = OkHttpClient().newCall(request).await()
// 更新 UI
textView.text = response.body?.string()
}
}
嗯,问题很明显:textView 在页面销毁后还在被赋值,不崩溃才怪。而且 OkHttp 的 Response 没有被 close,导致连接池里堆满了 TIME_WAIT。
用结构化并发改造后:
fun loadData() {
// 绑定到 ViewModel 生命周期
viewModelScope.launch {
val response = OkHttpClient().newCall(request).await()
// 页面销毁后,这段代码根本不会执行
_uiState.value = response.body?.string() ?: ""
}
}
你发现没?改动很小,但效果完全不同。结构化并发的本质,就是把协程的「生死权」交还给调用者。
自动取消机制——你真的需要手动 cancel 吗?
我经常被问到:「如果页面销毁了,我是不是要在 onDestroy 里手动 cancel 协程?」
我的回答是:如果你用对了 Scope,根本不需要。
自动取消的底层逻辑其实很简单:
- Scope 被取消时,它的
coroutineContext[Job]会变成Cancelled状态 - 所有挂起函数(如
delay、await)在执行前都会检查 Job 状态 - 如果已经是取消状态,直接抛出
CancellationException
但这里有一个容易踩的坑——我称之为「沉默的 CancellationException」。
viewModelScope.launch 里用了 try-catch 捕获了 CancellationException 并静默吃掉,结果协程永远取消不了,请求还在后台跑。
正确的做法是:
- 捕获异常后,如果是
CancellationException,必须重新抛出 - 或者直接用
try-finally,不做异常过滤
viewModelScope.launch {
try {
val result = someSuspendFun()
// 处理结果
} catch (e: CancellationException) {
// ❌ 千万不能吞掉
throw e
} finally {
// ✅ 清理资源(OkHttp Response 的 close 放在这里)
response.close()
}
}
自动取消不是说「你什么都不用做」,而是说「框架帮你管了 90% 的事,剩下的 10% 需要你配合好」。
Scope 的三种常见形态
在实际项目中,我一般会把这三种 Scope 场景区分开:
| Scope 类型 | 生命周期绑定 | 典型使用场景 | 注意事项 |
|---|---|---|---|
viewModelScope |
ViewModel 销毁时 | 网络请求、数据库操作 | 不要做 UI 更新操作,除非通过 LiveData/StateFlow |
lifecycleScope |
Activity/Fragment 生命周期 | UI 相关协程、动画、定时任务 | 记得用 repeatOnLifecycle 控制生命周期 |
CoroutineScope(Dispatchers.IO) |
手动管理 | 后台长任务、独立于 UI 的任务 | 务必在 onDestroy 中调用 cancel() |
我个人会尽量避免第三种。如果非要用,我会加一个 finalize 日志,确保被取消了。
Scope 的整体视图
下面这张图,帮你把这三个核心概念串起来——它们不是孤立的,而是互相支撑的。
小结一下
这一章我们聊了三件事:
- Scope 的作用:给协程一个「家」,确定它的上下文和边界
- 结构化并发:父子协程同生共死,不再野马脱缰
- 自动取消机制:Scope 消失,协程自动收尸
这些概念听起来绕,但在代码里其实就一行的事儿——选对 Scope,比你写一百行 cancel 都管用。