AndroidBLE双MTU回调与GATT时序竞态排查
Android BLE 扫得到却连不稳,MIUI 双 MTU 回调引发的 GATT 时序竞态

这次故障很会迷惑人。
扫描页面已经找到了目标 RF-B-SRx Beacon,MAC、广播名称、Major 和 Minor 都能正常显示。按界面给出的信息看,手机和设备之间应该已经有了联系。可页面里的 System ID 一直停在“等待连接读取”,继续进入配置页面,楼层和房间号虽然带过来了,设备当前参数仍然读不到。
当时统一收到的错误只有 BLE_GATT_SESSION_FAILED。这个错误能说明 GATT 初始化没有走完,没法告诉我们究竟停在连接、MTU、服务发现、通知订阅还是特征读取。
问题最后落在一个很短的时间窗口里。小米手机的蓝牙栈连续回调了两次 MTU,先是 250,大约 684 毫秒后又回调 247。原来的代码拿到第一次成功回调便继续发现服务,第二次 MTU 回调随即插进来,后续服务发现和特征读取的时序被打乱。
扫描成功和 GATT 可用之间,原来还隔着这么一段路。
广播能读到,不能说明 GATT 已经准备好
BLE 扫描读取的是设备广播。这个项目会解析 RF-B-SRx 的 0x1803 Service Data,从中拿到 MAC、Major、Minor、电量等信息。这些数据不要求 App 建立完整 GATT 会话。
System ID 走的是另一套流程。App 要先连接设备,完成 MTU 交换,发现服务,订阅 AT 通知,再读取设备信息特征。后面的配置读取还要通过 UART 特征发送 AT+BEACON?、AT+AUTH? 等命令,并从 Notify 回包里拼出完整响应。
所以页面能稳定看到广播数据,只能证明扫描流程工作正常。System ID 一直等待,排查重点应该马上转到 GATT 初始化阶段。
原来的流程很直。
connect(macAddress)
requestMtu(247)
discoverServices()
subscribe(atNotifyUuid)
read(systemIdUuid)
这段顺序本身没有问题。麻烦出在每个调用背后都是异步回调,调用返回成功,也未必意味着设备端和手机蓝牙栈都已经稳定下来。
回调竞态到底是什么
先把“回调”说清楚。App 调用 requestMtu(247) 时,只是把请求交给 Android 蓝牙栈。函数很快返回,MTU 交换可能仍在进行。系统处理完后,才通过 onMtuChanged() 把结果送回来。这个稍后由系统触发的函数就是回调。
“竞态”指的是程序结果开始依赖事件的到达顺序。两个回调都可能合法,单看任何一个也没有报错。可它们先后落到同一条状态流程时,先到的回调可能已经启动下一阶段,后到的回调又改变了这段会话的状态。顺序一换,结果也可能跟着变化。
这里发生的是异步回调的时序竞态。它不要求两个线程在同一瞬间修改同一个变量。多个回调只要会推进同一条 GATT 状态机,而代码没有限制每个回调在哪个阶段仍然有效,就足以造成竞态。
放到这次故障里看,250 回调先把流程推进到服务发现,247 回调又在服务发现尚未稳定时插入。两次回调的状态都是成功,组合起来的时序却让后续读取和写入失去可靠顺序。
用一段简化代码看会更直观。旧逻辑对每个成功的 MTU 回调都推进状态,第二个回调到达时便可能重复启动服务发现。
// 记录当前正在执行 MTU 阶段
private var stage = GattStage.MTU
// 系统稍后携带 250 或 247 异步调用这里
fun onMtuChanged(mtu: Int, status: Int) {
// 失败回调不再推进流程
if (status != BluetoothGatt.GATT_SUCCESS) return
// 每个成功回调都会把状态推进到服务发现
stage = GattStage.DISCOVER_SERVICES
// 250 会调用一次,247 到达后又可能调用一次
discoverServices()
}
// 结束本次 MTU 回调处理
加入阶段守卫后,第一个成功回调会先把状态切到稳定等待。后到的回调看到阶段已经变化,便不会再次启动服务发现。
// 当前只允许 MTU 阶段接收一次成功结果
private var stage = GattStage.MTU
// 系统稍后携带协商结果异步调用这里
fun onMtuChanged(mtu: Int, status: Int) {
// 失败回调不改变当前阶段
if (status != BluetoothGatt.GATT_SUCCESS) return
// 忽略已经离开 MTU 阶段后到达的重复回调
if (stage != GattStage.MTU) return
// 先切换阶段,阻止第二个回调重复进入
stage = GattStage.MTU_SETTLING
// 在协程中等待设备端和手机蓝牙栈稳定
launch {
// 覆盖真机上约 684 毫秒的第二次回调窗口
delay(750L)
// 稳定后再进入服务发现阶段
stage = GattStage.DISCOVER_SERVICES
// 整条连接流程只启动一次服务发现
discoverServices()
}
// 结束稳定等待协程
}
// 结束本次 MTU 回调处理

