Swift CoreBluetooth async/await:用 AsyncThrowingStream 封装 BLE 扫描
用
CBCentralManager的 Delegate 回调写扫描,代码散、难取消、错误还静默吞掉。本文用 Swift Concurrency 的AsyncThrowingStream把它改造成一条可以for await、可以取消、可以throw的异步流,并在最后介绍一个把这套方案做完整的开源库 ArcBLEKit。
一、为什么 CoreBluetooth 扫描让人难受
如果你写过 iOS 的蓝牙中心设备(Central)应用,一定对下面这套流程不陌生:创建一个 CBCentralManager,实现一堆 Delegate 方法,在 didDiscover 里过滤广播包,再想办法把结果送回 UI 层。
问题不在于"能不能写出来",而在于写出来的代码:
- 难以组合 —— 回调套回调,扫描结果和后续的"连接 → 读特征 → 订阅通知"这条链路,没法用自然的顺序表达,只能靠状态机硬拼。
- 难以取消 —— 忘记调
stopScan()会一直耗电;界面退出、任务超时、用户点了返回,都要手动在每个角落补一句清理。 - 错误被静默吞掉 —— 蓝牙被关闭了、权限被拒了、设备不支持了,扫描往往只是"一个结果都没有",而不是告诉你为什么。排查线上问题只能靠猜。
这三个痛点,正是开发者需要 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。
它做的事情,就是把刚才讨论的每个坑都系统地填平:
| 能力 | 直接写 CoreBluetooth | ArcBLEKit |
|---|---|---|
| 扫描 | 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 即可。