Android BLE 实战:从小米手环 7 踩坑到完整连接框架
包含 GATT 串行化 Bug / 配对加密 / 设备发现 / 动态 UUID 匹配等核心问题。
背景
技术栈:Kotlin + Android BLE API + ARouter + ViewBinding,核心逻辑约 800 行。这篇文章是我在这个项目里踩过的坑和学到的经验,从一个"零 IoT 经验"的 Android 开发视角来写,希望对同样刚接触 BLE 的读者有帮助。
先看一眼最终成果------成功读到的数据:
🔋 电池电量: 33%
🔧 硬件版本: 0.91.130.5
🔢 序列号: 39872/AAABF22ZJ05055
📟 软件版本: ...(读取中)
手环一共暴露了 12 个 GATT Service,包含标准服务(Battery、Device Info、Heart Rate、HID)和多个小米私有服务。
一、BLE 核心概念速览
如果你和我一样之前没做过 BLE,先花 3 分钟搞懂这几个概念就够了。
1.1 GATT 是什么
GATT(Generic Attribute Profile) 是 BLE 设备间数据交互的核心协议。它定义了一种 Client-Server 架构:
#mermaid-svg-Gf8AqASefVk8ym5t{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Gf8AqASefVk8ym5t .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Gf8AqASefVk8ym5t .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Gf8AqASefVk8ym5t .error-icon{fill:#552222;}#mermaid-svg-Gf8AqASefVk8ym5t .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Gf8AqASefVk8ym5t .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Gf8AqASefVk8ym5t .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Gf8AqASefVk8ym5t .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Gf8AqASefVk8ym5t .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Gf8AqASefVk8ym5t .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Gf8AqASefVk8ym5t .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Gf8AqASefVk8ym5t .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Gf8AqASefVk8ym5t .marker.cross{stroke:#333333;}#mermaid-svg-Gf8AqASefVk8ym5t svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Gf8AqASefVk8ym5t p{margin:0;}#mermaid-svg-Gf8AqASefVk8ym5t .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Gf8AqASefVk8ym5t .cluster-label text{fill:#333;}#mermaid-svg-Gf8AqASefVk8ym5t .cluster-label span{color:#333;}#mermaid-svg-Gf8AqASefVk8ym5t .cluster-label span p{background-color:transparent;}#mermaid-svg-Gf8AqASefVk8ym5t .label text,#mermaid-svg-Gf8AqASefVk8ym5t span{fill:#333;color:#333;}#mermaid-svg-Gf8AqASefVk8ym5t .node rect,#mermaid-svg-Gf8AqASefVk8ym5t .node circle,#mermaid-svg-Gf8AqASefVk8ym5t .node ellipse,#mermaid-svg-Gf8AqASefVk8ym5t .node polygon,#mermaid-svg-Gf8AqASefVk8ym5t .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Gf8AqASefVk8ym5t .rough-node .label text,#mermaid-svg-Gf8AqASefVk8ym5t .node .label text,#mermaid-svg-Gf8AqASefVk8ym5t .image-shape .label,#mermaid-svg-Gf8AqASefVk8ym5t .icon-shape .label{text-anchor:middle;}#mermaid-svg-Gf8AqASefVk8ym5t .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Gf8AqASefVk8ym5t .rough-node .label,#mermaid-svg-Gf8AqASefVk8ym5t .node .label,#mermaid-svg-Gf8AqASefVk8ym5t .image-shape .label,#mermaid-svg-Gf8AqASefVk8ym5t .icon-shape .label{text-align:center;}#mermaid-svg-Gf8AqASefVk8ym5t .node.clickable{cursor:pointer;}#mermaid-svg-Gf8AqASefVk8ym5t .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Gf8AqASefVk8ym5t .arrowheadPath{fill:#333333;}#mermaid-svg-Gf8AqASefVk8ym5t .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Gf8AqASefVk8ym5t .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Gf8AqASefVk8ym5t .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Gf8AqASefVk8ym5t .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Gf8AqASefVk8ym5t .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Gf8AqASefVk8ym5t .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Gf8AqASefVk8ym5t .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Gf8AqASefVk8ym5t .cluster text{fill:#333;}#mermaid-svg-Gf8AqASefVk8ym5t .cluster span{color:#333;}#mermaid-svg-Gf8AqASefVk8ym5t div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Gf8AqASefVk8ym5t .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Gf8AqASefVk8ym5t rect.text{fill:none;stroke-width:0;}#mermaid-svg-Gf8AqASefVk8ym5t .icon-shape,#mermaid-svg-Gf8AqASefVk8ym5t .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Gf8AqASefVk8ym5t .icon-shape p,#mermaid-svg-Gf8AqASefVk8ym5t .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Gf8AqASefVk8ym5t .icon-shape .label rect,#mermaid-svg-Gf8AqASefVk8ym5t .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Gf8AqASefVk8ym5t .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Gf8AqASefVk8ym5t .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Gf8AqASefVk8ym5t :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} GATT Server(手环)
GATT Client(手机)
BLE 无线
App
Service
Characteristic
Descriptor
- Client:手机,主动发起读/写请求
- Server:手环,存储数据并响应
1.2 三级数据结构
Service(服务) → 功能分组,比如 "电量服务"
└─ Characteristic(特征值) → 具体数据,比如 "当前电量 33%"
└─ Descriptor(描述符) → 元数据/配置,比如 "订阅通知"
一个设备可以有多个 Service,每个 Service 下有多个 Characteristic。每个元素都用UUID做唯一标识。
1.3 UUID 规则
| 类型 | UUID 格式 | 示例 |
|---|---|---|
| SIG 标准 | 0000xxxx-0000-1000-8000-00805f9b34fb |
Battery 0x180F |
| 厂商私有 | 任意 128-bit | cc353442-be58-4ea2-876e-11d8d6976366 |
看到前缀
0000的 16 位 UUID 就是标准服务/特征值;看到毫无规律的 128 位 UUID 就是厂商私有的。
1.4 四种 GATT 操作
| 操作 | 方向 | 说明 |
|---|---|---|
| Read | Client → Server | 客户端主动读取当前值 |
| Write | Client → Server | 客户端写入数据 |
| Notify | Server → Client | 服务器主动推送,不需确认(速度快) |
| Indicate | Server → Client | 服务器主动推送,需要确认(更可靠) |
二、完整连接流程
一张图概括从扫描到读取数据的全过程:
#mermaid-svg-SpRukDuEV3CxnRhU{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-SpRukDuEV3CxnRhU .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-SpRukDuEV3CxnRhU .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-SpRukDuEV3CxnRhU .error-icon{fill:#552222;}#mermaid-svg-SpRukDuEV3CxnRhU .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-SpRukDuEV3CxnRhU .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-SpRukDuEV3CxnRhU .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-SpRukDuEV3CxnRhU .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-SpRukDuEV3CxnRhU .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-SpRukDuEV3CxnRhU .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-SpRukDuEV3CxnRhU .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-SpRukDuEV3CxnRhU .marker{fill:#333333;stroke:#333333;}#mermaid-svg-SpRukDuEV3CxnRhU .marker.cross{stroke:#333333;}#mermaid-svg-SpRukDuEV3CxnRhU svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-SpRukDuEV3CxnRhU p{margin:0;}#mermaid-svg-SpRukDuEV3CxnRhU .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-SpRukDuEV3CxnRhU .cluster-label text{fill:#333;}#mermaid-svg-SpRukDuEV3CxnRhU .cluster-label span{color:#333;}#mermaid-svg-SpRukDuEV3CxnRhU .cluster-label span p{background-color:transparent;}#mermaid-svg-SpRukDuEV3CxnRhU .label text,#mermaid-svg-SpRukDuEV3CxnRhU span{fill:#333;color:#333;}#mermaid-svg-SpRukDuEV3CxnRhU .node rect,#mermaid-svg-SpRukDuEV3CxnRhU .node circle,#mermaid-svg-SpRukDuEV3CxnRhU .node ellipse,#mermaid-svg-SpRukDuEV3CxnRhU .node polygon,#mermaid-svg-SpRukDuEV3CxnRhU .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-SpRukDuEV3CxnRhU .rough-node .label text,#mermaid-svg-SpRukDuEV3CxnRhU .node .label text,#mermaid-svg-SpRukDuEV3CxnRhU .image-shape .label,#mermaid-svg-SpRukDuEV3CxnRhU .icon-shape .label{text-anchor:middle;}#mermaid-svg-SpRukDuEV3CxnRhU .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-SpRukDuEV3CxnRhU .rough-node .label,#mermaid-svg-SpRukDuEV3CxnRhU .node .label,#mermaid-svg-SpRukDuEV3CxnRhU .image-shape .label,#mermaid-svg-SpRukDuEV3CxnRhU .icon-shape .label{text-align:center;}#mermaid-svg-SpRukDuEV3CxnRhU .node.clickable{cursor:pointer;}#mermaid-svg-SpRukDuEV3CxnRhU .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-SpRukDuEV3CxnRhU .arrowheadPath{fill:#333333;}#mermaid-svg-SpRukDuEV3CxnRhU .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-SpRukDuEV3CxnRhU .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-SpRukDuEV3CxnRhU .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SpRukDuEV3CxnRhU .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-SpRukDuEV3CxnRhU .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SpRukDuEV3CxnRhU .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-SpRukDuEV3CxnRhU .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-SpRukDuEV3CxnRhU .cluster text{fill:#333;}#mermaid-svg-SpRukDuEV3CxnRhU .cluster span{color:#333;}#mermaid-svg-SpRukDuEV3CxnRhU div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-SpRukDuEV3CxnRhU .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-SpRukDuEV3CxnRhU rect.text{fill:none;stroke-width:0;}#mermaid-svg-SpRukDuEV3CxnRhU .icon-shape,#mermaid-svg-SpRukDuEV3CxnRhU .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-SpRukDuEV3CxnRhU .icon-shape p,#mermaid-svg-SpRukDuEV3CxnRhU .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-SpRukDuEV3CxnRhU .icon-shape .label rect,#mermaid-svg-SpRukDuEV3CxnRhU .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-SpRukDuEV3CxnRhU .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-SpRukDuEV3CxnRhU .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-SpRukDuEV3CxnRhU :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} startScan
是
否
是
扫描 BLE 设备
发现手环?
connectGatt
discoverServices
GATT 服务清单
已配对?
createBond
用户确认配对
Broadcast: BOND_BONDED
直接读取
写 CCCD 启用通知
readCharacteristic 读取电量
读取设备信息
看起来线性而简单。但实际实现中,每一步都藏着坑。下面逐一展开。
三、踩坑与修复
坑 1:GATT 操作"静默"失败(最深的坑 ⭐⭐⭐)
现象:
log
readCharacteristic() - uuid: 00002a19 ← 读请求发出去了
writeDescriptor() - uuid: 00002902 ← CCCD 写发出去了
...(8 秒无声无息)...
onClientConnectionState: DISCONNECTED ← 连接断了
onCharacteristicRead 回调从未触发。API 没有抛异常,Logcat 没有报错,就是"什么都没发生"。这是 BLE 开发中最让人抓狂的体验。
排查过程:
- 用
dumpGattServices()打印了手环的完整 GATT 服务树,确认 Battery Service(0x180F)和 Battery Level 特征值(0x2A19)确实存在,属性READ=true, NOTIFY=true - 确认
readCharacteristic()确实被调用了 - 抓日志发现
writeDescriptor(CCCD)和readCharacteristic之间几乎没有时间间隔 - 查阅 Android BLE 源码:Bluedroid 协议栈维护一个单操作队列,同一时间只能处理一个未完成的 GATT 操作
根因:
kotlin
// ❌ 错误写法(两个 GATT 操作并发)
gatt.writeDescriptor(cccd) // 异步,发送中...
gatt.readCharacteristic(batteryChar) // 立即调用 → 被 Bluedroid 静默丢弃!
writeDescriptor 还没完成,readCharacteristic 就被 Android 协议栈扔掉了------不抛异常、不回调错误,就像没调用过一样。
修复:
kotlin
// ✅ 正确写法(串行化:在回调中发起下一个操作)
pendingBatteryRead = true // 标记:CCCD 写完需要读电池
gatt.writeDescriptor(cccd)
// 不在这一行调 readCharacteristic!
// 在 onDescriptorWrite 回调中:
override fun onDescriptorWrite(gatt, descriptor, status) {
if (status == GATT_SUCCESS && pendingBatteryRead && isBatteryChar(descriptor)) {
pendingBatteryRead = false
gatt.readCharacteristic(batteryChar) // ← 这里才安全
}
}
修复后的日志:
log
writeDescriptor() - uuid: 00002902
onDescriptorWrite: status=OK ← CCCD 写入完成
CCCD 写入成功,执行 pending 的 read...
readCharacteristic() - uuid: 00002a19 ← 现在安全了
onCharacteristicRead: status=OK ← 成功!
🔋 电池电量(Read): 33%
💡 核心认知 :Android BLE 的 GATT 操作是严格串行的。
readCharacteristic、writeCharacteristic、writeDescriptor三个方法都算"GATT 操作",同一时刻只能有一个在飞行中。需要在当前操作的回调中发起下一个操作,而不是连续调用。
坑 2:配对后才能读特征值 ⭐⭐
现象 :未配对时,即使调用 readCharacteristic 成功了,回调也永远不触发,几秒后连接断开。
原因 :小米手环 7 对特征值读写实施了链路加密保护。必须完成 BLE Bonding(配对并存储密钥)后,GATT 链路才是加密的,设备才会响应读写请求。
修复 :在 onServicesDiscovered 后调用 createBond(),通过 BroadcastReceiver 监听配对结果,成功后再开始读写:
kotlin
override fun onServicesDiscovered(gatt, status) {
if (status == GATT_SUCCESS) {
if (gatt.device.bondState == BOND_BONDED) {
// 已配对,直接读
readCharacteristicsAfterBond(gatt)
} else {
// 未配对,先配对
gatt.device.createBond()
// → 等待 BroadcastReceiver 收到 BOND_BONDED
}
}
}
坑 3:已连接设备扫描不到 ⭐
现象 :手环已经被系统蓝牙连接后,App 的 startScan() 无论如何都扫不到。
原因 :BLE 外设连接后会停止广播(Advertising)以省电。小米手环 7 被系统蓝牙连接后就不发广播包了,所以 BluetoothLeScanner 肯定扫不到。
修复 :扫描前先查 BluetoothAdapter.bondedDevices 已配对列表:
kotlin
private fun checkBondedDevices() {
bluetoothAdapter?.bondedDevices?.forEach { device ->
val name = device.name ?: return@forEach
if (MI_BAND_KEYWORDS.any { name.contains(it, ignoreCase = true) }) {
scannedDevices.add(device) // 直接加入扫描列表
}
}
}
这样无论手环是否正在被系统连接,都能在 App 的设备列表里出现。
坑 4:Device Info UUID 不通用 ⭐
SIG 标准为 Device Information Service(0x180A)定义了十几种特征值 UUID,涵盖制造商、型号、序列号、硬件版本、软件版本等。但不同厂商选择性实现。
我以为 0x2A29(Manufacturer Name String)一定是标准配置,结果 Mi Band 7 根本没这个特征值。实际暴露的 5 个特征值跟我预设的完全不同:
| 我预设读的 | Mi Band 7 实际有的 |
|---|---|
❌ 0x2A29 制造商 |
0x2A23 系统 ID |
❌ 0x2A24 型号 |
0x2A25 序列号 |
❌ 0x2A26 固件版本 |
0x2A27 硬件版本 |
✅ 0x2A27 硬件版本 |
0x2A28 软件版本 |
| --- | 0x2A50 PnP ID |
修复:不要预设固定 UUID,先 dump 全部服务,再动态匹配:
kotlin
private fun dumpGattServices(gatt: BluetoothGatt) {
gatt.services.forEach { service ->
service.characteristics.forEach { char ->
val props = char.properties
val flags = mutableListOf<String>()
if ((props and PROPERTY_READ) != 0) flags.add("R")
if ((props and PROPERTY_NOTIFY) != 0) flags.add("N")
if ((props and PROPERTY_WRITE) != 0) flags.add("W")
Log.d(TAG, "Char: ${char.uuid} [${flags.joinToString()}]")
}
}
}
💡 生产级 BLE 代码不应硬编码 UUID,要自适应设备实际暴露的服务结构。
坑 5:Android 12 蓝牙权限大改
Android 12(API 31)重构了蓝牙权限模型:
| Android 版本 | 需要的权限 |
|---|---|
| < 12 | ACCESS_FINE_LOCATION(BLE 扫描需要位置权限) |
| ≥ 12 | BLUETOOTH_SCAN + BLUETOOTH_CONNECT(专用蓝牙权限) |
如果用 neverForLocation 声明,还可以明确告诉系统"我不需要位置":
xml
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation"
tools:targetApi="s" />
坑 6:Battery 的 Read + Notify 双路径
手环的 Battery Level 特征值同时支持 Read 和 Notify。但它们的适用场景不同:
- Read:主动读取当前值(开 App 时获取一次)
- Notify:电量变化时自动推送(后台持续监听)
我的策略:先写 CCCD 启用通知,然后在 onDescriptorWrite 回调中再执行 Read。Notify 作为"兜底"------如果 Read 因为某种原因失败了,还有推送机制:
kotlin
val supportsRead = (props and PROPERTY_READ) != 0
val supportsNotify = (props and PROPERTY_NOTIFY) != 0
when {
supportsNotify && supportsRead -> {
// 串行:先 CCCD,等回调后再 Read,Notify 兜底
gatt.setCharacteristicNotification(char, true)
pendingBatteryRead = true
gatt.writeDescriptor(cccd)
}
supportsRead -> gatt.readCharacteristic(char)
supportsNotify -> { /* 仅等推送 */ }
}
四、常见问题与最佳实践
基础概念
Q: BLE 和经典蓝牙有什么区别?
| BLE | 经典蓝牙 | |
|---|---|---|
| 功耗 | 纽扣电池可用数月 | 较高 |
| 速率 | ~1 Mbps | ~3 Mbps |
| 协议 | GATT | RFCOMM/SPP |
| 场景 | 手环/传感器 | 耳机/音箱 |
Q: CCCD 是什么?怎么启用通知?
CCCD = Client Characteristic Configuration Descriptor(UUID 0x2902)。写 0x0001=启用 Notify,0x0002=启用 Indicate,0x0000=关闭。
进阶理解
Q: connectGatt 的 autoConnect 参数有什么区别?
false:直接连接,超时后不重试(推荐)true:后台自动连接,设备进入范围时自动连(耗电,适用需要长期后台连接的场景)
Q: BLE 扫描模式选哪个?
| 模式 | 功耗 | 延迟 | 场景 |
|---|---|---|---|
SCAN_MODE_LOW_POWER |
低 | 高 | 后台持续 |
SCAN_MODE_BALANCED |
中 | 中 | 通用 |
SCAN_MODE_LOW_LATENCY |
高 | 低 | 快速发现 |
Q: MTU 协商有什么用?
默认 MTU 23 字节,有效载荷只有 20 字节。BLE 4.2+ 可协商到 247 字节,单包传 244 字节,大数据传输吞吐量提升约 12 倍:
kotlin
gatt.requestMtu(247)
// → onMtuChanged(gatt, mtu, status)
Q: Connection Interval 怎么影响功耗?
| Interval | 功耗 | 延迟 | 手环典型场景 |
|---|---|---|---|
| 7.5ms | 高 | 极低 | 实时心率 |
| 30ms | 中 | 低 | 通知推送 |
| 100ms+ | 低 | 高 | 待机同步 |
小米手环 7 会在连接后自动调整参数------从日志看从 interval=12 (15ms) 调整到了 interval=40 (50ms)。
五、如何设计一个健壮的 BLE 连接管理器
这是从这次实践中提炼出来的架构思路:
- 状态机 :
Idle → Scanning → Connecting → Discovering → Ready → Disconnecting,每个状态只允许特定操作 - 操作队列:所有 GATT 操作入 FIFO 队列,前一个操作的回调中出队下一个
- 超时机制:每个 GATT 操作设置超时(如 Read/Write 5s),超时后断开重连
- 重连策略:指数退避(1s → 2s → 4s → 8s → max 30s)
- 异常恢复 :
onConnectionStateChange(status != 0)时自动触发重连 - MTU 协商 :连接后尽早
requestMtu(247),提升后续大数据传输效率
六、GATT 状态码速查
| 值 | 常量 | 含义 |
|---|---|---|
| 0 | GATT_SUCCESS |
操作成功 |
| 2 | GATT_READ_NOT_PERMIT |
该特征值不允许读 |
| 3 | GATT_WRITE_NOT_PERMIT |
不允许写 |
| 5 | GATT_INSUFFICIENT_AUTHENTICATION |
需要先配对 |
| 6 | GATT_REQUEST_NOT_SUPPORTED |
不支持的操作 |
| 8 | GATT_INSUFFICIENT_ENCRYPTION |
加密等级不够 |
| 15 | GATT_INSUFFICIENT_AUTHORIZATION |
需要授权 |
| 257 | GATT_CONN_TIMEOUT |
连接超时(通常是设备无响应) |
七、小米手环 7 GATT 服务清单
供读者参考------如果你也在做类似的设备适配:
Xiaomi Smart Band 7 / 12 个 Service
│
├─ 0x1800 Generic Access [设备名、外观]
├─ 0x1801 Generic Attribute [Service Changed]
├─ 0x1811 Alert Notification [新消息提醒]
├─ 0x180A Device Information [序列号、硬件/软件版本、PnP ID]
├─ 0xFEE0 小米私有服务 [8 chars:认证/数据交互核心]
├─ 0x1530 小米私有服务 [2 chars]
├─ 0x180D Heart Rate [N] ← 心率
├─ 0xFEE1 小米私有 [2 chars]
├─ 0x3802 小米私有 [W,N]
├─ cc353442... 自定义 UUID [W,N]
├─ 0x1812 HID [8 chars:人机接口设备]
└─ 0x180F Battery Service ⭐ [R,N] ← 电量 33%
写在最后
从一个对 BLE 一无所知的 Android 开发,到能完整实现"扫描→连接→配对→GATT 交互→数据读取"的全链路框架,这篇文章记录了我踩过的每一个坑。
最大的教训是:Android BLE 的 API 是"乐观"设计的------它不报错不代表操作成功了。尤其是 GATT 串行化问题,API 不抛异常、回调不触发、日志看不出线索,完全靠读源码和推理才定位到。
如果你也在做类似的 IoT 开发,希望能少走一些弯路。