writeWithoutResponse 的背压处理:为什么你的 BLE 写入会丢包

2 阅读5分钟

writeWithoutResponse 的背压处理:为什么你的 BLE 写入会丢包

用 .withoutResponse 连续往设备写数据,速度快了就会静默丢包。因为这种写入没有确认,而 CoreBluetooth 的发送缓冲区是有限的。本文讲清楚背压是什么、CoreBluetooth 给了哪些工具、以及如何正确等待。ArcBLEKit 已经帮你自动处理好了。


一、两种写入:withResponse vs withoutResponse

CoreBluetooth 提供两种写入模式:

  • .withResponse —— 写完后设备会回一个确认,didWriteValueFor 回调触发才算完成。可靠,但慢(受往返延迟限制)。
  • .withoutResponse —— 写出去不等待确认,didWriteValueFor 不会被调用。快,适合高频数据(比如传感器流、固件升级数据块),但没有送达保证。

很多场景要吞吐量,就会选 .withoutResponse。但一选它,坑就来了:写快了会丢数据,而且没有任何报错。


二、为什么会丢:发送缓冲区是有限的

.withoutResponse 写入进入的是 CoreBluetooth 底层的发送缓冲区,缓冲区大小和外围设备的 MTU、链路状态有关。

你连续调用:

for chunk in bigData {
    peripheral.writeValue(chunk, for: characteristic, type: .withoutResponse)
}

如果这个 for 循环跑得比蓝牙链路实际发出去的速度还快,缓冲区就会满。满了之后继续写,结果就是:数据被丢弃,或者被拒绝——而你收不到任何回调告诉你"我丢了"。

这就是背压(backpressure)问题:生产者(你的写入循环)比消费者(蓝牙链路)快,必须有一种机制让生产者慢下来等一等。


三、CoreBluetooth 给的两个工具

好在 CoreBluetooth 不是完全没有办法,它给了两样东西:

3.1 就绪标志:canSendWriteWithoutResponse

peripheral.canSendWriteWithoutResponse // Bool

为 true 表示缓冲区还有空间、现在可以写;为 false 表示缓冲区满了、得等。

3.2 就绪回调:peripheralIsReady(toSendWriteWithoutResponse:)

func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
    // 缓冲区空出来了,可以继续写了
}

当缓冲区从"满"变为"有空间"时,这个回调会被触发。

两者配合,就是背压处理的标准姿势:写之前查 canSendWriteWithoutResponse,为 false 就挂起等待就绪回调,回调来了再继续写。


四、把它封装成 async,等就绪也带超时

把上面的逻辑包成一个 async 操作,语义就很清晰了:

func writeWithoutResponse(_ data: Data) async throws {
    // 1. 缓冲区还有空间就直接写
    guard !peripheral.canSendWriteWithoutResponse else {
        peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
        return
    }

    // 2. 缓冲区满了,等 peripheralIsReady 回调,且要带超时
    try await waitUntilReady(timeout: 10)

    peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
}

这里的 waitUntilReady 本质上是"等一个可能永远不来的回调"——又是超时和取消的问题(系列第 3 篇讲过的那套 withCheckedThrowingContinuation + 超时 Task + 取消处理)。如果等待期间设备断开或超时,必须抛错,不能永远挂起。

注意一个语义陷阱:.withoutResponse 写入返回,只代表"数据已被 CoreBluetooth 接受、进入发送缓冲区",不代表设备已经收到。这点在写代码注释和日志时要心里有数,别把它当可靠送达。


五、写入前,还有两道防线

背压只是 .withoutResponse 的一部分。真正安全的写入,在发起前还要做两件事:

5.1 校验特征属性

.withResponse 要求特征声明 .write,.withoutResponse 要求声明 .writeWithoutResponse。写错了特征,要么被静默忽略,要么出奇怪的错误。写入前先校验,能尽早暴露问题:

let required: CBCharacteristicProperties = type == .withResponse
    ? .write
    : .writeWithoutResponse
guard characteristic.properties.contains(required) else {
    throw .unsupportedOperation
}

5.2 校验最大写入长度

每个外设对单次写入有长度上限,通过 maximumWriteValueLength(for:) 查询:

let max = peripheral.maximumWriteValueLength(for: .withoutResponse)
guard data.count <= max else {
    throw .valueTooLong(actual: data.count, maximum: max)
}

超过上限的写入会被拒绝或截断。长数据要由上层切块(分片),而不是硬塞给一次 writeValue。


六、ArcBLEKit 里的实现

ArcBLEKit 把"校验 + 背压"都做进了 write 这一个 API:

try await session.write(
    Data([0x01, 0x02]),
    to: CBUUID(string: "FFF2"),
    service: CBUUID(string: "FFF0"),
    type: .withoutResponse,
    options: GATTOperationOptions(timeout: 10)
)

底层自动做了三件事:

  1. 属性校验 —— 根据 type 检查 .write / .writeWithoutResponse,不符合直接抛错;
  2. 长度校验 —— 超过 maximumWriteValueLength 抛 valueTooLong(actual:maximum:);
  3. 背压处理 —— .withoutResponse 写入时,若 canSendWriteWithoutResponse 为 false,就等待 peripheralIsReady(toSendWriteWithoutResponse:) 回调,等待过程带超时、支持取消、断开时结束。

写之前还能先查一下当前上限,方便自己做切块:

let maximum = session.maximumWriteValueLength(for: .withoutResponse)

而 .withResponse 写入走的则是另一条路——等 didWriteValueFor 回调(带超时、可取消),拿到确认才返回:

try await session.write(
    data,
    to: characteristicUUID,
    service: serviceUUID,
    type: .withResponse
)

两种写入的语义差异,被统一收敛到了 type 参数上:一个"等待确认",一个"等待缓冲区就绪"。上层代码读起来一目了然。


七、小结

writeWithoutResponse 的背压问题,核心就一句话:缓冲区有限,写快了会静默丢包,必须用 canSendWriteWithoutResponse + peripheralIsReady(toSendWriteWithoutResponse:) 让生产者慢下来。

落到代码上,是一套组合拳:

  1. 写前校验特征属性;
  2. 写前校验最大长度(超长要切块);
  3. 缓冲区满时挂起等待就绪回调,且带超时、支持取消;
  4. 记住 .withoutResponse 返回 ≠ 送达。

ArcBLEKit 把这一整套做进了 write(_:to:service:type:options:),你只需要在 type 上选择响应模式,其余自动处理。到这一篇为止,扫描、重连、超时与取消、通知恢复、背压——CoreBluetooth 中心设备开发里最常被踩的五个坑,就都串起来了。


系列回顾

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

这五个问题对应的解决方案,都沉淀在同一个开源库 ArcBLEKit 里。如果你也在写 CoreBluetooth 中心设备应用,欢迎直接拿去用,或者阅读源码一起讨论:

📦 ArcBLEKit on GitHub · 📖 API 文档 · 📱 真机示例 App