CoreBluetooth 的几乎所有结果都靠回调返回,而回调可能永远不来。没有超时,你的 await read() 会一直挂死;处理不好取消,任务退出了蓝牙还在后台扫。本文讲清楚"回调转 async"里最容易被忽略的两件事:超时和取消,以及它们之间那场你躲不掉的竞态。
一、为什么 CoreBluetooth 特别需要超时
看一组回调:
Swift
func centralManager(_:didConnect:) // 连接成功
func peripheral(_:didDiscoverServices:) // 服务发现完成
func peripheral(_:didDiscoverCharacteristicsFor:) // 特征发现完成
func peripheral(_:didUpdateValueFor:) // 读到值了
func peripheral(_:didWriteValueFor:) // 写完成它们的共同是:没有一个是保证会来的。设备信号差、固件卡死、甚至只是某个外设不按规范回复,对应回调就可能永远不触发。
如果你把这样的回调直接转成 async,就会得到一个"永远挂起"的 await——UI 卡住、内存里的 continuation 悬着、上层逻辑停摆。所以超时不是锦上添花,而是每个操作都必须有的兜底。
二、回调转 async 的起点:withCheckedThrowingContinuation
把回调转成可等待的操作,标准做法是:
Swift
let value = try await withCheckedThrowingContinuation { continuation in
peripheral.readValue(for: characteristic)
// 等 didUpdateValueFor 回调里 continuation.resume(...)
}CheckedContinuation 会在编译期 + 运行期帮你检查"是否漏了 resume"或"重复 resume",是个很好的安全网。
但这里立刻冒出两个坑:如果 didUpdateValueFor 永远不来,谁来 resume?如果外层 Task 被取消了,谁来响应?于是骨架变成了:
Swift
func read(timeout: TimeInterval) async throws -> Data {
try await withTaskCancellationHandler {
try await withCheckedThrowingContinuation { continuation in
// ① 发起操作
// ② 注册超时任务
// ③ 注册取消处理
}
} onCancel: {
// 取消时真正停止底层操作
}
}三、三件事抢着 resume:竞态是核心难点
一个操作从发起到结束,可能有三个来源都想 resume 同一个 continuation:
- 结果到了 ——
didUpdateValueFor回调 →resume(returning:) - 超时了 —— 你起的
Task.sleep(timeout)到期 →resume(throwing: .timedOut) - 被取消了 —— 外层 Task 取消 →
resume(throwing: .cancelled)
而 CheckedContinuation 有个铁律:只能 resume 一次。重复 resume 会直接崩溃。
解法是用一把锁 + 一个 completed 标志做互斥,谁先到谁生效,其余静默忽略:
Swift
private var completed = false
private let lock = NSLock()
func finish(_ body: () -> Void) {
lock.lock()
guard !completed else { lock.unlock(); return } // 已经有人 resume 过了
completed = true
lock.unlock()
body() // 这里才真正 resume
}三个来源都通过同一个 finish 收口,就能保证无论它们以什么顺序、什么并发度到达,都只有一次 resume 真正执行。
四、取消不只是"不 resume":要真正停掉底层操作
很多人在 withTaskCancellationHandler 的 onCancel 里只做一件事——continuation.resume(throwing: CancellationError())。这能让 await 抛错返回,但底层操作还在跑。
对 CoreBluetooth 来说,"取消"必须落到实处:
| 操作 | 取消时真正该做什么 |
|---|---|
| 扫描 | central.stopScan() |
| 连接 | central.cancelPeripheralConnection(peripheral) |
| 读写/发现 | 结束挂起的 continuation(底层无独立取消 API,靠超时/断开兜底) |
取消的正确姿势是:既让上层 await 抛错,又让底层资源被释放。两者缺一,要么界面退出了蓝牙还在耗电扫描,要么资源泄漏。
另外注意:任务取消抛出来的是 CancellationError,但业务上往往更希望一个统一、可读的错误:
Swift
do { try await client.findDevice(...) }
catch BLEError.operationCancelled { /* 用户取消 */ }
catch BLEError.scanTimedOut { /* 超时 */ }五、等待"就绪"也是一种超时场景
除了单个 GATT 操作,还有一个容易被忽略的超时:等蓝牙本身就绪。CBCentralManager 启动后,状态会经历 .unknown → .poweredOn 的跳变,期间不能发起扫描。
一个优雅的写法是竞速(race):让"状态到达 .poweredOn"和"超时"两个任务赛跑:
Swift
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask { /* 监听 bluetoothStates,等到 .poweredOn 就 return */ }
group.addTask { try await Task.sleep(timeout); throw .readyTimedOut }
defer { group.cancelAll() } // 一方胜出后取消另一方
_ = try await group.next()
}TaskGroup 天然适合这种"N 选一"的竞速:先完成的结果返回,defer { cancelAll() } 负责清理输掉的那一方。
六、ArcBLEKit 怎么落地这一整套
上面这些模式,ArcBLEKit 全部内建了:
Swift
let value = try await session.read(
characteristic: CBUUID(string: "FFF1"),
service: CBUUID(string: "FFF0"),
options: GATTOperationOptions(timeout: 10) // 每个操作自带超时
)一个 GATTOperationOptions(timeout:),把超时绑到了读、写、发现、订阅等所有操作上;而任务取消则贯穿始终——取消扫描会 stopScan,取消连接会 cancelPeripheralConnection。
Swift
public enum BLEError {
case connectionTimedOut(UUID)
case scanTimedOut
case gattOperationTimedOut(GATTOperation, service: CBUUID?, characteristic: CBUUID?)
case operationCancelled
// ...
}gattOperationTimedOut 甚至带上了是哪个操作超时,排查线上问题时不至于对着一个笼统的"超时"发懵。
一个实现细节:CoreBluetooth 的类型大多不是 Sendable,要在 Swift Concurrency 里安全地持有它们,需要 @preconcurrency import CoreBluetooth 加一层 @unchecked Sendable 的包装。
七、小结
"回调转 async"这件事,真正难的不是 withCheckedThrowingContinuation 本身,而是它背后必须补齐的两块:
- 超时 —— 每个操作都要有兜底,否则挂死
- 取消 —— 既要让 await 抛错,又要真正释放底层资源
以及横跨两者之上的那个竞态:结果、超时、取消三个来源抢着 resume,必须用互斥保证只 resume 一次。