第五章:服务与特征值发现
好,咱们继续。上一章我们把连接建立起来了,但连接上只是第一步。你想想看,你连上一个设备,总得知道它能干什么吧?
这就好比你去一个陌生的图书馆,门是开了,但你不知道里面有几层楼、每层楼有什么书。这时候你需要一张「地图」。在 BLE 的世界里,这张地图就是 服务(Service) 和 特征值(Characteristic)。
5.1 发现服务:onServicesDiscovered 回调
连接成功后,我们调用 BluetoothGatt.discoverServices() 来触发服务发现。系统会异步去读取设备的 GATT 数据库,然后通过 onServicesDiscovered 回调告诉我们结果。
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status == BluetoothGatt.GATT_SUCCESS) {
// 服务发现成功
val services = gatt.services
for (service in services) {
Log.d(TAG, "发现服务: ${service.uuid}")
}
} else {
Log.w(TAG, "服务发现失败,状态码: $status")
}
}
这里有个坑,我踩过。有一次在项目中,我连续调用了两次 discoverServices(),结果第二次回调一直没触发。后来查文档才发现,这个操作是串行的——你必须在一次发现完成之后才能发起下一次。嗯,这个细节很容易被忽略。
5.2 BluetoothGattService:服务的结构
一个 BluetoothGattService 代表一个功能模块。比如心率服务、电池服务、设备信息服务等等。每个服务都有一个唯一的 UUID 来标识。
服务有两种类型:
- 主服务(Primary Service):独立的功能模块
- 次服务(Secondary Service):被主服务引用,不能独立存在
我个人习惯在发现服务后,先打印出所有服务的 UUID 和类型,这样能快速了解设备的整体结构。
for (service in gatt.services) {
val type = if (service.type == BluetoothGattService.SERVICE_TYPE_PRIMARY) "主服务" else "次服务"
Log.d(TAG, "UUID: ${service.uuid}, 类型: $type")
}
5.3 BluetoothGattCharacteristic:特征值的核心
服务下面挂载的是特征值。特征值才是真正读写数据的地方。每个特征值也有自己的 UUID,并且包含一组属性(Properties)和权限(Permissions)。
属性决定了你能对这个特征值做什么:
| 属性常量 | 含义 | 说明 |
|---|---|---|
| PROPERTY_READ | 可读 | 可以读取特征值的数据 |
| PROPERTY_WRITE | 可写(无响应) | 写入数据,不需要设备确认 |
| PROPERTY_WRITE_NO_RESPONSE | 可写(无响应) | 同上,但更高效 |
| PROPERTY_NOTIFY | 通知 | 设备主动推送数据,不需要我们轮询 |
| PROPERTY_INDICATE | 指示 | 类似通知,但设备会等待确认 |
我建议你在发现服务后,把每个服务的特征值也遍历出来,看看它们的属性和 UUID。这样后面读写的时候心里有数。
for (service in gatt.services) {
for (characteristic in service.characteristics) {
val properties = characteristic.properties
Log.d(TAG, "特征值: ${characteristic.uuid}, 属性: $properties")
}
}
5.4 UUID 管理:别让 UUID 满天飞
UUID 是 BLE 开发中最容易让人头疼的东西之一。16 位的 UUID(比如 0x180D 表示心率服务)是 Bluetooth SIG 定义的,可以直接用。但更多时候,我们面对的是厂商自定义的 128 位 UUID。
我在项目中见过最糟糕的情况:UUID 字符串散落在代码的各个角落,有的写成了大写,有的写成了小写,还有的漏了横杠。调试的时候死活对不上,浪费了大半天。
所以我的做法是:把所有 UUID 集中管理,用一个常量类或者枚举来维护。
object BleUuid {
// 标准服务
val HEART_RATE_SERVICE = UUID.fromString("0000180D-0000-1000-8000-00805F9B34FB")
val BATTERY_SERVICE = UUID.fromString("0000180F-0000-1000-8000-00805F9B34FB")
val DEVICE_INFO_SERVICE = UUID.fromString("0000180A-0000-1000-8000-00805F9B34FB")
// 自定义服务(假设是某个智能手环)
val CUSTOM_SERVICE = UUID.fromString("12345678-1234-5678-1234-56789ABCDEF0")
val CUSTOM_CHAR_READ = UUID.fromString("12345678-1234-5678-1234-56789ABCDEF1")
val CUSTOM_CHAR_WRITE = UUID.fromString("12345678-1234-5678-1234-56789ABCDEF2")
val CUSTOM_CHAR_NOTIFY = UUID.fromString("12345678-1234-5678-1234-56789ABCDEF3")
}
5.5 完整的服务发现流程
把上面这些串起来,一个完整的服务发现流程大概是这样的:
// 1. 发起发现
gatt.discoverServices()
// 2. 回调中处理
override fun onServicesDiscovered(gatt: BluetoothGatt, status: Int) {
if (status != BluetoothGatt.GATT_SUCCESS) {
Log.e(TAG, "发现失败")
return
}
// 3. 遍历服务
for (service in gatt.services) {
when (service.uuid) {
BleUuid.HEART_RATE_SERVICE -> {
// 找到心率服务,处理它的特征值
handleHeartRateService(service)
}
BleUuid.BATTERY_SERVICE -> {
handleBatteryService(service)
}
BleUuid.CUSTOM_SERVICE -> {
handleCustomService(service)
}
}
}
}
private fun handleHeartRateService(service: BluetoothGattService) {
for (char in service.characteristics) {
when (char.uuid) {
// 心率测量特征值,通常支持通知
UUID.fromString("00002A37-0000-1000-8000-00805F9B34FB") -> {
// 开启通知
gatt.setCharacteristicNotification(char, true)
}
}
}
}
这里有个细节:setCharacteristicNotification 只是告诉本地 GATT 客户端你要监听这个特征值。但要让设备真正开始推送数据,你还需要往对应的 CCCD(Client Characteristic Configuration Descriptor)写入 0x0001(启用通知)或 0x0002(启用指示)。这个我们后面会详细讲。
5.6 知识体系图
下面这张图帮你理清服务发现的核心逻辑:
5.7 避坑指南
最后,分享几个我踩过的坑:
- 服务发现不是自动的:连接成功后不会自动发现服务,你必须手动调用
discoverServices()。我见过有人以为连上就能直接读写,结果一直报错。 - 不要在 onConnectionStateChange 里直接发现:虽然可以,但我建议加一个短暂延迟(比如 100ms),确保连接完全稳定。有些设备连接成功后需要一点时间准备 GATT 数据库。
- UUID 匹配要严谨:我曾经因为 UUID 字符串末尾多了一个空格,排查了整整一个下午。建议用
UUID.equals()而不是字符串比较。 - 特征值属性要检查:不要假设所有特征值都可读可写。先检查
characteristic.properties,再决定操作方式。
核心要点:
- 服务发现是 BLE 通信的「地图绘制」阶段
- 每个服务包含多个特征值,特征值才是数据交互的单元
- UUID 是唯一标识,务必集中管理
- 特征值的属性决定了你能对它做什么
- 开启通知需要两步:setCharacteristicNotification + 写 CCCD
好了,服务发现这块就讲到这里。你只要把 UUID 管理好,把回调逻辑写清楚,后面读写数据就是水到渠成的事。