iOS BLE 自动重连为什么比想象中复杂

34 阅读6分钟

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) // 断开就重连
}

这段代码的问题显而易见:

  1. 无限循环 —— 设备关机、彻底离开,connect 失败又会触发 didDisconnect(或 didFailToConnect),然后再次 connect……变成死循环疯狂重连,白白耗电。
  2. 没有退避 —— 立刻重连、每秒重连,设备还没准备好,只会反复失败。
  3. 不分场景 —— 用户主动点了"断开",代码还在自动重连,体验是反的。
  4. 状态是乱的 —— 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
}

四、重连成功后,还有一堆收尾工作

这是最容易漏的一步。重连成功 ≠ 一切都恢复原样:

  1. GATT 缓存失效 —— 重连后拿到的是新的连接会话,之前发现的 CBService / CBCharacteristic 对象已经不能用了,必须清掉缓存、重新发现。
  2. 通知订阅丢失 —— 你之前 setNotifyValue(true) 的订阅,重连后不会自动恢复,必须重新订阅(这一块我会在系列第 4 篇单独展开)。
  3. 挂起的操作已失败 —— 断开那一刻还在进行的读写操作,都要以"断开"错误结束,不能继续挂着。

只有把这三件事做完,才谈得上"恢复"。


五、"通过标识符重连"是另一条路

还有一种常见的需求: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 调用,而在于一套策略 + 状态 + 收尾的组合拳:

  1. 有限重试 + 退避,避免死循环和疯狂耗电;
  2. 区分手动/意外断开,别在用户想断开时硬连;
  3. 显式状态机,让 UI 能感知"重连中 / 成功 / 失败";
  4. 重连成功后做收尾,清缓存、恢复订阅、结束挂起操作。

这些正是 ArcBLEKit 已经替你封装好的东西。下一篇我会讲另一个高频痛点——CoreBluetooth 的超时与 Task Cancellation,这也是上述"结束挂起操作"能正确工作的底层机制。

本系列:

  1. 用 AsyncThrowingStream 封装 CoreBluetooth 扫描
  2. iOS BLE 自动重连为什么比想象中复杂(本文)
  3. 如何正确处理 CoreBluetooth 超时与 Task Cancellation
  4. BLE 重连后如何恢复 Notification
  5. writeWithoutResponse 的背压处理

📦 ArcBLEKit on GitHub · 📖 API 文档