协程基础:CoroutineScope、Job 与 CoroutineContext 详解

要说清楚 BLE 泄漏的根因,得先把协程的三个底层概念搞透。我个人习惯把这组概念称为「协程三件套」——Scope 管生命周期,Job 管任务状态,Context 管运行环境。三者配合不好,泄漏几乎是必然的。

先别急着背定义。我们结合实际场景来理解,这样讲 BLE 连接泄漏时才不会晕。

CoroutineScope:协程的「容器」

Scope 说白了就是一个范围。它定义了协程的起止边界。当 Scope 结束时,它内部所有协程都得跟着结束——哪怕你没手动 cancel。

核心规律: 协程不能脱离 Scope 单独存在。每个协程都从属于某个 Scope。

我在公司做过一个 BLE 扫描页面,当时图省事直接用了 GlobalScope,结果用户切到后台后协程还在跑,甚至收到了已经关闭的 Activity 引用。嗯,这就是典型的 Scope 生命周期不匹配。

正确的做法是绑定 Android 组件:

  • viewModelScope —— 跟随 ViewModel 生命周期
  • lifecycleScope —— 跟随 Activity / Fragment 生命周期
  • 自定义 scope —— 绑定到 BLE 连接会话
我的建议: 凡是需要手动控制取消时机的,都用 CoroutineScope(SupervisorJob() + Dispatchers.IO) 自己创建,明确指定生命周期边界。

Job:协程的「身份证」与「遥控器」

每个协程都对应一个 Job 对象。它记录了协程的状态(New、Active、Completing、Completed、Cancelling、Cancelled),同时也支持父子层级。

我记得有一次排查 BLE 写入超时,发现子协程抛异常后,父协程也跟着挂了——因为默认的 Job 是 取消传播 的:父 Job 取消会级联取消所有子 Job,子 Job 异常也会导致父 Job 取消。

// 常见写法
val parentJob = Job()
val scope = CoroutineScope(parentJob + Dispatchers.IO)

scope.launch {
    // BLE 连接协程
}

scope.launch {
    // BLE 数据解析协程
    // 如果这里抛异常,parentJob 会取消,导致另一个子协程也被取消
}

这个问题怎么解?用 SupervisorJob。它允许子 Job 失败不影响兄弟 Job:

val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
scope.launch {
    // BLE 连接
}
scope.launch {
    // BLE 解析 —— 就算失败,也不会影响到连接协程
}
曾经踩的坑: 我用 Job() 管理 BLE 扫描协程,结果扫描线程抛了个超时异常,整个 Scope 下的所有协程瞬间被取消,包括正在进行的连接操作。从那以后,只要是多协程协作的场景,我都会用 SupervisorJob。

CoroutineContext:协程的「运行配置」

Context 就像一个配置文件,确定了协程在哪个线程跑、用什么 Job、异常怎么处理、拦截器怎么走。它的四个基本元素:

元素类型作用
Job控制任务状态管理协程生命周期与父子关系
CoroutineDispatcher指定线程池决定协程在哪个线程执行
CoroutineExceptionHandler异常处理捕获未处理的异常,防止崩溃
CoroutineName调试标识给协程起名,方便日志定位

你想想看,平时我们写 Dispatchers.IO + SupervisorJob(),其实就是显式构建了一个 Context。而 launch 或 async 内部会自动继承外层的 Context。

关键: Context 遵循「就近覆盖」原则。父 Scope 的 Context 会被子协程继承,但如果你在子协程里显式指定了新的 Dispatcher,就会覆盖父级的。

三者关系:一张图说清楚

Scope 提供生命周期, Job 提供任务状态, Context 提供运行配置。它们的关系可以用下面的 SVG 图表示:

CoroutineScope、Job 与 CoroutineContext 关系 CoroutineScope 包含/管理 CoroutineContext 包含 包含 包含 Job CoroutineDispatcher CoroutineExceptionHandler 产生子 Job Child Job 注意 Scope 结束 → 所有子 Job 自动取消

这张图想表达的核心:Scope 是顶层容器,它持有 Context,而 Context 内部组合了 Job、Dispatcher、ExceptionHandler 等。Job 又可以形成父子层级。一旦 Scope 被取消(比如 Activity 销毁),整个树都会被清理。

实际排查中的「三件套」问题

在 BLE 泄漏案例里,我见过最多的模式就是:用 Activity 级别的 Scope 去管理 BLE 连接协程。Activity 退到后台但没销毁,Scope 还活着,协程就一直跑着,引用了 Activity 的 View 导致泄漏。

正确的做法应该是:

  1. 给每个 BLE 设备连接创建一个独立的 Scope,绑定到连接会话。
  2. 连接断开时主动调用 scope.cancel()。
  3. 使用 SupervisorJob 隔离不同 BLE 操作(扫描、连接、读特征值)。
曾经我吃过的大亏: 在一个 BLE OTA 升级功能里,我用 Job() 管理多个步骤。升级中某个步骤抛了异常,导致整个升级流程中断,而且 Scope 没有取消干净,后续重连时重复创建了多个协程。最后用 SupervisorJob 加 ensureActive() 检查才搞定。

一张表总结三者的职责

概念类比我的理解
CoroutineScope项目组负责启动和终止协程,生命周期必须明确
Job任务单跟踪任务状态,父子级联取消逻辑很重要
CoroutineContext工具箱线程调度、异常策略、调试名都在这里

嗯,理解这三个概念后再看 BLE 泄漏代码,基本一眼就能看出问题。Scope 绑错生命周期、Job 用了默认的取消传播、Context 没配异常处理——这些都在 BLE 案例里出现过。