BLE 重连后如何恢复 Notification:一个最容易丢数据的坑
你订阅了设备的 Notify,设备断开、自动重连成功后,却发现再也收不到任何数据了——因为 CoreBluetooth 不会替你恢复订阅。本文讲清楚通知订阅的"每次连接"本质、为什么重连后会静默丢失,以及如何正确地恢复它。方案在 ArcBLEKit 里已开箱即用。
一、症状:重连成功了,通知却"挂了"
这是 BLE 应用里一个非常隐蔽的 bug:
- 用户连上设备,订阅了某个特征的 Notify,数据流得好好的;
- 用户走远,连接断开,你的自动重连逻辑生效,连接又回来了;
- 但通知数据再也没有更新过——界面上静止,仿佛设备没发数据。
连接明明是 connected,读特征也正常,唯独通知不来了。新手排查半天,最后才发现:订阅丢了。
二、根因:Notify 状态是"每次连接"的,不是持久的
关键在于理解 CoreBluetooth 的语义。setNotifyValue(_:for:) 订阅的是当前这次连接里的那个特征:
peripheral.setNotifyValue(true, for: characteristic)
这个 characteristic 对象,是在这次连接下通过 discoverCharacteristics 拿到的。一旦连接断开、重新建立,新连接是全新的会话:
- 旧的
CBPeripheral关系变了; - 旧的
CBService/CBCharacteristic对象已经失效,不能再用; - 更关键的是,CoreBluetooth 不会记住你之前订阅过什么,不会自动帮你重新
setNotifyValue。
所以"重连成功后通知还该继续来"这件事,完全要由应用自己负责:重新发现服务/特征,再重新订阅。忘了这步,就是上面那个"通知死了"的症状。
三、天真的做法,通常有两类坑
坑一:在断开回调里记下一堆"待重订"清单,重连后手工恢复。
思路没错,但很容易漏或错:
// 断开时
var pendingNotifications: [CBCharacteristic] = subscribedCharacteristics
// 重连成功后
for characteristic in pendingNotifications {
peripheral.setNotifyValue(true, for: characteristic) // ❌ 用的是旧连接的对象!
}
subscribedCharacteristics 里存的是旧连接的 CBCharacteristic,重连后已经失效。正确的顺序是:先重新 discoverServices / discoverCharacteristics 拿到新连接的特征对象,再 setNotifyValue。跳过"重新发现"这一步,是这类 bug 最常见的根源。
坑二:多个消费者订阅同一个特征,恢复逻辑相互打架。
如果界面上有两个地方同时订阅了同一个特征的 Notify,它们各自维护"要不要恢复"的状态,就会出现:一个退出导致另一个的订阅被误关,或者重连时重复订阅、竞态更新。正确的做法是把订阅集中管理,多个消费者共享同一个底层订阅。
四、正确的恢复流程
把上面两坑填平,恢复通知的正确流程是:
重连成功
→ 清空 GATT 缓存(旧 CBService/CBCharacteristic 已失效)
→ 遍历"当前活跃的订阅集合"(以 serviceUUID + characteristicUUID 为 key)
对每个订阅:
重新发现服务/特征(拿到新连接下的特征对象)
重新 setNotifyValue(true)
更新注册表里缓存的特性对象
→ 全部恢复成功,才发布 .ready
几个要点:
- 用 (serviceUUID, characteristicUUID) 作为订阅的唯一 key,而不是用
CBCharacteristic对象引用——因为对象会随连接失效,UUID 才是稳定的标识。 - 先清缓存、再发现——重连后必须拿到新对象。
- 订阅要集中注册,多个消费者共享底层订阅,恢复时按"活跃订阅集合"统一处理,而不是散落在各处的逻辑。
- 恢复失败要处理——如果重新订阅失败了,不能假装成功,要么继续重试,要么以失败结束会话。
五、另一个细节:didUpdateValueFor 是"双用途"的
这里顺带提一个容易混淆的点:didUpdateValueFor 这个回调既承担"读操作的结果返回",也承担"通知数据的推送"——同一个回调,两种用途。
所以订阅恢复后,数据到了要能正确地"分流":
- 如果这次更新是某个
read操作在等的 → 完成那个 read 的 continuation; - 如果这个特征有活跃的通知订阅 → 把数据
yield给所有订阅者。
两条路互不干扰,但必须在同一个回调入口里同时处理,否则要么 read 挂死,要么通知漏发。
六、ArcBLEKit 里的实现
ArcBLEKit 把上面的机制完整内建了。订阅通知时,返回的是一条异步流:
let updates = try await session.notifications(
for: CBUUID(string: "FFF3"),
service: CBUUID(string: "FFF0")
)
for try await data in updates {
print(data) // 重连成功后,这里会自动继续收到数据
}
它在底层做的事,正好对应上面的流程:
- 订阅按 (serviceUUID, characteristicUUID) 注册,不依赖旧连接的对象引用;
- 多个消费者共享同一个底层订阅——两个地方
notifications(for:service:)同一个特征,只会发起一次setNotifyValue,数据yield给所有人;最后一个订阅者退出时才setNotifyValue(false); - 重连成功后自动恢复:清空 GATT 缓存 → 遍历活跃订阅 → 重新发现 + 重新订阅 → 全部成功才发
.ready; didUpdateValueFor双用途正确分流:既完成正在等待的read,又把数据推给通知订阅者;- 恢复失败时不会静默,而是走重连策略的失败路径(重试或结束会话)。
针对一些不规范的固件,还提供了兼容选项:
let updates = try await session.notifications(
for: CBUUID(string: "FFF3"),
service: CBUUID(string: "FFF0"),
allowUnsupportedProperties: true, // 特征没声明 .notify/.indicate 也尝试订阅
discoveryMode: .all // 用传统 discoverServices(nil) 全量发现
)
有些老固件能发通知却不声明 .notify 属性,或要求全量发现才能工作;默认的严格模式会直接拒绝这类订阅,显式开启兼容选项即可。符合规范的设备,保持默认即可。
七、小结
"重连后恢复 Notification"这个坑,根子在两点:
- Notify 是"每次连接"的状态,重连后 CoreBluetooth 不会自动恢复;
- 恢复时容易用错对象(旧连接的
CBCharacteristic)或漏掉重新发现。
正确做法是:以 UUID 为 key 集中管理订阅、重连后清缓存重新发现、统一恢复、正确分流 didUpdateValueFor、并处理恢复失败。ArcBLEKit 把这些都做进了 notifications(for:service:) 里,你只需要关心 for try await data in updates 这一行。
下一篇是本系列的最后一篇,讲一个更底层的性能坑:writeWithoutResponse 的背压处理——连续写快了,数据是会丢的。
本系列:
- 用 AsyncThrowingStream 封装 CoreBluetooth 扫描
- iOS BLE 自动重连为什么比想象中复杂
- 如何正确处理 CoreBluetooth 超时与 Task Cancellation
- BLE 重连后如何恢复 Notification(本文)
- writeWithoutResponse 的背压处理
📦 ArcBLEKit on GitHub · 📖 API 文档