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 核心知识图谱

下面这张图总结了本章的关键链路,我画完后自己参考了很多次:

CoroutineScope 生命周期管理知识结构 Android 组件 Activity Fragment ViewModel Scope 类型 lifecycleScope viewModelScope 自定义Scope 生命周期事件 onStart onStop onDestroy 蓝牙协程泄漏风险 关键原则:Scope 必须在组件销毁前 cancel,否则蓝牙回调持有的引用会导致内存泄漏

4.5 典型场景流程图

做一个蓝牙连接功能时,我通常按照下面这个流程来管理 scope 的生命周期:

  1. 建立连接:在 Activity/Fragment 的 onCreate 或 onResume 中,用 lifecycleScope 启动一个协程去执行 connectGatt。
  2. 状态监听:用 repeatOnLifecycle(Lifecycle.State.STARTED) 收集蓝牙连接状态的 Flow,保证只在界面可见时才更新 UI。
  3. 数据读写:如果是耗时操作(比如读取大特征值),用 viewModelScope 发起,确保 ViewModel 存续时任务能完成;ViewModel 清除时自动取消。
  4. 断开连接:在 onStop 里取消与 UI 相关的协程(比如扫描结果展示),但保留连接保持心跳协程到 onDestroy。
  5. 彻底清理:onDestroy 里调用 scope.cancel() 或直接依赖 lifecycleScope 的内置取消机制。

💡 技巧: 我个人的习惯是把蓝牙连接的状态放在 ViewModel 里,用 StateFlow 暴露。这样即使 Activity 重建(比如旋转屏幕),ViewModel 活着,协程就不会断,连接还能复用。

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 就被钉在了内存里。

下一回你写蓝牙功能时,不妨先问自己三个问题:

  • 这个协程应该绑定到谁的生命周期?
  • 如果用户突然离开页面,这个协程还该不该继续?
  • 如果它失败了,会不会影响其他协程?

想清楚这三点,蓝牙泄漏就很难找上你了。