通知与订阅:CCCD配置、onCharacteristicChanged、实时数据接收、心跳包机制

好,我们继续往下走。上一章我们把连接和发现服务讲透了,但蓝牙低功耗真正好用的地方,其实是「设备主动给你发数据」。你想想看,如果每次都要手机去问「你心跳多少?」,那效率太低了。BLE 的设计哲学就是——设备有数据变化,直接推给你。

这一章,我们就来搞定这个「推」的机制。说白了,就是通知与订阅。

CCCD 是什么?为什么必须配它?

CCCD,全称是 Client Characteristic Configuration Descriptor。名字很长,但别怕。它就是一个描述符,专门用来控制某个特征值是否允许通知或指示。

我刚开始做 BLE 时,踩过一个坑:连接上了,也找到特征值了,但设备就是不发数据。查了半天,发现 CCCD 没写。嗯,这里要注意——不写 CCCD,设备默认不会主动推数据

CCCD 的值很简单:

含义
0x0000 禁用通知和指示
0x0001 启用通知(Notification)
0x0002 启用指示(Indication)

通知和指示的区别,我简单说一下:通知不需要设备确认,速度快但可能丢包;指示需要设备回复确认,可靠但慢一点。大部分场景,比如心率、温度、姿态数据,用通知就够了。

核心原则:每次订阅前,先找到特征值对应的 CCCD 描述符,然后写入 0x0001 或 0x0002。

如何配置 CCCD?代码实战

在 Android 上,配置 CCCD 的流程是这样的:

  1. 通过 BluetoothGatt 找到目标特征值
  2. 获取该特征值的描述符(UUID 为 0x2902)
  3. 写入 0x0001 启用通知
  4. 等待 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 回调可能不会立刻通知你。

心跳包的作用就是:定期确认连接是否还活着。

我建议的做法是:

  1. 手机端每隔 5~10 秒发一个空包或特定指令给设备
  2. 设备收到后回复一个确认包
  3. 如果连续 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 秒,配合设备端的低功耗模式,续航直接翻倍。所以,心跳间隔要根据设备功耗和业务实时性来权衡。

本章知识体系总览

我把这一章的核心逻辑画成了流程图,方便你理解整体脉络:

通知与订阅核心流程 1. 发现特征值 2. 获取CCCD描述符 3. 写入0x0001 4. setCharacteristicNotification 5. 等待onDescriptorWrite 6. 接收onCharacteristicChanged 心跳包:定时发送指令 收到ACK → 重置计数 连续3次超时 → 重连 并行运行

从图中你可以看到,整个流程分两条线:上面是标准的订阅流程,下面是心跳保活机制。两者并行运行,互不干扰。

总结几个关键点

  • CCCD 是开关,不写它,设备不会主动发数据
  • onCharacteristicChanged 是入口,但要注意线程安全
  • 心跳包不是可选项,在长时间连接场景下,它是保命的
  • 间隔要合理,别让设备因为心跳而耗电过快

嗯,这一章的内容就到这里。你把这些机制吃透了,BLE 的实时通信部分基本就稳了。下一章我们会聊更进阶的话题,但先把今天的内容消化掉,动手写写代码,效果会更好。


公众号:蓝海资料掘金营,微信deep3321