作用域泄漏原理:协程生命周期与资源释放的关系
协程作用域,说白了就是协程的“容器”。它决定了协程从哪儿来,到哪儿去。我做了这么多年 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 连接泄漏
下面这张图,我花了点心思画了对比逻辑,你看一眼就能明白:
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 代码才能活得更长久。