四、BLE回调机制:BluetoothGattCallback的生命周期与回调处理

说实话,刚做BLE开发那阵子,我也是被回调搞晕过。你注册了一个回调,但啥时候触发?顺序对不对?哪个线程里跑?这些问题足足让我排查了两天。今天我把自己的经验整理出来,希望能帮你少走弯路。

核心认知:BluetoothGattCallback就是Android系统和你的BLE业务之间的"信使"。系统收到数据、连接状态变了、发现服务了,都会通过回调来告诉你。说白了,它是异步编程模式下,我们和设备"对话"的唯一通道。

生命周期回调总览

BluetoothGattCallback里一共定义了13个回调方法,但日常开发常用的就6个。我习惯把这6个方法按"流程阶段"归类:

回调方法触发时机我关注的点
onConnectionStateChange连接/断开连接时一定要检查status参数,status==0才是正常断开
onServicesDiscovered服务发现完成后建议在这里转发到主线程处理UI
onCharacteristicRead读取特征值返回时注意分片读取的情况,MTU协商后有时也会触发这个
onCharacteristicWrite写入特征值响应时写入方式要匹配:WRITE_TYPE_NO_RESPONSE时不会触发
onCharacteristicChanged收到通知/指示时这是最常用的数据接收入口
onDescriptorWrite配置通知描述符完成后很多人忘了等这个回调就发数据,会出问题

典型处理流程:我踩过的坑与最佳实践

我在项目中遇到过一个问题:连续调用多个GATT操作,结果回调顺序全乱了。后来我悟出一个道理——BLE回调不是天然的"排队机制"。

来看一个基础但不严谨的写法:

// ❌ 不要这样写
gatt.discoverServices()
gatt.readCharacteristic(characteristic) // 可能services还没发现完

为什么会这样?因为discoverServices是异步的,你调用它之后系统立刻返回true,但onServicesDiscovered还没触发呢。这时候去读特征值,大概率读到的是不完整的数据。

我现在的处理办法是这样的:

核心回调处理代码模板

private val gattCallback = object : BluetoothGattCallback() {
    override fun onConnectionStateChange(gatt: BluetoothGatt, status: Int, newState: Int) {
        when (newState) {
            BluetoothProfile.STATE_CONNECTED -> {
                // 连接成功,开始服务发现
                gatt.discoverServices()
            }
            BluetoothProfile.STATE_DISCONNECTED -> {
                // 连接断开,清理资源
                closeGatt(gatt)
            }
        }
    }

    override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
        if (status == BluetoothGatt.GATT_SUCCESS) {
            // 服务发现完成,开始配置通知
            val service = gatt.getService(UUID_SERVICE)
            val characteristic = service.getCharacteristic(UUID_CHAR)
            gatt.setCharacteristicNotification(characteristic, true)
            // 还要写描述符
            val descriptor = characteristic.getDescriptor(UUID_DESCRIPTOR)
            descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
            gatt.writeDescriptor(descriptor)
        }
    }

    override fun onDescriptorWrite(gatt: BluetoothGatt, status: Int) {
        if (status == BluetoothGatt.GATT_SUCCESS) {
            // 描述符写入成功,可以开始读或写数据了
            gatt.readCharacteristic(targetCharacteristic)
        }
    }

    override fun onCharacteristicRead(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic, status: Int) {
        if (status == BluetoothGatt.GATT_SUCCESS) {
            val data = characteristic.value
            // 处理数据,记得回到主线程更新UI
            handler.post { updateUI(data) }
        }
    }

    override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
        val data = characteristic.value
        // 通知数据回调,注意线程安全问题
        handler.post { notifyDataChanged(data) }
    }
}
小提示:我个人习惯在onDescriptorWrite之后才执行首次数据操作。你看,服务发现→配置通知→写描述符→读/写数据,这个顺序一环扣一环,少了哪一步都可能翻车。

回调的线程模型,你搞懂了吗?

