Android BLE 实战:从小米手环 7 踩坑到完整连接框架

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 开发中最让人抓狂的体验。

排查过程

  1. dumpGattServices() 打印了手环的完整 GATT 服务树,确认 Battery Service(0x180F)和 Battery Level 特征值(0x2A19)确实存在,属性 READ=true, NOTIFY=true
  2. 确认 readCharacteristic() 确实被调用了
  3. 抓日志发现 writeDescriptor(CCCD)readCharacteristic 之间几乎没有时间间隔
  4. 查阅 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 操作是严格串行的。readCharacteristicwriteCharacteristicwriteDescriptor 三个方法都算"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: connectGattautoConnect 参数有什么区别?

  • 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 连接管理器

这是从这次实践中提炼出来的架构思路:

  1. 状态机Idle → Scanning → Connecting → Discovering → Ready → Disconnecting,每个状态只允许特定操作
  2. 操作队列:所有 GATT 操作入 FIFO 队列,前一个操作的回调中出队下一个
  3. 超时机制:每个 GATT 操作设置超时(如 Read/Write 5s),超时后断开重连
  4. 重连策略:指数退避(1s → 2s → 4s → 8s → max 30s)
  5. 异常恢复onConnectionStateChange(status != 0) 时自动触发重连
  6. 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 开发,希望能少走一些弯路。

相关推荐
笑一下蒜了.2 小时前
Linux 存储多路径访问手册|multipath.conf 各配置段详解
android·linux·运维
凡泰AI2 小时前
如何通过小程序多端框架,让一个小程序同时运行在iOS、安卓、鸿蒙以及PC客户端,实现一次开发多端运行的效果
android·ios·小程序
一笑的小酒馆3 小时前
Androidiot扫地机器人设备详情页地图加载慢卡顿优化方案
android
xixiaoyunya3 小时前
安卓远程管理电脑/NAS文件:从传统SMB方案到Web化私有云的选型与落地实践
android·电脑
Zhu7588 小时前
在k8s集群部署MySQL单实例,支持多个主流发行版
android·mysql·kubernetes
AFinalStone11 小时前
Android 7系统异常问题排查(四)Framework层(上)—System Server Watchdog
android·系统异常
宝杰X713 小时前
Android Room3 多平台数据库
android·数据库
huibin14785236916 小时前
热缓解学习记录
android·学习
小田的博客19 小时前
SAP MM 供应商银行主数据更新报错!message R1228!
android·java·服务器