如何正确处理 CoreBluetooth 超时与 Task Cancellation

如何正确处理 CoreBluetooth 超时与 Task Cancellation

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 被取消了,谁来响应? 答案是你得再套一层 withTaskCancellationHandler

于是骨架变成了:

swift 复制代码
func read(timeout: TimeInterval) async throws -> Data {
    try await withTaskCancellationHandler {
        try await withCheckedThrowingContinuation { continuation in
            // ① 发起操作
            // ② 注册超时任务
            // ③ 注册取消处理
        }
    } onCancel: {
        // 取消时真正停止底层操作
    }
}

三、三件事抢着 resume:竞态是核心难点

一个操作从发起到结束,可能有三个 来源都想 resume 同一个 continuation:

  1. 结果到了 ------ didUpdateValueFor 回调 → resume(returning:)
  2. 超时了 ------ 你起的 Task.sleep(timeout) 到期 → resume(throwing: .timedOut)
  3. 被取消了 ------ 外层 Task 取消 → resume(throwing: .cancelled)

CheckedContinuation 有个铁律:只能 resume 一次。重复 resume 会直接崩溃(这正是它"Checked"要抓的问题)。

想象一个真实场景:超时时刻和结果回调几乎同时到达------超时任务刚 resume(throwing: .timedOut),回调又 resume(returning: value),boom。

解法是用一把锁 + 一个 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,但业务上往往更希望一个统一、可读的错误。把取消归一化成自定义错误(比如 .operationCancelled),上层 catch 时逻辑更清爽:

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 甚至带上了是哪个操作 超时(serviceDiscovery / read / write / notificationSetup...),排查线上问题时不至于对着一个笼统的"超时"发懵。

连接、扫描、就绪等待同样有超时:

swift 复制代码
try await client.waitUntilReady(timeout: 10)          // 等蓝牙就绪
let device = try await client.findDevice(matching: filter, timeout: 10) // 找设备
try await client.connect(to: device, options: .init(timeout: 10))       // 连接

实现层面,ArcBLEKit 统一用 withTaskCancellationHandler + withCheckedThrowingContinuation + 超时 Task 的组合,并用上面那套"锁 + 标志位"保证只 resume 一次 ;所有挂起的操作在断开时会通过 failAll 一次性结束,绝不留悬着的 continuation。

一个实现细节:CoreBluetooth 的类型大多不是 Sendable,要在 Swift Concurrency 里安全地持有它们,需要 @preconcurrency import CoreBluetooth 加一层 @unchecked Sendable 的包装。这也是为什么"手写封装"往往比想象中更费劲------并发安全的坑一个接一个。


七、小结

"回调转 async"这件事,真正难的不是 withCheckedThrowingContinuation 本身,而是它背后必须补齐的两块:

  1. 超时 ------ 每个操作都要有兜底,否则挂死;
  2. 取消 ------ 既要让 await 抛错,又要真正释放底层资源。

以及横跨两者之上的那个竞态:结果、超时、取消三个来源抢着 resume,必须用互斥保证只 resume 一次。

ArcBLEKit 把这套机制沉淀成了默认行为------超时内建、取消贯穿、错误具体、断开时统一清理。下一篇我们继续往下走,讲一个依赖这套机制的进阶话题:重连之后如何恢复 Notification

本系列:

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

📦 ArcBLEKit on GitHub · 📖 API 文档

相关推荐
00后程序员张2 小时前
SSL Pinning 抓包抓不到明文?绕过证书固定的几种方案
网络协议·计算机网络·网络安全·ios·adb·https·udp
ii_best4 小时前
手机自动化脚本按键精灵实战:随机布局安全数字键盘的自动化输入方案
android·运维·ios·自动化·手机
软泡芙21 小时前
【IOS】Codable
ios·ssh·cocoa
nvvas1 天前
9月苹果发布会前瞻:新iPhone、Apple Watch重点速览
ios·iphone
Zender Han1 天前
Flutter 自适应(Adaptive)与响应式(Responsive)设计实践:官方推荐方案详解
android·flutter·ios
冯汉栩1 天前
Swift Control DateSelection(日期选择框)
ios·cocoa·swift
开心就好20251 天前
appuploader-cli 使用教程:在 Windows 上用命令行把 IPA 上传到 App Store
后端·ios
软泡芙1 天前
【IOS】CoreBluetooth
ios
冯汉栩1 天前
Swift Control DashLineView(虚线)
开发语言·ios·swift