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":要真正停掉底层操作

很多人在 withTaskCancellationHandleronCancel 里只做一件事——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 一次。