通知与订阅:CCCD配置、onCharacteristicChanged、实时数据接收、心跳包机制
好,我们继续往下走。上一章我们把连接和发现服务讲透了,但蓝牙低功耗真正好用的地方,其实是「设备主动给你发数据」。你想想看,如果每次都要手机去问「你心跳多少?」,那效率太低了。BLE 的设计哲学就是——设备有数据变化,直接推给你。
这一章,我们就来搞定这个「推」的机制。说白了,就是通知与订阅。
CCCD 是什么?为什么必须配它?
CCCD,全称是 Client Characteristic Configuration Descriptor。名字很长,但别怕。它就是一个描述符,专门用来控制某个特征值是否允许通知或指示。
我刚开始做 BLE 时,踩过一个坑:连接上了,也找到特征值了,但设备就是不发数据。查了半天,发现 CCCD 没写。嗯,这里要注意——不写 CCCD,设备默认不会主动推数据。
CCCD 的值很简单:
| 值 | 含义 |
|---|---|
| 0x0000 | 禁用通知和指示 |
| 0x0001 | 启用通知(Notification) |
| 0x0002 | 启用指示(Indication) |
通知和指示的区别,我简单说一下:通知不需要设备确认,速度快但可能丢包;指示需要设备回复确认,可靠但慢一点。大部分场景,比如心率、温度、姿态数据,用通知就够了。
核心原则:每次订阅前,先找到特征值对应的 CCCD 描述符,然后写入 0x0001 或 0x0002。
如何配置 CCCD?代码实战
在 Android 上,配置 CCCD 的流程是这样的:
- 通过 BluetoothGatt 找到目标特征值
- 获取该特征值的描述符(UUID 为 0x2902)
- 写入 0x0001 启用通知
- 等待 onDescriptorWrite 回调确认
直接上代码,这是我个人习惯的写法:
fun enableNotification(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
val descriptor = characteristic.getDescriptor(cccdUuid) ?: return
// 启用通知
descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
gatt.writeDescriptor(descriptor)
}
注意,ENABLE_NOTIFICATION_VALUE 就是 byteArrayOf(0x01, 0x00)。如果你要启用指示,就用 ENABLE_INDICATION_VALUE。
小技巧:我建议在 onDescriptorWrite 回调里,再调用一次 gatt.setCharacteristicNotification(characteristic, true)。虽然官方文档说这一步不是必须的,但我在项目中遇到过不调用就不触发回调的情况。保险起见,两步都做。
onCharacteristicChanged:实时数据的入口
配置好 CCCD 之后,设备一旦有数据变化,就会通过 onCharacteristicChanged 回调通知你。这个回调是实时数据接收的核心。
它的签名很简单:
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
val data = characteristic.value
// 处理实时数据
}
嗯,这里有个细节。在 Android 8.0(API 26)之后,characteristic.value 可能返回 null。我建议用 getValue() 方法,或者直接判空。
我在项目中遇到过一个问题:设备每 20ms 发一次数据,回调被疯狂调用。如果你在主线程里做耗时操作,UI 直接卡死。所以,一定要在回调里做轻量处理,或者用 Handler 切到工作线程。
注意:onCharacteristicChanged 是在 Binder 线程池中调用的,不要在这里直接更新 UI。用 runOnUiThread 或者 LiveData 都可以。
实时数据接收的典型架构
我一般会这样组织代码:
- 一个 BLE 管理类,负责连接、发现、订阅
- 一个数据解析器,把原始 byte 数组转成业务数据
- 一个回调接口,把解析后的数据抛给上层
举个例子,接收心率数据:
interface HeartRateListener {
fun onHeartRateUpdated(bpm: Int)
}
class BleManager(private val listener: HeartRateListener) {
override fun onCharacteristicChanged(
gatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic
) {
val data = characteristic.value ?: return
// 心率数据格式:第一个字节是标志位,第二个字节是心率值
if (data.size >= 2) {
val bpm = data[1].toInt() and 0xFF
listener.onHeartRateUpdated(bpm)
}
}
}
这样,上层 Activity 或 ViewModel 只需要实现 HeartRateListener,就能拿到实时心率。代码清晰,也方便测试。
心跳包机制:为什么需要?怎么实现?
你可能会问:BLE 不是已经连接了吗?为什么还要心跳包?
原因很简单——连接可能断,但你不知道。尤其是设备进入深睡眠、或者距离变远时,链路层可能已经断开了,但 Android 的 Gatt 回调可能不会立刻通知你。
心跳包的作用就是:定期确认连接是否还活着。
我建议的做法是:
- 手机端每隔 5~10 秒发一个空包或特定指令给设备
- 设备收到后回复一个确认包
- 如果连续 3 次没有收到回复,判定连接断开,主动重连
代码实现大致如下:
class HeartbeatManager(
private val gatt: BluetoothGatt,
private val characteristic: BluetoothGattCharacteristic
) {
private var missedCount = 0
private val handler = Handler(Looper.getMainLooper())
private val interval = 5000L // 5秒
private val heartbeatRunnable = object : Runnable {
override fun run() {
// 发送心跳包
characteristic.value = byteArrayOf(0xAA, 0x01) // 自定义心跳指令
gatt.writeCharacteristic(characteristic)
handler.postDelayed(this, interval)
}
}
fun start() {
handler.post(heartbeatRunnable)
}
fun onHeartbeatAck() {
missedCount = 0
}
fun onHeartbeatTimeout() {
missedCount++
if (missedCount >= 3) {
// 触发重连逻辑
gatt.disconnect()
gatt.close()
}
}
}
避坑指南:我曾经在心跳包里用了太短的间隔(2秒),结果设备频繁唤醒,电池一天就耗光了。后来改成 10 秒,配合设备端的低功耗模式,续航直接翻倍。所以,心跳间隔要根据设备功耗和业务实时性来权衡。
本章知识体系总览
我把这一章的核心逻辑画成了流程图,方便你理解整体脉络:
从图中你可以看到,整个流程分两条线:上面是标准的订阅流程,下面是心跳保活机制。两者并行运行,互不干扰。
总结几个关键点
- CCCD 是开关,不写它,设备不会主动发数据
- onCharacteristicChanged 是入口,但要注意线程安全
- 心跳包不是可选项,在长时间连接场景下,它是保命的
- 间隔要合理,别让设备因为心跳而耗电过快
嗯,这一章的内容就到这里。你把这些机制吃透了,BLE 的实时通信部分基本就稳了。下一章我们会聊更进阶的话题,但先把今天的内容消化掉,动手写写代码,效果会更好。