背景:蓝牙通信中常见的内存泄漏与资源泄漏
我记得刚接手公司第一个蓝牙项目时,产品经理天天盯着我测连接稳定性。结果倒好,连接倒是没断,手机先卡死了。一查日志——好家伙,内存泄漏了十几个 Gatt 对象,一个都没释放。
其实蓝牙泄漏是个老生常谈的问题。说白了,就是「开了没关」的经典翻版。你想想看,蓝牙通信涉及多个层次——系统级、应用级、协程级——每一层都可能埋着泄漏的雷。
泄漏的两大类型
| 类型 | 根因 | 典型表现 |
|---|---|---|
| 内存泄漏 | 对象引用未释放,GC 无法回收 | App 内存持续上升,最终 OOM |
| 资源泄漏 | 系统资源(GATT、Socket、协程)未关闭 | 蓝牙扫描停止不了,设备无法再连接 |
这两种泄漏在蓝牙场景下往往如影随形。内存泄漏会拖垮 App,资源泄漏则直接破坏蓝牙通信链路。我自己就踩过一个坑——一个 Flow 数据流挂在 Activity 上没取消,结果协程一直持有 BluetoothGatt 的引用,Activity 销毁后 Gatt 对象还活着,下一轮连接直接报错「GATT already connected」。
蓝牙通信中的经典泄漏场景
来,我列几个实战中最常见的场景。这些都是我在不同项目里亲手修过的 bug。
- BluetoothGatt 未关闭 —— 每次调用
connectGatt()都会创建一个 Gatt 对象。如果你忘记调用close(),系统内部会保留一个蓝牙进程的引用,导致同一个设备再也连不上。 - 广播接收器未注销 —— 注册了
BluetoothDevice.ACTION_FOUND或BluetoothAdapter.ACTION_STATE_CHANGED,但 Activity 或 Service 销毁时没调unregisterReceiver()。你会收到一个指向已销毁组件的空引用。 - 协程作用域未取消 —— 用
viewModelScope或lifecycleScope还好,但如果你自己创建了CoroutineScope并传入蓝牙回调,Activity 销毁后协程还在跑,持有外部引用。 - Flow 数据流未取消收集 —— 这个是我们课程的核心。用
callbackFlow包装蓝牙通知时,没有在awaitClose里释放资源,导致协程和 Gatt 互相引用,泄漏。 - 观察者/回调列表未清空 —— 自定义了一个
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 连接列表,看是否有已断开的僵尸连接 |
作为开发者,我们能做什么?
提前预防比事后排查效率高一百倍。我在团队里推行了三条铁律:
- 所有蓝牙相关协程必须绑定生命周期作用域。要么用
lifecycleScope,要么用viewModelScope,绝不允许自己建GlobalScope或在匿名对象里启动协程。 - 每个 callbackFlow 必须写
awaitClose并做彻底清理。包括关闭 Gatt、注销回调、清空 Buffer。哪怕你认为「取消的时候系统会自动释放」——不,它不会。 - 建立统一的蓝牙连接管理类。单例管理所有 Gatt 连接,用
WeakReference持有 Activity/Context,并提供releaseAll()方法在 Application 的onTrimMemory里调用。