Swift CoreBluetooth async/await:用 AsyncThrowingStream 封装 BLE 扫描

0 阅读8分钟

Swift CoreBluetooth async/await:用 AsyncThrowingStream 封装 BLE 扫描

用 CBCentralManager 的 Delegate 回调写扫描,代码散、难取消、错误还静默吞掉。本文用 Swift Concurrency 的 AsyncThrowingStream 把它改造成一条可以 for await、可以取消、可以 throw 的异步流,并在最后介绍一个把这套方案做完整的开源库 ArcBLEKit。


一、为什么 CoreBluetooth 扫描让人难受

如果你写过 iOS 的蓝牙中心设备(Central)应用,一定对下面这套流程不陌生:创建一个 CBCentralManager,实现一堆 Delegate 方法,在 didDiscover 里过滤广播包,再想办法把结果送回 UI 层。

问题不在于"能不能写出来",而在于写出来的代码:

  1. 难以组合 —— 回调套回调,扫描结果和后续的"连接 → 读特征 → 订阅通知"这条链路,没法用自然的顺序表达,只能靠状态机硬拼。
  2. 难以取消 —— 忘记调 stopScan() 会一直耗电;界面退出、任务超时、用户点了返回,都要手动在每个角落补一句清理。
  3. 错误被静默吞掉 —— 蓝牙被关闭了、权限被拒了、设备不支持了,扫描往往只是"一个结果都没有",而不是告诉你为什么。排查线上问题只能靠猜。

这三个痛点,正是开发者需要 Swift CoreBluetooth async/await 的原因——大家想要的不是又一个新的回调封装,而是一套用 Swift Concurrency 重写过的、可组合、可取消、错误明确的 API。


二、传统 Delegate 写法长什么样

先看一段典型的原始写法:

final class Scanner: NSObject, CBCentralManagerDelegate {
    private var central: CBCentralManager!
    private var onDeviceFound: ((CBPeripheral, Int) -> Void)?
    private var isScanning = false

    func start(onDeviceFound: @escaping (CBPeripheral, Int) -> Void) {
        self.onDeviceFound = onDeviceFound
        central = CBCentralManager(delegate: self, queue: .main)
    }

    func stop() {
        central.stopScan()
        isScanning = false
    }

    func centralManagerDidUpdateState(_ central: CBCentralManager) {
        guard central.state == .poweredOn else { return }
        central.scanForPeripherals(withServices: nil, options: nil)
        isScanning = true
    }

    func centralManager(
        _ central: CBCentralManager,
        didDiscover peripheral: CBPeripheral,
        advertisementData: [String: Any],
        rssi RSSI: NSNumber
    ) {
        onDeviceFound?(peripheral, RSSI.intValue)
    }
}

这只是"扫描"一个动作。真实业务里,你还需要处理:蓝牙状态在 .unknown / .poweredOff / .unauthorized 之间跳变、扫描超时、去重、以及"扫描到一半用户退出了页面"的清理。这些逻辑一旦分散在各个 Delegate 方法里,就会慢慢长成谁都看不懂的一团迷雾。


三、我们想要的 API

理想形态其实很简单——一条异步流:

for try await device in client.scan(filter: filter) {
    print(device.name ?? "Unknown", device.rssi)
}
  • 用 for await 顺序消费扫描结果;
  • 停止迭代、或者取消外层 Task,扫描就自动停止(不用手动 stopScan);
  • 蓝牙关闭、未授权、不可用,都能作为错误抛出来,而不是静默结束。

幸运的是,Swift 5.5 之后标准库给了我们现成的工具:AsyncThrowingStream。


四、核心武器:AsyncThrowingStream

AsyncThrowingStream 是生产者和消费者之间的一座桥梁:

  • 生产者拿到一个 Continuation,通过 yield(_:) 往里送值;
  • 消费者用 for try await 往外读值;
  • 出错时,生产者调 finish(throwing:) 把错误传给消费者;
  • 消费者停止迭代(break、return)或任务被取消时,onTermination 回调被触发,用来做清理。