这里有个容易被忽略的点:所有这些回调默认跑在Binder线程池里,不是主线程。你想想看,如果直接在回调里更新UI,会怎么样?

没错,直接崩溃:CalledFromWrongThreadException。

解决方案其实很简单:用一个Handler把回调内容post到主线程。或者用Kotlin协程,把回调转成suspend函数。我在项目中倾向于后者,代码更简洁:

suspend fun readCharacteristicSuspend(gatt: BluetoothGatt, char: BluetoothGattCharacteristic): ByteArray {
    return suspendCancellableCoroutine { cont ->
        gatt.readCharacteristic(char)
        val callback = object : BluetoothGattCallback() {
            override fun onCharacteristicRead(
                gatt: BluetoothGatt,
                characteristic: BluetoothGattCharacteristic,
                status: Int
            ) {
                if (status == BluetoothGatt.GATT_SUCCESS) {
                    cont.resume(characteristic.value ?: ByteArray(0))
                } else {
                    cont.resumeWithException(BleException("Read failed, status=$status"))
                }
                // 注意:这里要主动移除这个临时callback,否则会堆积
                gatt.close()
            }
        }
        cont.invokeOnCancellation {
            gatt.close()
        }
    }
}
⚠️ 注意!用suspendCancellableCoroutine封装时,一定要处理好callback的生命周期。我遇到过一个问题:协程取消时忘记移除callback,结果回调里继续resume,导致IllegalStateException。这种bug特别难查。

常见陷阱与避坑指南

  • 回调顺序不保证:我曾经连续调用discoverServices和readCharacteristic,以为系统会排队执行。实际上,建议你用队列或者状态机显式控制流程。
  • onCharacteristicWrite的status不是100%可靠:有些国产设备返回的status可能是非标准值,不要只检查GATT_SUCCESS,建议记录日志并加一层重试。
  • onDescriptorWrite没写对就不能收发通知:很多初学者漏了这一步,结果设备发了数据但app收不到。特别注意:描述符的value要根据设备要求设置ENABLE_NOTIFICATION_VALUE或ENABLE_INDICATION_VALUE。
  • 记得调用super:新版本的Android系统在回调里加了默认行为,漏掉super可能导致一些奇怪的兼容性问题。

回调机制的核心思维导图

整个回调流程我用一个SVG串了起来,你看一遍就能记住关键依赖关系:

BluetoothGattCallback 生命周期与回调依赖关系 onConnectionStateChange onServicesDiscovered onDescriptorWrite onCharacteristicRead onCharacteristicWrite onCharacteristicChanged 注意:虚线箭头表示onCharacteristicChanged(通知数据)的接收不依赖onDescriptorWrite成功 但onDescriptorWrite失败后,设备可能不发送通知,导致数据收不到
⚠️ 特别提醒:上图中onServicesDiscovered到onDescriptorWrite之间还有一个关键步骤——你要调用gatt.setCharacteristicNotification()来开启本地通知开关。这个API必须在写描述符之前调用,否则描述符写操作会失败。我在测试时发现,很多新手把这两个顺序搞反了,结果回调死活不走。

聊聊测试中的回调验证

写了这么多回调代码,怎么确保它正确?我现在接手项目时,首先会给回调流程加日志:

override fun onCharacteristicChanged(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
    Log.d(TAG, "onCharacteristicChanged | uuid=${characteristic.uuid} | len=${characteristic.value?.size}")
    // 业务处理...
}

日志格式要统一,方便后续用logcat抓取分析。我常用的格式包含:回调名、UUID、数据长度、时间戳。这样在Compose快照回归测试中,可以直接断言某条日志是否按期望顺序出现。

好了,关于BluetoothGattCallback的生命周期和回调处理,核心要点都在这里了。你写回调时记住一个原则:每个回调都是一次独立的事件,不要假设前一个回调已经处理完。用好队列或状态机,你的BLE代码会稳定很多。