第五章:服务与特征值发现

好,咱们继续。上一章我们把连接建立起来了,但连接上只是第一步。你想想看,你连上一个设备,总得知道它能干什么吧?

这就好比你去一个陌生的图书馆,门是开了,但你不知道里面有几层楼、每层楼有什么书。这时候你需要一张「地图」。在 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(),结果第二次回调一直没触发。后来查文档才发现,这个操作是串行的——你必须在一次发现完成之后才能发起下一次。嗯,这个细节很容易被忽略。

注意:onServicesDiscovered 回调是在 Binder 线程中执行的。不要在这里直接操作 UI,记得切到主线程。

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")
}
小技巧:Android 的 UUID.fromString() 对大小写不敏感,但为了可读性,我建议统一用小写。另外,标准 UUID 的 Base UUID 是 0000xxxx-0000-1000-8000-00805F9B34FB,其中 xxxx 就是 16 位的短 UUID。

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 知识体系图

下面这张图帮你理清服务发现的核心逻辑:

服务与特征值发现流程 连接成功 gatt.discoverServices() onServicesDiscovered() 遍历 Services 按 UUID 匹配服务 遍历 Characteristics 检查属性,开启通知 核心:UUID 匹配 → 属性检查 → 功能绑定

5.7 避坑指南

最后,分享几个我踩过的坑:

  • 服务发现不是自动的:连接成功后不会自动发现服务,你必须手动调用 discoverServices()。我见过有人以为连上就能直接读写,结果一直报错。
  • 不要在 onConnectionStateChange 里直接发现:虽然可以,但我建议加一个短暂延迟(比如 100ms),确保连接完全稳定。有些设备连接成功后需要一点时间准备 GATT 数据库。
  • UUID 匹配要严谨:我曾经因为 UUID 字符串末尾多了一个空格,排查了整整一个下午。建议用 UUID.equals() 而不是字符串比较。
  • 特征值属性要检查:不要假设所有特征值都可读可写。先检查 characteristic.properties,再决定操作方式。

核心要点:

  • 服务发现是 BLE 通信的「地图绘制」阶段
  • 每个服务包含多个特征值,特征值才是数据交互的单元
  • UUID 是唯一标识,务必集中管理
  • 特征值的属性决定了你能对它做什么
  • 开启通知需要两步:setCharacteristicNotification + 写 CCCD

好了,服务发现这块就讲到这里。你只要把 UUID 管理好,把回调逻辑写清楚,后面读写数据就是水到渠成的事。


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