GATT 回调机制:Android/iOS 平台下 GATT 回调注册与触发流程

聊到协程泄漏,很多问题其实都出在 GATT 回调这块。我个人习惯把回调理解成蓝牙协议栈扔给应用层的一根“信号线”——你注册好了,它就按时响;你忘了注销,它就永远挂在那,协程也就跟着泄漏了。

今天我们就掰开揉碎看看,Android 和 iOS 上 GATT 回调到底是怎么注册、怎么触发的。你搞清楚了这个,后面排查泄漏就有一半底气了。

Android 端的 GATT 回调注册

Android 用 BluetoothGattCallback 这个抽象类。你写个匿名内部类或者单独的文件,重写那几个关键方法:onConnectionStateChange、onServicesDiscovered、onCharacteristicChanged 等等。

BluetoothGattCallback callback = new BluetoothGattCallback() {
    @Override
    public void onConnectionStateChange(BluetoothGatt gatt, int status, int newState) {
        // 连接状态变化
    }
    @Override
    public void onServicesDiscovered(BluetoothGatt gatt, int status) {
        // 服务发现完成
    }
    @Override
    public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) {
        // 通知/指示到达
    }
};

然后通过 BluetoothDevice.connectGatt() 把这个 callback 传进去。注意,这个调用会创建一个 BluetoothGatt 实例,同时把 callback 绑定到该实例的生命周期上。我记得有一次排查泄漏,发现同事把 callback 写成了匿名内部类,但外部 Activity 已经被销毁了,callback 还持有 Activity 的引用——这就是经典的隐式引用泄漏。

Android 回调触发流程

当蓝牙芯片收到远端设备的响应或通知时,底层会通过 Binder 把数据送给 BluetoothGatt 的 Binder 代理。然后这个代理会回调你注册的 callback 方法。注意:这些回调默认运行在 Binder 线程池,不是主线程。

你想想看,这意味着什么?如果你在回调里直接 launch 一个协程,而且没指定调度器,协程就会跑到 Binder 线程上。Binder 线程是有限的,一旦泄漏,整个蓝牙通信都会卡住。我遇到过几次,log 里全是 "BluetoothGatt: onCharacteristicChanged called but callback is null" 之类的警告——其实是因为回调线程被堵塞,系统超时回收了 callback 引用。

避坑指南:我曾经在一个项目里发现 onCharacteristicChanged 被频繁触发时,协程 Job 没有正确管理。因为每次回调都 launch 一个新的协程去做数据处理,但前一个协程还没完成就被覆盖了,结果协程泄漏 + 数据乱序。后来强制给每个 characteristic 绑定一个独立的 Job,用 Mutex 保证串行处理。

iOS 端的 GATT 回调注册

iOS 用的是 CBCentralManager 和 CBPeripheral 两个核心类。回调通过 delegate 模式实现。注册时你要设置 centralManager.delegate 和 peripheral.delegate,然后实现对应的 delegate 方法。

// 设置中心管理器
centralManager = CBCentralManager(delegate: self, queue: nil)
// 实现回调
extension ViewController: CBCentralManagerDelegate {
    func centralManagerDidUpdateState(_ central: CBCentralManager) { }
    func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) { }
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        peripheral.delegate = self
        peripheral.discoverServices(nil)
    }
}
extension ViewController: CBPeripheralDelegate {
    func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) { }
    func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) { }
    func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) { }
}

这里有个关键区别:iOS 的 delegate 是 弱引用。如果你把 peripheral.delegate 设为一个临时对象,那个临时对象被释放后,delegate 自动变成 nil,不会发生回调。但如果你把 delegate 设为一个持久对象(比如 ViewController),而 ViewController 又被别的对象强引用,那就没问题。

不过,实际开发中很多泄漏出现在 CoreBluetooth 的队列管理上。你传入 queue: nil 时系统会用串行主队列,但如果你手动传了一个自定义队列,并且在这个队列里启动协程——嗯,协程和队列的生命周期如果不同步,很容易泄漏。

iOS 回调触发流程

iOS 的 CoreBluetooth 框架内部使用 XPC 与 bluetoothd 守护进程通信。当蓝牙芯片有数据到达时,bluetoothd 通过 XPC 把消息传给你的 App 进程,然后 CoreBluetooth 会调用你的 delegate 方法。默认情况下,delegate 方法在你的主队列上执行,除非你指定了自定义队列。

为什么会这样?因为苹果希望 UI 更新方便,所以默认放到主线程。但如果你在主线程里做耗时操作,或者启动协程但不切线程,就容易阻塞主线程,导致蓝牙回调延迟甚至丢失。

我的经验:在 iOS 端,我习惯在 didUpdateValueFor 回调里直接使用 async/await 包装协程,并把协程的调度器设为一个自定义串行队列,确保回调处理不会阻塞主线程,同时避免并发竞争。这样能有效减少泄漏概率。

Android vs iOS 回调对比

维度 Android iOS
回调注册方式 BluetoothGattCallback 对象 delegate 弱引用
回调线程 Binder 线程池(默认非主线程) 主队列(默认主线程)
强引用隐患 匿名内部类持有外部引用 delegate 对象被提前释放导致 nil
协程泄漏常见点 在回调里 launch 但未取消 Job 自定义队列和协程作用域不同步

你看,两边的机制虽然不同,但核心问题一样:回调的触发时机和应用层协程的生命周期必须匹配。否则,回调执行完了协程还在跑,或者协程取消了回调还在注册,都会造成泄漏。

核心原则:在 GATT 回调中启动的任何异步操作,都必须绑定到可取消的协程作用域上。Android 推荐使用 lifecycleScope 或 viewModelScope;iOS 推荐使用 Task 并传入一个 CancellationToken 或 Hold 住 Task 引用。

为了让你更直观地理解整个回调注册与触发的数据流,我画了一张图。它展示了从上层代码注册回调,到底层蓝牙芯片响应,再到协程泄漏可能发生的几个关键点。

GATT 回调注册与触发流程(Android / iOS 通用视角) 上层应用注册回调 onCharChanged / didUpdateValue 系统蓝牙协议栈 Android: Binder / iOS: XPC → bluetoothd 蓝牙芯片通信 收发 ATT 包 / 通知 / 指示 回调触发 onCharacteristicChanged / delegate 方法 开发者协程处理 launch / async / Task ⚠️ 协程泄漏常见原因 回调中启动协程但未绑定作用域 → 回调取消时协程继续运行 → 内存/资源无法释放 Android 特殊点 匿名内部类隐式持有 Activity iOS 特殊点 delegate 弱引用 → 对象释放回调失效

图里自下而上看,从蓝牙芯片到协议栈,再到回调触发,最后落到我们的协程代码。泄漏点通常出现在第五步——回调触发了,协程启动了,但是回调对应的对象(比如 Android 的 BluetoothGatt 或 iOS 的 CBPeripheral)已经被释放或 reconnect 了,导致协程失去上下文,一直挂在那。

一个小技巧:如果你在 Android 上使用 Kotlin 协程,可以在 callback 内部通过 rememberCoroutineScope() 或 viewModelScope 启动协程,并且每次连接断开时取消所有子协程。iOS 的话,用 withTaskCancellationHandler 配合 Task.checkCancellation() 可以快速感知回调取消。