课程概述:Flow数据流与蓝牙泄漏问题简介

大家好。我是你们的讲师,一个在Android蓝牙坑里摸爬滚打多年的开发老兵。今天,咱们来聊一个看似平常,却能让你线上崩溃的隐形杀手——因Flow数据流未取消导致的蓝牙资源泄漏。

为什么会想到开这个课?因为我个人习惯在项目里大量使用协程和Flow来管理异步数据。几年前的一次经历让我记忆犹新:某个蓝牙扫描功能,上线后用户反馈手机发烫、耗电飞快。查了半天,发现就是一个.launchIn没有配合cancel(),导致扫描流一直运行,不断唤醒蓝牙芯片。

说白了,Flow很强大,但用不好就是一把双刃剑。

一个容易被忽视的真相

很多开发者觉得,只要用了协程,就自动生命周期安全了。这种想法很危险。其实,Flow本身只是一个冷数据流,它本身不携带生命周期信息。真正控制它生死的,是协程作用域(CoroutineScope)。

你想想看,你在Activity里用viewModelScope.launch收集一个Flow,看起来很安全。但如果这个Flow在某个中间操作开启了蓝牙Socket,而你在退出页面时没有取消Flow,那么:

  • 蓝牙连接会被强引用持有
  • 协程在挂起点恢复后继续执行
  • 蓝牙回调和系统服务处于非正常存活状态

这就是泄漏的根源链条。

⚠️ 核心警告: Flow的取消机制不同于传统线程。当你调用cancel(),协程只会在挂起点(如delay()、emit())检查取消状态。如果Flow处于长时间运行的蓝牙回调循环中,不主动设置ensureActive()检查,取消指令会被直接忽略。

蓝牙操作的特殊性

蓝牙通信和普通网络请求不一样。普通Retrofit请求发完就结束了,但蓝牙操作往往是长驻型的:

操作类型 典型持续时间 泄漏后果
蓝牙扫描 无界(需手动停止) CPU唤醒锁、蓝牙芯片持续工作、电量耗尽
GATT连接 会话级(数分钟到数小时) 系统蓝牙服务句柄泄漏、设备无法被其他应用连接
通知接收 一直持续 内存泄漏、回调堆积、OOM

嗯,这里要注意:大多数蓝牙泄漏不是瞬间爆发的,而是随着时间推移指数级恶化。你退出页面100次,蓝牙线程池里就堆积了100个挂起的协程。

Flow 泄漏的常见场景

  1. 使用 liveData 桥接:liveData { ... }.asFlow(),虽然LiveData自带生命周期感知,但内部的Flow如果启动了一个callbackFlow,这个callbackFlow的生命周期与LiveData的Observer绑定,而不是与View的生命周期绑定。我就在项目中遇到过一次,用户反复进入退出页面,蓝牙通知流积累了几百个。
  2. 滥用 .catch 和 .retry:有些蓝牙操作会抛出异常(比如设备断连)。在Flow里用.retryWhen无限重试,配合.launchIn在全局Scope里执行,流变成永不结束的“僵尸流”。
  3. 忘记在 callbackFlow 中处理 awaitClose:这是最经典的。callbackFlow必须提供awaitClose来注册资源清理回调。如果忘记写,或者写了但没正确取消上游回调,蓝牙Gatt回调会永远持有Flow实例。
// 这是一个典型的危险写法
fun BluetoothGatt.observeCharacteristicChanges(): Flow<ByteArray> = callbackFlow {
    val callback = object : BluetoothGattCallback() {
        override fun onCharacteristicChanged(gatt: BluetoothGatt?, characteristic: BluetoothGattCharacteristic?) {
            // 这里没有trySend,而是直接send,如果消费者不消费,协程挂起
            trySend(characteristic?.value ?: byteArrayOf())
        }
    }
    // 注册回调
    setCharacteristicNotification(gattService.getCharacteristic(UUID), true)
    // 关键:awaitClose中必须注销回调,否则泄漏!
    awaitClose {
        // 我习惯在这里做清理
        close()  // 如果这里你只close了gatt,但没有unregister,泄漏!
    }
}
💡 小经验: 在写callbackFlow时,我一般会在awaitClose里强制打印日志,确保每个流的创建和销毁都成对出现。这样一旦泄漏,日志里会看到明显的“不对称”。

一个真实的泄漏案例

去年我参与重构一个蓝牙打印项目。原始代码是用RxJava写的,改成Flow后出现了一个奇怪现象:手机温度持续上升,30分钟消耗50%电量。

我们排查后发现,问题出在这么一个逻辑里:

  • Service启动了一个蓝牙扫描Flow,使用flowOn(Dispatchers.IO)和buffer()优化性能。
  • 扫描结果是StateFlow,被Activity收集用于UI更新。
  • 当Activity销毁时,ViewModel的viewModelScope被取消,但Service里启动流的那个协程作用域是一个自定义的全局Scope(为了保持扫描后台运行)。
  • 扫描流源源不断产生数据,即使没有消费者(Activity已销毁),buffer()队列疯狂增长,把蓝牙芯片压到满负荷运行。

为什么会这样?因为buffer()在消费者速度慢或没有消费者时,会堆积缓存。蓝牙硬件每秒产生几十个回调,buffer无限增长,内存和CPU都受不了。

本章知识体系

下面这张图展示了Flow泄漏蓝牙资源的完整链条。我个人建议你先把它看明白,后面章节我们会逐个攻破每个环节。

Flow数据流未取消 → 蓝牙泄漏 核心逻辑 Flow 数据流 未调用 cancel() / 协程未取消 callbackFlow 回调未注销 buffer() 队列无限堆积 蓝牙 GATT 连接泄漏 内存 / CPU 资源耗尽 应用无响应 / OOM / 蓝牙不可用 💡 典型泄漏路径: Flow 未取消 → 协程挂起/排队 → 蓝牙回调强引用 → 系统服务句柄泄漏 → 设备连接数超限

本文小结

蓝牙泄漏不像空指针那样会立刻Crash,它是一种慢性的资源枯竭。作为一个开发者,你需要意识到:

  • Flow本身无生命周期,它依赖所在的协程作用域
  • 蓝牙操作(扫描、连接、通知)是无限期的,必须主动终止
  • CallbackFlow是最常见的泄漏源头,awaitClose是唯一的清理机会

我曾经因为一个.launchIn(GlobalScope)没处理好,导致线上用户蓝牙功能集体瘫痪。所以,别偷懒,每个Flow的创建都要问自己:它什么时候停止?