背景:蓝牙通信中常见的内存泄漏与资源泄漏

我记得刚接手公司第一个蓝牙项目时,产品经理天天盯着我测连接稳定性。结果倒好,连接倒是没断,手机先卡死了。一查日志——好家伙,内存泄漏了十几个 Gatt 对象,一个都没释放。

其实蓝牙泄漏是个老生常谈的问题。说白了,就是「开了没关」的经典翻版。你想想看,蓝牙通信涉及多个层次——系统级、应用级、协程级——每一层都可能埋着泄漏的雷。

泄漏的两大类型

类型根因典型表现
内存泄漏对象引用未释放,GC 无法回收App 内存持续上升,最终 OOM
资源泄漏系统资源(GATT、Socket、协程)未关闭蓝牙扫描停止不了,设备无法再连接

这两种泄漏在蓝牙场景下往往如影随形。内存泄漏会拖垮 App,资源泄漏则直接破坏蓝牙通信链路。我自己就踩过一个坑——一个 Flow 数据流挂在 Activity 上没取消,结果协程一直持有 BluetoothGatt 的引用,Activity 销毁后 Gatt 对象还活着,下一轮连接直接报错「GATT already connected」。

注意:大多数蓝牙泄漏的核心原因只有一个——生命周期管理不到位。协程、Callback、数据流都绑错了时机。

蓝牙通信中的经典泄漏场景

来,我列几个实战中最常见的场景。这些都是我在不同项目里亲手修过的 bug。

  1. BluetoothGatt 未关闭 —— 每次调用 connectGatt() 都会创建一个 Gatt 对象。如果你忘记调用 close(),系统内部会保留一个蓝牙进程的引用,导致同一个设备再也连不上。
  2. 广播接收器未注销 —— 注册了 BluetoothDevice.ACTION_FOUND 或 BluetoothAdapter.ACTION_STATE_CHANGED,但 Activity 或 Service 销毁时没调 unregisterReceiver()。你会收到一个指向已销毁组件的空引用。
  3. 协程作用域未取消 —— 用 viewModelScope 或 lifecycleScope 还好,但如果你自己创建了 CoroutineScope 并传入蓝牙回调,Activity 销毁后协程还在跑,持有外部引用。
  4. Flow 数据流未取消收集 —— 这个是我们课程的核心。用 callbackFlow 包装蓝牙通知时,没有在 awaitClose 里释放资源,导致协程和 Gatt 互相引用,泄漏。
  5. 观察者/回调列表未清空 —— 自定义了一个 BluetoothCallback 列表,在连接时把 Activity 的匿名回调加进去,断开时却忘了 remove。Activity 被强引用持有。
我的习惯:在每个蓝牙操作的出口点(断开、销毁、超时)都统一做一个 release() 方法,把所有清理逻辑塞进去。少记一次,就多一个泄漏。

为什么协程和 Flow 会加剧泄漏?

嗯,这里要讲重点了。传统的 Callback 写法虽然烦,但至少是显式注册和注销。协程和 Flow 来的太「自动」了,容易让人忘掉取消。

举个具体例子。你写了一个这样的 Flow 来监听蓝牙通知:

fun observeNotifications(device: BluetoothDevice): Flow<ByteArray> = callbackFlow {
    val gatt = device.connectGatt(context, false, object : BluetoothGattCallback() {
        override fun onCharacteristicChanged(
            gatt: BluetoothGatt, characteristic: BluetoothCharacteristic
        ) {
            trySend(characteristic.value)
        }
    })
    gatt.setCharacteristicNotification(someChar, true)
    // 注意:这里没有 awaitClose
}

这段代码看起来没什么问题,对吧?但实际上 gatt 被匿名内部类 BluetoothGattCallback 强引用持有,而 callbackFlow 建立的协程又持有了 gatt。如果 Flow 的收集在某个生命周期结束后被取消,但协程没走到 cancel() 分支,那 gatt 永远不会 close()。

