4. CoroutineScope 在 Android 中的生命周期管理
聊到协程泄漏,本质上就是 CoroutineScope 没有被正确关闭。我呢,见过不少开发者把协程当普通线程用,启个 GlobalScope.launch 就跑了。殊不知在 Android 里,scope 必须绑定到组件的生命周期上,否则泄漏只是时间问题。
说白了,每个协程都活在某个 scope 里。scope 没了,子协程就得被取消。蓝牙场景尤其敏感:一次蓝牙搜索可能拉起五六层嵌套协程,如果 Activity 退出了 scope 还活着,那些协程死撑着不放,GattCallback 引用着 Activity 的 context,泄漏就来了。
4.1 Android 组件自带的 Scope 家族
Android 官方很贴心地给了我们几个现成的 scope 选项。我列个表,你一看就明白各自该用在哪儿:
| Scope 类型 | 生命周期绑定点 | 典型使用场景 | 注意点 |
|---|---|---|---|
viewModelScope |
ViewModel 的 onCleared() | 蓝牙状态管理、数据持久化 | ViewModel 清除后所有协程自动取消 |
lifecycleScope |
Activity/Fragment 的 ON_DESTROY | 蓝牙扫描、UI 更新 | 注意区分 destroy 和 stop |
lifecycle.repeatOnLifecycle |
指定生命周期状态 | 蓝牙状态监听(仅活跃时收集) | 非常适合 Flow 的冷流收集 |
我项目里踩过最坑的一次:用 lifecycleScope 在 Fragment 里发蓝牙连接请求,结果 Fragment 被 addToBackStack 后 onDestroy 没立刻触发(transaction 延迟),协程继续跑,等到连接回调过来时 Fragment 已经 detached 了,直接空指针。
教训: 不要盲目相信默认 scope,要确认你的组件销毁时机。
4.2 自定义 CoroutineScope:你来管它的命
有时候内置 scope 不够灵活。比如你的蓝牙服务不是绑定在某个 Activity 上,而是跨组件的单例。那你就得自己创建 scope。
class BluetoothManager(private val context: Context) {
// 我习惯用 SupervisorJob,这样子协程失败不会干掉兄弟
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
fun startScan() {
scope.launch {
// 蓝牙扫描逻辑
}
}
fun release() {
scope.cancel() // 必须手动取消!
}
}
嗯,这里要注意:scope.cancel() 之后这个 scope 就废了,不能再 launch 新协程。你要是复用了同一个 scope 对象,记得重建。
GlobalScope!我之前接手过一个项目,蓝牙扫描用 GlobalScope,App 切到后台 30 分钟后,那个协程还在扫描,电量哗哗地掉。GlobalScope 的线程池是 Application 级别的,永远不会自动取消。
4.3 生命周期与 scope 的关联方式
你想想看,怎么让自定义 scope 跟随组件生命周期?最简单的方式是在 onDestroy 里调用 cancel()。但这样写起来很啰嗦,而且容易遗漏。
我推荐另一种做法——用 Lifecycle.addObserver 自动管理:
class MyLifecycleAwareScope : DefaultLifecycleObserver {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
override fun onDestroy(owner: LifecycleOwner) {
scope.cancel()
}
fun launch(block: suspend CoroutineScope.() -> Unit): Job {
return scope.launch(block = block)
}
}
然后你在 Activity 里注册:
class BleActivity : AppCompatActivity() {
private val bleScope = MyLifecycleAwareScope()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycle.addObserver(bleScope) // 自动绑定销毁
}
}
这样就不用手动调 release 了。不过要注意顺序:如果 LifecycleObserver 的 onDestroy 在 Fragment 的 onDestroy 之前执行,倒是没问题;但如果在之后,会有一个微小的窗口期协程可能还活着。我建议在 onStop 里就取消掉那些不必要在后台跑的协程(比如蓝牙扫描),只保留必须完成的连接任务到 onDestroy。
4.4 核心知识图谱
下面这张图总结了本章的关键链路,我画完后自己参考了很多次:
4.5 典型场景流程图
做一个蓝牙连接功能时,我通常按照下面这个流程来管理 scope 的生命周期:
- 建立连接:在 Activity/Fragment 的
onCreate或onResume中,用lifecycleScope启动一个协程去执行connectGatt。 - 状态监听:用
repeatOnLifecycle(Lifecycle.State.STARTED)收集蓝牙连接状态的 Flow,保证只在界面可见时才更新 UI。 - 数据读写:如果是耗时操作(比如读取大特征值),用
viewModelScope发起,确保 ViewModel 存续时任务能完成;ViewModel 清除时自动取消。 - 断开连接:在
onStop里取消与 UI 相关的协程(比如扫描结果展示),但保留连接保持心跳协程到onDestroy。 - 彻底清理:
onDestroy里调用scope.cancel()或直接依赖lifecycleScope的内置取消机制。
4.6 常见的坑与避坑指南
避坑指南一:不要混合使用不同的 scope。我有次在一个 Fragment 里同时用了 lifecycleScope 和自定义 scope,忘记在 onDestroy 里取消自定义 scope,结果 Fragment 重建了四次,自定义 scope 里的协程还在跑旧连接,导致蓝牙堆栈乱掉。
避坑指南二:SupervisorJob 不是万能药。它确实可以让子协程独立失败,但如果你的蓝牙逻辑里某个子协程抛了异常,失败传播到父 Job 后,父 Job 还是会被取消。我曾经在扫描协程里忘记 try-catch,结果一个特征值读取失败导致整个扫描 scope 都挂了。后来我用 CoroutineExceptionHandler 捕获异常,再决定是否重启 scope。
// 推荐做法:给 scope 加上异常处理器
val scope = CoroutineScope(
SupervisorJob() +
Dispatchers.IO +
CoroutineExceptionHandler { _, exception ->
Log.e("BLE", "协程异常:${exception.message}")
// 在这里决定是否要重启 scope
}
)
避坑指南三:注意 lifecycleScope 的启动模式。lifecycleScope.launch 默认在 Dispatchers.Main 上启动,如果你要执行蓝牙底层读写,一定要切到 Dispatchers.IO。我见过同事直接在 UI 线程上跑 BluetoothGatt.writeCharacteristic,导致 ANR。正确做法是:
lifecycleScope.launch {
withContext(Dispatchers.IO) {
gatt?.writeCharacteristic(characteristic, value)
}
// 回到主线程更新 UI
updateStatus("写入成功")
}
嗯,说到底,生命周期管理没那么神秘。核心就是一句话:协程的 scope 必须比它依赖的组件活得短。你把 scope 想成一个“协程容器”,组件销毁时把容器扔掉,里面的协程自然就没了。蓝牙设备不会因为你泄漏了协程就一直保持连接——通常底层会超时断开,但你的 app 里挂着的回调引用却无法释放,Context 和 View 就被钉在了内存里。
下一回你写蓝牙功能时,不妨先问自己三个问题:
- 这个协程应该绑定到谁的生命周期?
- 如果用户突然离开页面,这个协程还该不该继续?
- 如果它失败了,会不会影响其他协程?
想清楚这三点,蓝牙泄漏就很难找上你了。