把 CoreBluetooth 扫描包进去,最小实现长这样:

import CoreBluetooth

struct BLEDevice {
    let id: UUID
    let name: String?
    let rssi: Int
}

func scanForDevices() -> AsyncThrowingStream<BLEDevice, Error> {
    AsyncThrowingStream { continuation in
        // 1. 前置校验:蓝牙不可用就直接抛错
        // 2. 设置 central delegate,didDiscover 里 yield
        // 3. continuation.onTermination 里 stopScan()
    }
}

AsyncThrowingStream 的妙处在于:取消和清理是免费的。只要把 stopScan() 挂在 onTermination 里,那么无论消费者是 break、return,还是外层 Task 被取消,系统都会帮你触发清理。这就解决了痛点二。

而把蓝牙状态检查放在流的开头、用 finish(throwing:) 报告,就解决了痛点三——错误再也不会被静默吞掉。


五、三个必须处理好的细节

上面是最小形态,但真要上生产,有三个细节躲不过去。

5.1 生命周期:停止扫描是唯一的清理钩子

AsyncThrowingStream 只有 onTermination 这一个清理时机,所以要把它用足。扫描开始后,如果消费者中途退出,必须在这里调 stopScan(),否则 CoreBluetooth 会一直扫描。

continuation.onTermination = { _ in
    central.stopScan()
    central.delegate = nil
}

5.2 错误传播:不要静默结束

蓝牙状态不对时,宁可让流立刻抛错,也不要让消费者看到"空流"然后自己猜原因:

switch central.state {
case .poweredOn:  break
case .unauthorized: continuation.finish(throwing: BLEError.bluetoothUnauthorized); return
case .poweredOff:   continuation.finish(throwing: BLEError.bluetoothPoweredOff);   return
default:            continuation.finish(throwing: BLEError.bluetoothUnavailable); return
}

这样 for try await 那一层就能直接 catch 到明确的原因,日志和用户提示都有据可依。

5.3 任务取消与超时:让"找第一个设备"也变成 async 操作

扫描是无尽的流,但业务里最常见的其实是"在 N 秒内找到第一个符合条件的设备"——一个典型的一次性 async 操作。把"流 + 超时 + 取消"三者拼起来,才是完整答案:

func findDevice(timeout: TimeInterval) async throws -> BLEDevice {
    try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { continuation in
            // 三个来源抢着 resume 同一个 continuation:
            // ① 扫描到设备 → resume(returning:)
            // ② 超时 Task 触发 → resume(throwing: .scanTimedOut)
            // ③ 任务取消       → resume(throwing: .operationCancelled)
            // 必须用一个锁 + completed 标志位保证只 resume 一次
        }
    } onCancel: {
        // 取消时停止扫描
    }
}

这里的关键在于竞态:设备发现、超时、任务取消,三个事件几乎同时到达,而 CheckedContinuation 只允许 resume 一次。用一把锁加一个 completed 布尔值做互斥,谁先到谁生效,其余的静默忽略。这也是很多人第一次用 withCheckedThrowingContinuation 时踩的坑——重复 resume 会导致崩溃。

到这里,扫描这一个动作才算是被"封装干净"了。


六、冰山之下:扫描只是第一关

当你把扫描封装完,很快会发现真正的麻烦在后面:

  • 连接超时 —— didConnect 可能永远不来,需要超时 + 取消;
  • 断开自动重连 —— 用户走远又走回来,要不要重试?重试几次?间隔多久?
  • GATT 读写超时 —— didUpdateValueFor 不回调,整个操作就挂死了;
  • 重连后恢复通知 —— 重连成功后,之前订阅的 Notification 得重新 setNotifyValue;
  • withoutResponse 背压 —— 连续写入时,要等 peripheralIsReady(toSendWriteWithoutResponse:) 就绪,否则丢包。

每一个都是独立的坑,也都值得单独写一篇。扫描,只是这趟旅程的入口。


