作用域泄漏原理:协程生命周期与资源释放的关系

协程作用域,说白了就是协程的“容器”。它决定了协程从哪儿来,到哪儿去。我做了这么多年 Android 开发,跟内存泄漏斗智斗勇,发现很多问题就出在作用域没选对。

你想想看,BLE 扫描是个典型的长时间后台任务。如果协程跑在一个没有生命周期感知的作用域里,Activity 都销毁了,它还在那里吭哧吭哧地扫描、持有 Context 引用,这不泄漏才怪。

1. 协程作用域的本质

结构化并发强调一个原则:协程的生命周期必须与它的宿主绑定。一个 CoroutineScope 管理着一组协程,当作用域被取消时,内部所有子协程都会被递归地取消。

我遇到过一位同事,他习惯用 GlobalScope 启动所有协程,美其名曰“省事”。结果呢?内存泄漏报表里的问题一大半都是他贡献的。嗯,这里要注意:GlobalScope 的生命周期是整个应用进程,它不会自动取消,所以你需要手动管理。

2. 常见作用域对比

选对作用域,就成功了一半。下面这张表我经常拿来培训新人:

作用域类型生命周期自动取消时机泄漏风险
GlobalScope应用进程永不自动取消极高
lifecycleScope(Activity/Fragment)与宿主 Lifecycle 绑定onDestroy低(正确使用时)
viewModelScope与 ViewModel 绑定ViewModel onCleared低
自定义 CoroutineScope + Job由开发者控制需手动调用 cancel取决于管理方式

3. 泄漏是怎么发生的?

举个例子:Activity 里用 GlobalScope 启动了一个 BLE 扫描协程,协程内部持有 Activity 的 WeakReference?有些人会用这个技巧,但协程还在跑,引用链可能被其他对象持有,导致无法回收。更常见的是持有 View 或 Context 的强引用。

核心路径:

  • 协程未绑定生命周期 → 宿主销毁后协程继续运行
  • 协程内部持有宿主引用 → GC 无法回收宿主
  • 宿主占用的资源无法释放 → 内存泄漏 + BLE Gatt 连接泄漏

下面这张图,我花了点心思画了对比逻辑,你看一眼就能明白:

协程作用域选择 → 泄漏还是安全? 启动协程 GlobalScope lifecycleScope 宿主销毁,协程未取消 持有 Context 引用 → 泄漏 onDestroy 自动取消 资源安全释放 ✅ ⚠️ 关键区别 GlobalScope 协程生命周期 = 应用进程 → 宿主销毁后仍存活 → 泄漏 lifecycleScope 协程生命周期 = 宿主 Lifecycle → 自动取消 → 安全

4. 案例复盘:BLE 扫描协程泄漏

我记得之前有个项目,蓝牙扫描在后台一直触发回调,Activity 退出了还在打印日志。排查后发现是 GlobalScope 启动的协程没有取消。

错误代码(典型泄漏写法):

// 在 Activity 中直接使用 GlobalScope
fun startBleScan() {
    GlobalScope.launch(Dispatchers.IO) {
        // 模拟 BLE 扫描
        while (isActive) {
            scanForDevices()
            delay(10_000)
        }
    }
}
// Activity onDestroy 时协程仍然在运行
// 且协程内部若持有 Activity 的引用 -> 泄漏

正确代码(使用 lifecycleScope):

class BleScanActivity : ComponentActivity() {
    
    fun startBleScan() {
        // 使用 lifecycleScope,生命周期自动绑定
        lifecycleScope.launch(Dispatchers.IO) {
            while (isActive) {
                scanForDevices()
                delay(10_000)
            }
        }
        // onDestroy 时 lifecycleScope 自动取消,协程终止
    }
}

看到区别了吗?一个 lifecycleScope 就解决了问题。它内部注册了 Lifecycle 观察者,在 onDestroy 时自动调用 cancel()。

5. 避坑指南与最佳实践

  • 不要用 GlobalScope 启动跟 UI 绑定的任务。我自己吃过这个亏,后来定下规矩:凡是跟 Activity/Fragment 相关的协程,一律用 lifecycleScope。
  • 在 ViewModel 里用 viewModelScope。做过 BLE 扫描的话,通常扫描状态需要存在 ViewModel 里,用 viewModelScope 启动扫描协程,用户退出页面后 ViewModel 被清除,协程自然取消。
  • 自定义作用域要手动取消。如果你自己创建了 CoroutineScope + SupervisorJob,记得在生命周期结束时调用 scope.cancel(),或者用 Job() 并传入 Dispatchers.Main.immediate 等,然后通过 LifecycleObserver 触发取消。

技巧: 如果我需要在一个协程里执行超时控制,会配合 withTimeout 或 withTimeoutOrNull。即使作用域没有取消,超时后也能自动停止后续逻辑,作为双重保险。

警告: 不要认为用了 lifecycleScope 就万事大吉。如果你在协程里通过匿名内部类或 lambda 捕获了 Activity 的强引用(比如直接调用了 Activity 的方法),即使协程取消了,捕获的引用也可能导致泄漏。我以前就踩过这个坑,所以建议在协程里使用安全的引用方式,或者传递 Activity 的 ApplicationContext。

最后总结一句话:作用域就是协程的“身份证”,绑定好生命周期,资源才能按规矩释放。 选对作用域,你的 BLE 代码才能活得更长久。