我之前在项目里就吃过这个亏。调试了三天,最后发现是某个同事在 Fragment 的 onViewCreated 里启动了一个 lifecycleScope.launch 收集这个 Flow,但 Fragment 销毁时 lifecycleScope 确实是取消了,可 callbackFlow 内部的 awaitClose 块根本没写。结果协程虽然取消了,但 gatt 资源没人去 close,系统的蓝牙堆栈一直认为这个 Gatt 还在使用中。

核心结论:协程取消 ≠ 资源释放。你必须手动在 awaitClose 里关闭 Gatt、注销回调、释放文件句柄等系统资源。否则 Flow 一旦创建,就是个泄漏炸弹。

泄漏的连锁反应

别看只是一个小 Gatt 没关闭,它的影响是链式的。

  • 第一步: Gatt 对象泄漏,系统 Native 层保持一个蓝牙连接会话。
  • 第二步: 下一次连接同一设备时,底层返回 «GATT_CONNECTION_CONGESTED» 或 «GATT_ALREADY_OPENED»。
  • 第三步: 用户发现连不上,开始反复尝试连接。你的 connectGatt() 每次创建新 Gatt,但旧的不释放,内存暴增。
  • 第四步: OOM 或 蓝牙系统服务崩溃,导致整个手机蓝牙模块无法使用,必须重启蓝牙或重启手机。

这种场景我在 IoT 设备配网功能里遇到过两次。最严重的一次,用户反馈「配对后手机蓝牙彻底死掉」,最后定位是配网页面退出了但 Flow 没取消,累积了 20 多个 Gatt 对象。

泄漏检测常用方法

实战中我一般用三招来排查蓝牙泄漏:

手段怎么做能发现什么
Android Profiler抓取内存快照,查看 BluetoothGatt / GattService 实例数量是否有多余的 Gatt 对象未被 GC 回收
LeakCanary集成后测试蓝牙连接/断开/Activity 销毁明确哪个对象被哪个线程持有引用
dumpsys bluetooth_manager命令:adb shell dumpsys bluetooth_manager当前系统蓝牙 GATT 连接列表,看是否有已断开的僵尸连接
我自己的流程:先跑一遍 LeakCanary,确认栈回溯。然后用 dumpsys 看系统层是不是也滞留着连接。最后用 Profiler 抓个 heap dump 验证。三个工具交叉定位,一般半小时就能找到泄漏根因。
蓝牙泄漏类型与连锁反应 内存泄漏 资源泄漏 BluetoothGatt未关闭 Broadcast未注销 协程未取消 Flow未关闭 回调列表未清 连锁反应流程 1. Gatt对象泄漏 → 2. Native层保留连接会话 → 3. 新连接失败 4. 用户反复重连 → 5. 多个Zoombie Gatt实例 → 6. OOM或蓝牙服务崩溃 最终结果:App卡死 / 蓝牙模块不可用 / 需要重启手机 检测手段:Android Profiler · LeakCanary · dumpsys bluetooth_manager

作为开发者,我们能做什么?

提前预防比事后排查效率高一百倍。我在团队里推行了三条铁律:

  • 所有蓝牙相关协程必须绑定生命周期作用域。要么用 lifecycleScope,要么用 viewModelScope,绝不允许自己建 GlobalScope 或在匿名对象里启动协程。
  • 每个 callbackFlow 必须写 awaitClose 并做彻底清理。包括关闭 Gatt、注销回调、清空 Buffer。哪怕你认为「取消的时候系统会自动释放」——不,它不会。
  • 建立统一的蓝牙连接管理类。单例管理所有 Gatt 连接,用 WeakReference 持有 Activity/Context,并提供 releaseAll() 方法在 Application 的 onTrimMemory 里调用。
一句话总结:蓝牙泄漏不是玄学,是「生命周期管理不到位」的必然结果。协程和 Flow 进来了以后,我们不仅不能偷懒,反而应该比传统回调更谨慎——因为「自动取消」的幻觉会让你忘掉资源清理。