两次 MTU 回调挤进了同一条连接流程
真机日志给出的顺序大致如下。
GATT connected
onMtuChanged mtu=250 status=0
discoverServices requested
onMtuChanged mtu=247 status=0
System ID read did not complete
App 请求的 MTU 是 247,第一次却收到了 250。约 684 毫秒后,系统又送来一次 247。旧逻辑只等待第一条匹配的 onMtuChanged,拿到成功状态以后立刻调用 discoverServices()。
这一行为在部分手机上可以工作。在这台 MIUI 设备上,MTU 交换仍有后续状态变化,服务发现发得太早。日志里的连接状态保持成功,页面却拿不到完整设备信息,于是用户看到的就成了一个很别扭的结果,设备明明在附近,App 也看见了它,System ID 仍然显示等待。

继续堆一次普通重试解决不了根因。新连接如果照着相同节奏再跑一遍,竞态还会出现。修复需要把 GATT 初始化拆成几个有明确边界的阶段,并给设备端留下稳定时间。
先让 MTU 稳下来
修复后的 BleGattSession 在 MTU 成功后等待 750 毫秒,再开始服务发现。
val negotiatedMtu = stage(GattStage.MTU) {
adapter.requestMtu(REQUESTED_MTU)
}
if (negotiatedMtu < MINIMUM_MTU) {
throw GattFailure(GattStage.MTU)
}
delay(MTU_SETTLE_DELAY_MS)
requireStage(GattStage.DISCOVER_SERVICES) {
adapter.discoverServices()
}

这里的 750 毫秒来自当前手机和设备的真实回调窗口。日志里第二次 MTU 回调出现在约 684 毫秒后,稳定时间需要覆盖它,同时又不能把现场操作拖得太慢。
延时必须有边界。CONNECT、MTU、服务发现、通知订阅和设备信息读取都保留独立超时,某一阶段失败后会关闭本次原生 GATT 句柄。App 不会因为一次回调缺失永久挂住。
服务发现同时看回调和系统缓存
另一个问题出在 onServicesDiscovered。部分设备上,系统的 BluetoothGatt.services 已经有数据,标准回调却没有按 App 预期到达。只等回调会把已经成功的服务发现误判成超时。
修复后的适配器先检查现有服务,调用 discoverServices() 后继续等待事件。每隔 100 毫秒没有收到事件时,再检查一次 activeGatt.services。
if (activeGatt.services.isNotEmpty()) return true
if (!activeGatt.discoverServices()) return false
while (true) {
when (val event = withTimeoutOrNull(100L) { events.receive() }) {
null -> if (activeGatt.services.isNotEmpty()) return true
is CallbackEvent.Connection -> {
if (event.newState != BluetoothGatt.STATE_CONNECTED) return false
}
is CallbackEvent.ServicesDiscovered -> {
return event.status == BluetoothGatt.GATT_SUCCESS
}
else -> Unit
}
}
外层仍有 10 秒服务发现超时。这样既能接住正常回调,也能兼容系统已经完成服务发现的情况。
Notify 写成功以后再等 500 毫秒
服务找到以后,App 会为 AT UART 特征写入 CCCD,开启 Notify。描述符写入成功只说明订阅请求已经被手机蓝牙栈接受,RF-B-SRx 的通知通道还需要一点初始化时间。
我们给 Notify 订阅和第一次 System ID 读取之间留了 500 毫秒。
requireStage(GattStage.SUBSCRIBE_AT_NOTIFY) {
adapter.subscribe(GattUuids.AT_NOTIFY)
}
delay(SUBSCRIPTION_SETTLE_DELAY_MS)
val systemId = stage(GattStage.READ_DEVICE_INFORMATION) {
adapter.read(GattUuids.SYSTEM_ID)
}
对应单元测试专门模拟了一台“订阅后短时间拒绝第一次 System ID 读取”的设备。测试只有在等待时间达到 500 毫秒后才允许读取成功。MTU 的 750 毫秒和 Notify 的 500 毫秒承担不同职责,不能合并成一个含义含糊的总延时。
重连以前要把旧句柄收干净
Android BLE 里,重试常常会把问题越弄越乱。旧 BluetoothGatt 没有释放,新连接已经建立,回调和特征状态便可能来自不同会话。
当前实现只允许一次受控重连。第一次连接失败后,先执行 close(),等待 500 毫秒,再重新连接。任何阶段抛出异常或超时,最终都会释放当前 GATT 句柄。即使 disconnect() 自己抛异常,close() 也会执行。
RF-B-SRx 还有一个设备侧要求。新的鉴权配置需要等物理连接断开后才能生效。App 调用 disconnect() 后会等待 1000 毫秒,再关闭 GATT 句柄并重新连接。真机日志记录到的间隔约为 1.005 秒,和代码约束一致。

