摘要:Android BLE 开发中,writeCharacteristic 的异步回调机制常导致“请求 - 响应”匹配困难,尤其在 OTA 升级等高并发场景下,极易出现回调错乱、许可丢失、队列卡死等问题。本文结合真实项目经验,深入剖析 GATT 回调的本质陷阱,并分享一套基于 Kotlin Coroutines + 单 In-Flight 模型 + 幂等完成机制 的高可靠解决方案,最终实现 99.5%+ 的 OTA 升级成功率。
一、背景:为什么 BLE 回调这么难搞?
在 IoT 设备(如智能音响、手环)的固件升级(OTA)场景中,我们需要通过 BLE GATT 协议将几百 KB 甚至几 MB 的固件分包发送给设备。
理想流程很简单:
- 发送数据包 A
- 收到
onCharacteristicWrite成功回调 - 发送数据包 B
- ...
但在 Android 实际开发中,这个流程充满了“坑”:
🔴 核心痛点
- 回调无 ID:
onCharacteristicWrite只返回BluetoothGattCharacteristic对象,不携带请求 ID。如果你并发发送了多个包,根本不知道哪个回调对应哪次发送。 - 广播式回调:某些厂商的 ROM 或蓝牙栈实现中,
WM_GATT_WRITE_SUCCESS是广播消息,可能被多次触发,导致重复处理。 - 写失败静默:
gatt.writeCharacteristic()返回false时,往往没有明确错误码,且不会触发回调,导致发送端永久等待。 - 断线资源泄漏:设备突然断开时,如果正在进行的写入任务未清理,重连后信号量(Semaphore)无法释放,导致队列永久卡死。
💡 结论:在 Android BLE 栈上,绝对不能依赖“并发写入 + 回调匹配” 。必须设计一套严格的流控与状态管理机制。
二、深度剖析:GATT 回调的本质
Android 的 BLE 协议栈(Bluedroid/Fluoride)通过 Binder 机制与 App 通信。当你调用 writeCharacteristic 时:
- App 层将数据写入本地 Characteristic 对象。
- 通过 Binder 调用底层
writeCmd。 - 底层通过 HCI 命令发送给蓝牙控制器。
- 关键点:App 层立刻返回(同步),但真正的“写入成功”要等到控制器回复
HCI_Command_Complete或HCI_LE_Event后,系统才会通过 Handler 发送MESSAGE_GATT_WRITE_DONE,最终触发onCharacteristicWrite。
这就导致了时间差(Time Gap) :
- 如果你在收到回调前,又发起了一次写入,底层队列可能会堆积。
- 如果两次写入间隔太短(<10ms),部分蓝牙芯片会直接丢弃第二包,或者返回
false。 - 如果此时设备进入“忙”状态(BACK_PULL),它会暂时不回复 ACK,导致 App 端超时。
三、解决方案:高可靠 OTA 架构设计
基于上述分析,我在一个线上项目中设计了一套 OtaBleServiceOptimized 引擎。核心设计思想只有三条:
1. 严格单 In-Flight 模型(Single In-Flight)
原则:同一时刻,BLE 栈中只能有一个未完成的写入请求。
// 核心配置:强制并发数为 1
private const val MAX_CONCURRENT_WRITES = 1
private val flowControlSemaphore = Semaphore(MAX_CONCURRENT_WRITES)
为什么必须是 1?
因为 Android 回调不带 ID。如果允许并发 2,当收到回调时,你无法区分是“包 A”成功了还是“包 B”成功了。
通过 Semaphore(1),我们强行将并发转为串行:只有上一包确认成功(或超时),才允许下一包入栈。
2. 幂等完成机制(Idempotent Completion)
问题:onWriteSuccess 可能因系统广播机制被触发多次,或者超时任务与正常回调同时发生。
对策:引入 writeId 和 inFlightCompleted 标志位,确保每一笔写入只被完成一次。
@Volatile private var inFlightWriteId: Int = 0
@Volatile private var inFlightCompleted: Boolean = true
private fun handleWriteDoneInternal(from: String, success: Boolean, expectedWriteId: Int?) {
synchronized(inFlightLock) {
val currentWriteId = inFlightWriteId
// 1. 忽略过期的超时任务(Stale Timeout)
if (expectedWriteId != null && expectedWriteId != currentWriteId) {
return
}
// 2. 幂等检查:如果已经处理过,直接忽略
if (inFlightCompleted) {
TLog.w(TAG, "Duplicate completion ignored from $from")
return
}
// 3. 标记完成
inFlightCompleted = true
inFlightTimeoutJob?.cancel()
}
// 4. 释放信号量,驱动队列继续
flowControlSemaphore.release()
}
价值:无论回调来多少次,无论超时是否触发,信号量只释放一次,彻底杜绝了“多发少收”导致的队列积压。
3. 自适应流控与断线清理
A. 动态发送间隔
不同蓝牙芯片的处理能力不同。硬编码 delay(20ms) 要么太慢(效率低),要么太快(丢包)。
我们实现了自适应算法:
- 成功:逐步减小间隔(15ms → 2ms),逼近设备极限。
- 失败/BACK_PULL:指数退避,增大间隔,给设备喘息时间。
B. 断线同步清理(关键!)
很多 OTA 卡死发生在重连后。原因是断线时,Semaphore 被占用,但回调永远不会来了。
必须在 onDisConnected 中暴力重置:
private fun cleanupOnDisconnect() {
synchronized(inFlightLock) {
inFlightTimeoutJob?.cancel()
inFlightCompleted = true // 强制标记完成
}
// 暴力恢复信号量到满值
val permitsToRelease = MAX_CONCURRENT_WRITES - flowControlSemaphore.availablePermits
repeat(permitsToRelease) { flowControlSemaphore.release() }
// 清空待发送队列,防止脏数据污染新会话
clearPendingWrites()
}
四、架构全景图
整个数据流如下所示:
五、实战成果
这套架构在一个线上项目中落地,取得了显著效果:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| OTA 成功率 | 82% | 99.5%+ | ⬆️ 21% |
| 平均升级耗时 | 180s | 120s | ⬇️ 33% |
| 卡死复现率 | 高频(弱网必现) | 0 | ✅ 彻底解决 |
| 累计升级设备 | - | 10万+ 台 | 零重大事故 |
六、总结与建议
Android BLE 开发不是简单的 API 调用,而是一场与异步、并发、硬件不确定性的博弈。
给同行的 3 条建议:
- 不要信任并发:除非你有绝对的把握处理回调匹配,否则坚持 Single In-Flight。
- 防御性编程:假设回调会重复、会丢失、会迟到。用 ID + 状态机 + 超时 构建铁桶阵。
- 关注断线场景:90% 的疑难杂症都发生在重连瞬间。务必在
onDisconnected中做彻底的资源清洗。