七、ArcBLEKit:把整套方案做扎实的开源库

上面这一整套设计,我自己在多个蓝牙项目里反复踩坑后,沉淀成了一个零第三方依赖的 Swift Package:ArcBLEKit。

它做的事情,就是把刚才讨论的每个坑都系统地填平:

能力直接写 CoreBluetoothArcBLEKit
扫描Delegate 回调AsyncThrowingStream<BLEDevice, Error>
读写手动协调 Delegate 状态可取消的 async throws 操作
超时应用自行管理内置于连接和 GATT 选项
重连应用自行管理有限重试策略 + 状态更新
重连后的通知手动重新订阅自动恢复活跃订阅
无响应写入手动跟踪就绪回调自动处理背压

还是回到扫描这个入口,看看它长什么样:

import ArcBLEKit
import CoreBluetooth

let client = BLEClient()
let service = CBUUID(string: "FFF0")

// 等蓝牙就绪(自带超时,状态不对会抛错)
try await client.waitUntilReady(timeout: 10)

// 扫描:取消任务或停止迭代,就自动 stopScan
for try await device in client.scan(
    filter: ScanFilter(serviceUUIDs: [service])
) {
    print(device.name ?? "Unknown", device.id, device.rssi)
}

而"找第一个设备并连接"这种一次性操作,也变成了两个自然的 await:

let device = try await client.findDevice(
    matching: ScanFilter(serviceUUIDs: [service]),
    timeout: 10
)

let session = try await client.connect(
    to: device,
    options: ConnectionOptions(
        timeout: 10,
        autoReconnect: .limited(maxAttempts: 3, delay: 1)
    )
)

连接之后读一个特征、订阅一个通知,同样是 async throws 和异步流:

let value = try await session.read(
    characteristic: CBUUID(string: "FFF1"),
    service: service,
    options: GATTOperationOptions(timeout: 10)
)

let updates = try await session.notifications(
    for: CBUUID(string: "FFF3"),
    service: service
)

for try await data in updates {
    print(data)
}

连接状态也可以通过 AsyncStream 订阅,UI 层可以天然地响应重连等生命周期事件:

for await state in session.connectionStates {
    print("Connection:", state) // .connected / .reconnecting(attempt: 1) / .ready ...
}

ArcBLEKit 的设计原则只有一条:让 BLE 的操作像普通网络请求一样,顺序写、可取消、有超时、错误明确。

  • 扫描和通知用异步流,连接和 GATT 操作用 async throws;
  • 所有操作都支持任务取消、强制执行超时,断开时结束挂起的任务;
  • 自动重连是有限次数的重试,且重连成功后会自动恢复活跃的通知订阅;
  • .withoutResponse 写入会在必要时等待背压解除,避免丢包;
  • 无第三方依赖,支持 iOS 14+、macOS 11+、Swift 5.5+。

八、安装与上手

在 Xcode 中 File > Add Package Dependencies 输入:

https://github.com/ilawsonlu/ArcBLEKit.git

或者在 Package.swift 中声明:

dependencies: [
    .package(
        url: "https://github.com/ilawsonlu/ArcBLEKit.git",
        from: "0.2.2"
    )
]

记得在 App 的 Info.plist 加上蓝牙权限说明:

<key>NSBluetoothAlwaysUsageDescription</key>
<string>此 App 使用蓝牙连接附近的低功耗蓝牙设备。</string>

九、写在最后

本文想解决的是扫描这个入口——用 AsyncThrowingStream 把回调改造成可组合、可取消、错误明确的异步流。

但这只是第一关。接下来我还会围绕连接超时与 Task Cancellation、自动重连、重连后恢复 Notification、withoutResponse 背压这几个真实痛点分别展开——而这些,正是 ArcBLEKit 已经替你填平的地方。

如果你也被 CoreBluetooth 的 Delegate 折腾过,欢迎:

有 Bug、建议或想一起完善文档,直接提 Issue / PR 即可。