这一步后来直接影响了完整作业流程。App 写完 Beacon 参数并开启鉴权后,需要断开,再用同一项目密码重连验证。旧连接关得太快,设备可能还没来得及切换状态。
AT 命令必须串行
设备信息读取成功以后,配置页还要连续查询名称、广播间隔、加密状态、连接模式、Beacon 参数、功率和鉴权状态。这些命令共用一个 Notify 通道。
项目使用 Mutex 串行执行 AT 命令。每次写入后只等待当前命令的响应,收到 BUSY 时最多重试一次,5 秒没有回包就明确失败。通知数据可能分片到达,AtResponseAssembler 会先把字节拼成完整响应,再交给等待中的命令。
这套约束让 GATT 会话从一串松散回调变成一条可追踪的流程。出现问题时,可以判断失败发生在哪个阶段,也能确定哪个资源应该释放。
真机验证不能停在“写入成功”
修复完成后,我们没有拿一次 onCharacteristicWrite 成功就结束验收。
目标设备是 70:D0:7E:BA:FA:59。App 先读到设备原广播值 0007/0708,随后只把 Major 和 Minor 改为 0009/0901,Company ID、UUID、功率和广播间隔保持原值。
写入过程留下了三份互相独立的证据。
第一份来自特征写入回调,AT 写操作得到确认。第二份来自 AT+BEACON? 写后回读,设备返回 0009/0901。第三份来自断开后的 0x1803 广播复扫,广播中的 Major 和 Minor 同样是 0009/0901。
最后再回到真实 App 页面复扫并连接。扫描页显示新的广播值,点击连接后可以读取 System ID 和完整设备参数,配置页不再出现“等待连接读取”。
整个过程使用 adb install -r 覆盖安装,没有卸载应用,也没有清除数据库和蓝牙权限。定向 JVM 测试及 Debug APK 构建通过,执行命令如下。
./gradlew testDebugUnitTest assembleDebug
git diff --check
完整 App 验收还继续走了写后回读、断开重连鉴权和最终广播复扫。底层连接测试只能证明它实际经过的 GATT 路径,不能代替页面、业务状态机和后端绑定流程。
这次故障留下的几条经验
广播扫描和 GATT 连接应该分开记录。扫描页拿到 MAC 与 Major、Minor 时,只能标记“发现目标设备”。System ID、服务和特征尚未读取,界面文案也应当如实表示当前阶段。
BLE 日志要带时间和阶段。只留一个 BLE_GATT_SESSION_FAILED,排查时只能猜。连接状态、MTU、服务发现、描述符写入、特征读写和断开都应该单独记录,同时对密码和其他敏感字段做脱敏。
固定延时可以用,前提是它对应可重复的设备行为,并由测试固定下来。本文里的 750 毫秒、500 毫秒和 1000 毫秒分别覆盖三个不同窗口。换手机、换固件或调整蓝牙栈以后,这些时间还要重新验证。
重试次数越多,现场状态越难解释。一次受控重连已经足够处理瞬时连接失败。连续自动重连会掩盖资源泄漏,也可能在设备写入一半时引入新的副作用。
硬件写入的成功证据至少要走到写后回读。设备会广播时,再补一次断开复扫。页面显示、写入回调和广播结果能互相对上,才有理由把这次操作交给后面的业务流程。

这次问题最后只增加了几段等待、一个服务发现兜底和一次受控重连。改动不算大,把“已经扫描到”之后的每一个异步阶段重新排一遍,花掉的时间却最多。Android BLE 的很多疑难问题都藏在这些阶段之间。回调都来了,顺序差几百毫秒,结果就会完全不同。