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)
)
底层自动做了三件事:
- 属性校验 —— 根据
type检查.write/.writeWithoutResponse,不符合直接抛错; - 长度校验 —— 超过
maximumWriteValueLength抛valueTooLong(actual:maximum:); - 背压处理 ——
.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:) 让生产者慢下来。
落到代码上,是一套组合拳:
- 写前校验特征属性;
- 写前校验最大长度(超长要切块);
- 缓冲区满时挂起等待就绪回调,且带超时、支持取消;
- 记住
.withoutResponse返回 ≠ 送达。
ArcBLEKit 把这一整套做进了 write(_:to:service:type:options:),你只需要在 type 上选择响应模式,其余自动处理。到这一篇为止,扫描、重连、超时与取消、通知恢复、背压——CoreBluetooth 中心设备开发里最常被踩的五个坑,就都串起来了。
系列回顾
- 用 AsyncThrowingStream 封装 CoreBluetooth 扫描
- iOS BLE 自动重连为什么比想象中复杂
- 如何正确处理 CoreBluetooth 超时与 Task Cancellation
- BLE 重连后如何恢复 Notification
- writeWithoutResponse 的背压处理(本文)
这五个问题对应的解决方案,都沉淀在同一个开源库 ArcBLEKit 里。如果你也在写 CoreBluetooth 中心设备应用,欢迎直接拿去用,或者阅读源码一起讨论:
📦 ArcBLEKit on GitHub · 📖 API 文档 · 📱 真机示例 App