Scope 到底在管什么?

很多人刚开始用协程时,会有一个错觉——launch 和 async 随便调,反正能跑就行。

但真正一到线上,问题就来了:

  • 请求发出去,页面销毁了,回调还在执行
  • OkHttp 的 Response 泄漏,底层 Socket 迟迟不释放
  • 甚至出现野指针、空指针,崩溃率飙升

这时候你才意识到——没有 Scope,协程就是脱缰的野马。

我个人习惯,接手任何项目第一件事,就是先看它的协程是挂在哪儿的。如果到处是 GlobalScope 或者 runBlocking,那基本可以断定,泄​​漏只是时间问题。

一句话总结:CoroutineScope 定义了协程的「边界」和「生命周期」。没有边界,就没有控制。

Scope 的三个核心职责

我们把这层概念拆开来看,其实就三件事:

  1. 为协程提供「上下文环境」——它携带了调度器、Job、异常处理器等关键信息。
  2. 管理子协程的结构化关系——父 Scope 取消,所有子协程一起取消;子协程异常,传播给父 Scope。
  3. 决定协程的「存活时长」——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() ?: ""
    }
}

你发现没?改动很小,但效果完全不同。结构化并发的本质,就是把协程的「生死权」交还给调用者。

核心原则:协程的生命周期不应该超过创建它的那个 Scope 的生命周期。谁创建,谁管理,谁销毁。

自动取消机制——你真的需要手动 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 的整体视图

下面这张图,帮你把这三个核心概念串起来——它们不是孤立的,而是互相支撑的。

CoroutineScope 核心逻辑关系 CoroutineScope Scope 的作用 提供上下文·定义边界 结构化并发 父子协程·层级管理 自动取消机制 Job取消·传播取消 调度器 Dispatcher 异常处理器 父取消 → 子取消 → 子结束 CancellationException 资源清理 viewModelScope lifecycleScope launch / async coroutineScope { } 子协程自动跟随 异常传播链 try-catch 注意 重新抛出 close() Response 最终效果 Scope 不消失 → 协程正常执行 | Scope 消失 → 协程自动取消 → 资源自动释放

小结一下

这一章我们聊了三件事:

  • Scope 的作用:给协程一个「家」,确定它的上下文和边界
  • 结构化并发:父子协程同生共死,不再野马脱缰
  • 自动取消机制:Scope 消失,协程自动收尸

这些概念听起来绕,但在代码里其实就一行的事儿——选对 Scope,比你写一百行 cancel 都管用。