iOS BLE 自动重连为什么比想象中复杂
你以为重连就是在
didDisconnect里再调一次connect?真正落地时会撞上一连串问题:要不要重试、重试几次、间隔多久、手动断开别误重连、重连成功后还得恢复订阅。本文拆解 BLE 自动重连的设计难点,并介绍 ArcBLEKit 是如何把这些坑一次性填平的。
一、重连不是"再连一次"那么简单
蓝牙设备断开的场景很多:用户拿着手机走远了、设备进入休眠、信号被干扰。对产品来说,"断开后自动恢复"几乎是刚需——谁也不希望用户手动进设置页重新配对。
但 CoreBluetooth 并没有"自动重连"这个概念。它只给你一个回调:
func centralManager(
_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?
) { }
剩下的全靠自己。于是很多人第一版会写成这样:
func centralManager(
_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral,
error: Error?
) {
central.connect(peripheral) // 断开就重连
}
这段代码的问题显而易见:
- 无限循环 —— 设备关机、彻底离开,
connect失败又会触发didDisconnect(或didFailToConnect),然后再次connect……变成死循环疯狂重连,白白耗电。 - 没有退避 —— 立刻重连、每秒重连,设备还没准备好,只会反复失败。
- 不分场景 —— 用户主动点了"断开",代码还在自动重连,体验是反的。
- 状态是乱的 —— UI 不知道现在是"正在重连"还是"彻底失败",只能靠猜。
所以自动重连的复杂度,集中在策略和状态两个词上,而不是那一个 connect 调用。
二、先回答三个设计问题
2.1 有限重试还是无限重试?
无限重试看似"更鲁棒",实际上只会制造耗电和死循环。合理的设计是有限次重试:给一个最大次数,比如 3 次,超过就宣布失败,让上层决定是提示用户、还是过一会儿再手动触发。
2.2 要不要退避?
要。两次重试之间应该有一段延迟,给设备留出重新广播、重新就绪的时间。常见做法是固定间隔,进阶一点可以做指数退避。
2.3 手动断开和意外断开要不要区分?
必须区分。 用户主动断开的语义是"我不想连了",此时绝不能再自动重连。实现上用一个标志位(比如 manualDisconnectRequested)把两种断开分开处理。
把这三个问题想清楚,自动重连的骨架就有了:
意外断开
→ 结束所有挂起的 GATT 操作(它们已经没意义了)
→ 发布 .disconnected 状态
→ 若策略是 .limited(maxAttempts:delay:)
循环 maxAttempts 次:
sleep(delay)
尝试重连
成功后恢复通知订阅、发布 .ready,结束
全部失败 → 发布 .failed(error),结束会话
否则 → 直接结束会话
三、状态机:让 UI 能跟着动
重连不是一个瞬间动作,而是一段时间跨度内的多个阶段。UI 层需要知道此刻该显示"正在重连"还是"连接失败",所以必须把状态显式地暴露出来,而不是闷头在后台连。
一个清晰的状态集合可以是:
enum ConnectionState {
case disconnected
case connecting
case connected
case discoveringServices
case ready
case reconnecting(attempt: Int) // 第几次重试
case failed(Error)
}
重点在 reconnecting(attempt:)——把"第几次重试"带出来,UI 就能显示"正在重连(2/3)"这样的提示。而 failed 则表示"重试耗尽、彻底失败",是给用户明确反馈的终态。
把状态集合用 AsyncStream 发布,UI 侧就能直接 for await 消费:
for await state in session.connectionStates {
print(state) // .reconnecting(attempt: 2) → .connected → .ready
}
四、重连成功后,还有一堆收尾工作
这是最容易漏的一步。重连成功 ≠ 一切都恢复原样:
- GATT 缓存失效 —— 重连后拿到的是新的连接会话,之前发现的
CBService/CBCharacteristic对象已经不能用了,必须清掉缓存、重新发现。 - 通知订阅丢失 —— 你之前
setNotifyValue(true)的订阅,重连后不会自动恢复,必须重新订阅(这一块我会在系列第 4 篇单独展开)。 - 挂起的操作已失败 —— 断开那一刻还在进行的读写操作,都要以"断开"错误结束,不能继续挂着。
只有把这三件事做完,才谈得上"恢复"。
五、"通过标识符重连"是另一条路
还有一种常见的需求:App 冷启动后,凭用户上次选过的设备直接重连,而不是重新扫描一遍。这时用 CBPeripheral.identifier:
let savedID = device.id // 用户选择设备后持久化
之后重连时,先问 CoreBluetooth 能不能直接找回这个外设,找不回再回退到扫描:
let session = try await client.reconnect(
identifier: savedID,
fallbackScan: ScanFilter(serviceUUIDs: [CBUUID(string: "FFF0")]),
options: ConnectionOptions(timeout: 10)
)
注意一个容易误解的点:CBPeripheral.identifier 是 CoreBluetooth 针对当前 App 和当前 Apple 设备分配的标识,不是全局的 BLE MAC 地址,换设备、换 App 都会变。
六、ArcBLEKit 里的实现
上面的骨架、状态机、收尾工作,ArcBLEKit 都内建了。你只需要在连接时声明策略:
let session = try await client.connect(
to: device,
options: ConnectionOptions(
timeout: 10,
autoReconnect: .limited(maxAttempts: 3, delay: 1)
)
)
AutoReconnectPolicy 只有两种取值,把策略约束得很死:
public enum AutoReconnectPolicy {
case disabled // 意外断开后直接结束会话
case limited(maxAttempts: Int, delay: TimeInterval) // 有限次重试,每次间隔 delay 秒
}
maxAttempts是有限次,杜绝死循环;delay提供退避;- 手动
session.disconnect()明确不会触发重连(manualDisconnectRequested标志); - 断开瞬间,所有挂起的 GATT 操作统一以
disconnected错误结束; - 重连成功后清空 GATT 缓存、恢复通知订阅,最后才发布
.ready。
状态全程通过 connectionStates 暴露,UI 直接订阅:
for await state in session.connectionStates {
switch state {
case .reconnecting(let attempt): print("正在重连 \(attempt)/3")
case .ready: print("已恢复")
case .failed(let error): print("重连失败:\(error)")
default: break
}
}
七、小结
BLE 自动重连的复杂不在于那一个 connect 调用,而在于一套策略 + 状态 + 收尾的组合拳:
- 有限重试 + 退避,避免死循环和疯狂耗电;
- 区分手动/意外断开,别在用户想断开时硬连;
- 显式状态机,让 UI 能感知"重连中 / 成功 / 失败";
- 重连成功后做收尾,清缓存、恢复订阅、结束挂起操作。
这些正是 ArcBLEKit 已经替你封装好的东西。下一篇我会讲另一个高频痛点——CoreBluetooth 的超时与 Task Cancellation,这也是上述"结束挂起操作"能正确工作的底层机制。
本系列:
- 用 AsyncThrowingStream 封装 CoreBluetooth 扫描
- iOS BLE 自动重连为什么比想象中复杂(本文)
- 如何正确处理 CoreBluetooth 超时与 Task Cancellation
- BLE 重连后如何恢复 Notification
- writeWithoutResponse 的背压处理
📦 ArcBLEKit on GitHub · 📖 API 文档