第六章 读写操作实战:读取特征值、写入特征值、setCharacteristicNotification、onCharacteristicRead/Write

好,咱们进入真正的实战环节了。前面几章我们把 BLE 的扫描、连接、服务发现都走了一遍,现在终于可以跟设备“对话”了。说白了,读写特征值就是 BLE 通信的核心操作——你发指令,设备回数据,就这么简单。

我个人习惯把这一章叫做“握手环节”。因为大部分 BLE 设备,你连上之后第一件事就是读某个特征值看看状态,或者写一个指令让它开始干活。嗯,咱们今天就把这几种操作彻底讲透。

6.1 读取特征值:从设备拿数据

读取特征值,就是向 BLE 设备请求某个特征值的当前内容。比如一个温度计,你读它的温度特征值,就能拿到当前温度。

在 Android 里,读取操作是通过 BluetoothGatt.readCharacteristic() 发起的。但注意,这是个异步操作——你调用之后不会立刻拿到结果,而是要通过回调 onCharacteristicRead() 来接收。

核心流程:

  1. 找到你要读的特征值对象(BluetoothGattCharacteristic
  2. 调用 gatt.readCharacteristic(characteristic)
  3. onCharacteristicRead() 回调中处理返回的数据

来看代码:

// 发起读取
fun readCharacteristic(gatt: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
    val success = gatt.readCharacteristic(characteristic)
    if (!success) {
        Log.e("BLE", "读取特征值失败,可能连接已断开")
    }
}

// 回调处理
override fun onCharacteristicRead(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    status: Int
) {
    if (status == BluetoothGatt.GATT_SUCCESS) {
        val data = characteristic.value
        // data 是 ByteArray,需要按协议解析
        Log.d("BLE", "读取成功,数据长度: ${data?.size}")
    } else {
        Log.e("BLE", "读取失败,错误码: $status")
    }
}

这里有个坑,我曾经踩过:不要在 onCharacteristicRead 里直接调用另一个 readCharacteristic。Android 的 BLE 栈在同一时间只允许一个 GATT 操作在进行,连续调用会导致第二个操作被忽略。解决办法是用队列,或者等上一个回调完成再发下一个。

小技巧:读取到的数据是 ByteArray,通常需要按设备的协议文档来解析。比如温度传感器可能返回两个字节,需要转成 short 再除以 10。我习惯写一个工具类专门处理数据解析,避免在回调里写一堆位运算。

6.2 写入特征值:给设备发指令

写入操作比读取稍微复杂一点,因为 BLE 支持两种写入模式:带响应写入无响应写入。你想想看,这就像你给朋友发微信——带响应就是你要等对方回“收到了”,无响应就是发完就不管了。

写入类型 特点 适用场景
带响应写入 (WRITE_TYPE_DEFAULT) 设备收到后会回复确认,速度较慢但可靠 关键指令,如开关控制、参数设置
无响应写入 (WRITE_TYPE_NO_RESPONSE) 发完即走,速度快但无确认 大量数据流,如 OTA 升级、实时数据

写入的代码也很直接:

fun writeCharacteristic(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    data: ByteArray,
    writeType: Int = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
) {
    // 设置写入类型
    characteristic.writeType = writeType
    // 设置要写入的数据
    characteristic.value = data
    // 发起写入
    val success = gatt.writeCharacteristic(characteristic)
    if (!success) {
        Log.e("BLE", "写入特征值失败")
    }
}

// 回调
override fun onCharacteristicWrite(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic,
    status: Int
) {
    if (status == BluetoothGatt.GATT_SUCCESS) {
        Log.d("BLE", "写入成功")
    } else {
        Log.e("BLE", "写入失败,错误码: $status")
    }
}

注意:无响应写入虽然快,但如果你连续发太快,设备的蓝牙缓冲区可能会满,导致丢包。我建议在无响应写入时,每条指令之间至少间隔 20-30 毫秒。如果数据量大,最好用流控机制。

6.3 开启通知:让设备主动告诉你

读取是“你问它答”,通知是“它主动告诉你”。对于需要实时更新的数据(比如心率、电量),用通知再合适不过了。

开启通知需要两步:

  1. 调用 gatt.setCharacteristicNotification(characteristic, true)
  2. 找到对应的 CCCD(客户端特征配置描述符),写入 0x0001 或 0x0002

为什么需要两步?说白了,第一步是告诉 Android 系统“我要监听这个特征值”,第二步是告诉 BLE 设备“请开始发送通知”。缺一不可。

fun enableNotification(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic
) {
    // 第一步:设置本地通知
    val success = gatt.setCharacteristicNotification(characteristic, true)
    if (!success) {
        Log.e("BLE", "设置通知失败")
        return
    }

    // 第二步:写入 CCCD
    val cccdUuid = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
    val descriptor = characteristic.getDescriptor(cccdUuid)
    if (descriptor == null) {
        Log.e("BLE", "未找到 CCCD 描述符")
        return
    }

    // 0x0001 表示开启通知,0x0002 表示开启指示
    descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
    gatt.writeDescriptor(descriptor)
}

// 接收通知的回调
override fun onCharacteristicChanged(
    gatt: BluetoothGatt,
    characteristic: BluetoothGattCharacteristic
) {
    val data = characteristic.value
    Log.d("BLE", "收到通知,数据: ${data?.toHexString()}")
}

个人经验:有些设备比较“调皮”,你写 CCCD 之后它不会立刻发通知,可能需要等几百毫秒。我习惯在开启通知后加一个 500ms 的延迟再开始读取数据,这样能避免漏掉前几条通知。

6.4 完整操作流程与状态机

在实际项目中,读写操作不是孤立的。你需要管理好 GATT 操作的状态,避免冲突。下面这张图展示了典型的读写流程:

BLE 读写操作状态机 空闲 (IDLE) 读取中 (READING) 写入中 (WRITING) 通知中 (NOTIFYING) 操作完成 错误处理 readCharacteristic() writeCharacteristic() setNotify + writeCCCD onCharacteristicRead() onCharacteristicWrite() onCharacteristicChanged() status != SUCCESS

从这张图可以看出,所有操作都从空闲状态开始,完成后回到空闲。如果操作失败,进入错误处理,然后也要回到空闲。我曾经见过一个项目,因为没处理好错误状态,导致连续失败后 GATT 操作卡死,整个蓝牙功能就废了。

6.5 实战中的避坑指南

最后,我总结几个实际开发中容易踩的坑:

  • 操作队列是必须的。 Android BLE 不支持并发操作。我习惯用一个 ConcurrentLinkedQueue 来管理所有读写请求,每次只处理队列头部的操作。
  • 注意线程问题。 所有 GATT 回调都在 Binder 线程,不能直接更新 UI。记得用 Handler 或 LiveData 切到主线程。
  • 写入数据长度有限制。 默认 MTU 是 23 字节,扣掉 3 字节头,实际只能写 20 字节。如果需要写更多,先协商 MTU(后面章节会讲)。
  • 关闭通知要彻底。 退出页面时,不仅要调用 setCharacteristicNotification(false),还要把 CCCD 写回 0x0000。否则下次连接可能收不到通知。

核心要点回顾:

  • 读取用 readCharacteristic + onCharacteristicRead
  • 写入分带响应和无响应两种,按场景选择
  • 开启通知需要两步:本地设置 + 写 CCCD
  • 所有操作都是异步的,通过回调获取结果
  • 务必用队列管理操作,避免并发冲突

嗯,读写操作就这些内容。代码量不大,但细节很多。你只要把状态机理清楚,把回调处理好,剩下的就是按协议解析数据了。下一章我们会聊更高级的话题——MTU 协商和长数据读写,到时候你会发现,今天学的这些是基础中的基础。


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