关于消费该传输层的更宏观的聊天架构,参见 Chat Architecture。关于两台设备如何获得 用于加密每个 BLE 载荷的共享 ChaCha20 密钥,参见 Pairing Flow。
目录
- 为什么需要 BLE 传输层?
- GATT 服务布局
- 对端识别:shortId,而非 MAC
- 两层分片设计
- RPC 原语:
BleDeviceApi.requestAsync - 线路封装格式
- 消息发送路径(端到端)
- 文件下载路径(端到端)
- 优先级:聊天如何在实践中胜过文件
- 并发控制与静态 GATT 队列
- 连接生命周期与 MTU 协商
- 通知的流控
- 错误处理:TransportUnavailable 与真实失败
- 关键常量参考
- 设计取舍回顾
为什么需要 BLE 传输层?
PlainApp 是无服务器、离线优先的。传输层是一个有序的回退链:LAN → Wi-Fi Aware → BLE。LAN 是理想路径(基于 Wi-Fi 的 HTTPS,往返约 10 ms)。Wi-Fi Aware
覆盖跨子网的对端(不同 SSID、访客 VLAN 与 IoT VLAN)。两者都需要某种形式的
IP 连接。BLE 是唯一在以下情况下仍能工作的传输方式:
- 设备完全不在同一个 IP 网络上时。
- Wi-Fi 关闭或处于飞行模式时(BLE 射频是独立的)。
- Wi-Fi Aware 不受支持时(Android < 13,以及所有 iOS 版本的 PlainApp)。
BLE 速度慢——每秒数十 KB,每次请求延迟数秒——但对于任何已配对的对端而言它是
有保证的,因为它唯一需要的就是对端的 clientId,而该 ID 始终在 BLE 扫描响应中广播。
GATT 服务布局
PlainApp 广播一个单一的自定义 GATT 服务,包含两个特征值。
没有注册的 16-bit UUID——该服务使用一个 128-bit UUID,其末尾字节按 ASCII 解码后为 plpai\x01:
为什么是两个特征值?
这两个协议具有完全不同的信任模型和载荷形态:
- NEARBY 承载配对消息。它们在对端尚未配对时(还没有共享密钥) 到达,因此使用各自 Ed25519 签名的 JSON 载荷和各自的前缀路由。 其 body 是一个普通字符串。
- HTTP 承载所有配对之后的流量(聊天、文件、在线状态)。它始终
使用共享密钥进行 ChaCha20 加密,并使用与 LAN Ktor 服务器相同的
HttpRouteRegistry,因此路由处理器 (/peer_graphql、/fs、/peer_status)只需编写一次, 即可在两种传输层上复用。
为什么用通知而不是读?
BLE ATT 协议将单次属性读限制在 512 字节。一个
GraphQL 响应或一个 16 KB 的文件分片可能大得多。PlainApp 的解决方式是
永远不使用 readCharacteristic 传输真实数据——服务端的
onCharacteristicReadRequest 返回一个空载荷并附带 GATT_SUCCESS。
取而代之的是,客户端将请求写入特征值,服务端通过发送一系列
分片通知 来响应,由客户端重新组装。相关实现见
BleDeviceApi.requestAsync、
BleServerProtocol.handleWrite 和 AndroidBleGattServer.sendChunkedResponse。
对端识别:shortId,而非 MAC
BLE 广播包很小(31 字节),并且 BLE 的 MAC 地址会被
Android 每约 15 分钟随机化一次——因此无法将其用作
稳定标识符。PlainApp 改为在扫描响应中广播一个 9 字节的 serviceData 载荷:
为什么用截断哈希而不是完整的 clientId?
一个 13 字符的 clientId 可以塞进 13 字节,但 PlainApp 选择 8 字节的 SHA-256 截断,原因有二:
- 稳定的字节预算。 共 9 字节可以舒适地容纳在 31 字节的 广播载荷中,与服务 UUID(16 字节)、长度及类型字段共存 (约使用 27 字节,留有 4 字节余量)。
- 隐私。 被动扫描 BLE 的观察者无法从 shortId 还原出 clientId (SHA-256 哈希的 8 字节前缀在实践中不可逆)。他们只能 识别 出之前已见过广播同一 shortId 的对端—— 无法枚举 PlainApp 用户。
完整的 clientId 只会暴露给真正通过 GATT 连接并交换了
DDiscoverReply 的对端——即用户已经选择与之交互的对端。
两层分片设计
这是 BLE 传输层中最微妙的部分,理解两层至关重要, 因为它们在大小和用途上完全不同:
为什么是 380 个字符?
协商后的 ATT MTU 在 Android 上是 517 字节(requestMtu(517)——
BLE 规范允许的最大值),在 iOS 上是 ~185+(由
CoreBluetooth 自动协商)。减去 ATT 头部(~3 字节)和
BleSegmentData 的 JSON 包装开销
({"d":"...","s":N} 约增加 12 字节),380 字符的载荷可以
舒适地容纳在两个平台上单个 ATT MTU 之内。该值是
对称的(客户端请求分片和服务端通知分片都使用 380),
从而保持代码简洁。
为什么文件分片用 16 KiB?
一个 16 KiB 的文件分片经 base64 编码后约为 22 KiB 的 JSON,
会被拆分成约 58 个 GATT 通知段。每次 requestAsync 往返
在 BLE 上耗时数秒,因此"更少但更大"的分片能降低每分片开销。
再大就有可能触发 BLE RPC 超时,并造成糟糕的进度反馈
(用户每个分片只能看到一次进度更新)。16 KiB 是经验调校出的
最佳点——大到足以保证吞吐量,小到足以提供响应式的
进度 UI。
RPC 原语:BleDeviceApi.requestAsync
每一条 BLE 聊天消息和每一个文件分片都是一次对
BleDeviceApi.requestAsync(service, requestData) 的调用——一个
suspend 函数,返回一个 BleResult。从调用者角度看它是同步的:
一次请求 → 一次完全重组好的响应,没有流水线。
关键不变式
- 一次请求 → 一次响应。
requestAsync从调用者角度看是同步的—— 它仅在完整响应被重组之后才返回。没有流水线。 - 每次调用启用通知。 客户端在每次
requestAsync开始时写入 CCCD, 并在结束时禁用它。这很浪费(每次调用多出两次 GATT 写入), 但使协议保持无状态——服务端不必跟踪哪些客户端 "正在监听"。 - RPC 内部不重试。 如果任何一次
writeCharacteristic超时 (5 秒),整个 RPC 中止。只有ensureConnected会重试 (连接失败时最多 3 次)。粗粒度的传输层退避由PeerCircuitBreaker提供,而非 RPC 层。
线路封装格式
Layer A 分片内部的载荷是一个嵌套的 JSON 信封。剥离掉 分片之后,其逻辑结构为:
响应形态
响应通过同样的 Layer A 分片沿相反方向流动,但内部的
JSON 是一个 BleHttpResponse,包含三个字段:
s(HTTP 状态码)、h(响应头映射)和 b(body)。body
始终由 BleHttpCall.encodeResponse() 进行 base64 编码,即使
为空也如此——响应可能是二进制的(加密的 GraphQL 字节、原始的 /fs
文件字节),而 BLE 传输层仅支持字符串,因此同一个 JSON 信封
既能承载文本载荷,也能承载二进制载荷。
消息发送路径(端到端)
把上述内容串起来——当一条聊天消息通过 BLE 发送时会发生什么:
值得注意的设计选择
- 与 LAN 使用同一密钥。 配对得到的 ChaCha20 共享密钥被
BLE 复用——不存在单独的 BLE 密钥。
LanTransport使用的 OkHttp 加密拦截器与BleTransport中手写的chaCha20Encrypt/chaCha20Decrypt是同一原语, 只是调用方式不同。 - 与 LAN 使用相同的路由处理器。
BleHttpRequest通过HttpRouteRegistry.matchRoute(path)分发,该注册表与 Ktor LAN 服务器使用的相同。因此/peer_graphql、/fs、/peer_status等 只实现一次,并在两种传输层上以相同方式工作。 - 不复用连接。
finally { scanner.teardownConnection(client) }块始终会执行。每条消息都要支付完整的 connect→discoverServices→MTU 开销(约数秒)。这是一个刻意做出的取舍——参见 设计取舍。
文件下载路径(端到端)
通过 BLE 的下载是流式的——文件以 16 KiB 分片读取,
到达时即写入临时文件,因此一个 10 MB 的文件并不需要 10 MB 的
RAM。诀窍在于每个分片的 RPC 是一次独立的 requestAsync 调用,
分片被推入一个 ByteChannel,由消费方并发读取。
为什么用流式而不是一次大 RPC?
一个 10 MB 的文件作为单次 RPC 发送,意味着约 28 万个通知段, 在响应甚至还未开始之前就要在两端全部驻留内存中——并且 整个传输必须在任何进度被上报之前完全成功。 更糟的是,中途一个丢失的通知就会破坏整份内容。
分片设计带来三个好处:
- 常量内存。 同一时刻只有一个 16 KiB 分片在途。
- 实时进度。
DownloadQueue.notifyProgressUpdate()每秒 触发一次,UI 显示下载进度条。 - 韧性。 一个失败的分片可以独立重试
(
DownloadQueue在任务级别支持暂停/恢复/重试; 中途失败会保留部分临时文件,不过目前 下载器在失败时会将其删除——见取舍)。
为什么 onClose 会取消下载作业
DownloadedResponse.onClose 回调会调用 downloadJob.cancel()。这
至关重要,因为下载循环运行在一个子协程中,否则
当消费方提前放弃 channel(例如用户点击了 Pause)时,
该协程会永远运行下去。DownloadedResponse 上的 AutoCloseable 契约
意味着消费方的 use { ... } 块在退出时会自动调用
onClose,取消 BLE 下载协程,并在该协程的 finally 块中
拆除 GATT 连接。
优先级:聊天如何在实践中胜过文件
对于任何聊天应用而言,这都是最重要的问题:当一个缓慢的 BLE 文件下载正在进行时,一条新的聊天消息能插队到它前面吗?
诚实的回答:没有显式的优先级机制
在 BLE 代码或下载队列中没有优先级字段、没有优先级队列、没有抢占。
我通过穷举 grep 验证了这一点——shared/src 中所有 priority 的匹配
要么是日志优先级级别,要么是 EXIF 元数据,没有任何与
消息对下载排序相关的内容。
取而代之的是一组架构上的分离,它们作为涌现属性 产生出期望的行为:
为什么它在实践中奏效
使聊天"感觉被优先处理"的分离是结构性的:
- 聊天发送不经过
DownloadQueue。 它们由PeerGraphQLClient→PeerTransportRouter→BleTransport.send直接发起。因此 一条聊天消息永远不会排在文件下载队列后面。 - 每次
BleTransport调用都会打开自己的 GATT 连接。 一个 长时间运行的下载占用的一个连接不会阻止聊天 发送向同一对端打开第二个连接。Android 支持多个同时存在的 GATT 连接。 - 聊天 RPC 很短。 单条聊天消息是一次
requestAsync往返(连接后约 1 秒)。即使射频正忙于 下载,聊天发送也会在几秒内完成。
设计的不足之处
"无显式优先级"的取舍:
- 连接延迟。 聊天和下载每次都要支付 connect→discover→MTU 开销(约数秒),因为连接不复用。下载期间到达的聊天 消息无法搭便车使用下载现有的连接——它要打开一个新连接。
- Android 上的静态队列。
AndroidBleGattClient中进程范围内的operationQueue在所有对端和所有 连接之间串行化 GATT 操作。因此虽然两个 GATT 连接可以共存,但它们的 写/读/通知 操作 在队列层面会被交错。在 实践中这没问题(每次操作约毫秒级),但在高并发下它是一个 微妙的全局瓶颈。 - 无抢占。 进行中的下载无法被暂停以让聊天 消息通过。聊天发送只是并发运行, 争夺射频时间。
未来的改进可以是在 BleTransport.send 和
downloadFile 周围加一个按对端的 Mutex,并在队列上增加一个
优先级字段——但当前设计依赖于这样一个事实:聊天 RPC 足够短,
竞争 rarely 对用户可见。
并发控制与静态 GATT 队列
这值得单独成节,因为它是 Android BLE 实现中最微妙的方面。
为什么是静态的(进程范围)?
Android BLE 协议栈不允许在单个 BluetoothGatt 实例上并发 GATT 操作——在
另一个写入尚在进行时调用 writeCharacteristic 会返回 false 并静默丢弃第二次
写入。标准的变通方法是为每个 BluetoothGatt 维护一个队列。PlainApp 更进一步,使用一个
进程范围的队列(在 companion object 中),
这过于保守但正确:它保证应用中任何地方都不会有两个 GATT 操作
同时运行。
代价是一个长 BLE 文件下载的写/读/通知操作 会排在任何其他对端 GATT 操作之后(也反过来被其排队)。 由于每次单独的操作约毫秒级,这 rarely 成为用户可见的瓶颈—— 但在向多个对端进行重并发 BLE 流量时,它可能成为瓶颈。
传输层无按对端锁
BleDeviceApi.requestAsync 是一个普通的 suspend fun,没有互斥锁、没有
队列、没有按对端串行化。对同一对端的两次并发
BleTransport.send 调用会各自打开自己的 GATT
连接并独立进行。串行化隐式地发生在
GATT 操作层面(在 Android 上通过静态队列,在 iOS 上通过
顺序 await)。
连接生命周期与 MTU 协商
为什么 requestMtu(517)?
默认 ATT MTU 是 23 字节(减去 3 字节 ATT 头部后仅有 20 字节载荷)。使用默认 MTU 时, 每个 380 字符的分片需要约 19 次 GATT 写入,而不是 1 次——慢了 19 倍。请求 BLE 规范允许的 最大 MTU(517 字节)可以让 380 字符的分片容纳在 单次 ATT 操作中,显著提升吞吐量。
iOS 不暴露显式的 MTU 请求 API——CoreBluetooth 在连接期间自动与 外围设备协商。现代 iOS 设备通常协商到约 185 字节, 这仍然可以舒适地容纳 380 字符的分片 (减去 ATT 头部和 JSON 包装开销之后)。
通知的流控
服务端以通知形式发送响应分片,但 BLE 通知 没有内置流控——如果服务端发送通知的速度超过 控制器能传输的速度,它们会被静默丢弃。PlainApp 实现了显式的基于 ack 的流控:
没有这种流控,背靠背的通知会在 BLE 控制器
内部发送队列填满时被静默丢弃——这是
BleGattServer 接口注释中记录的一个众所周知的 Android BLE 问题。按设备的单次在途规则保证每个
通知要么被传输,要么触发超时(随后被
视为传输失败)。
错误处理:TransportUnavailable 与真实失败
TransportUnavailable 是告诉 PeerTransportRouter穿透到下一个传输层的信号。其他任何情况都是返回给
调用者的真实失败。
下载失败的微妙之处
BleTransport.downloadFile 立即返回 DownloadedResponse(200, channel, onClose)
——分片下载循环运行在一个后台协程中,
向 channel 写入。如果某个分片 RPC 在中途失败,循环会调用
channel.close(TransportUnavailable(...)),这意味着消费方
(PeerFileDownloader.downloadAsync)会把这个错误视为从
channel.readAvailable(buf) 抛出的异常。
这意味着 PeerTransportRouter.downloadFile 调用本身已经成功
(返回了一个 DownloadedResponse),因此熔断器不会为中途
下载错误记录失败。只有连接时和扫描时的
失败会被路由器捕获。这是一个刻意的设计选择——
中途失败不应永久禁用该对端的 BLE(对端
可能只是暂时离开了范围)。
关键常量参考
| 常量 | 值 | 位置 | 用途 |
|---|---|---|---|
BleDeviceApi.CHUNK_SIZE | 380 | GATT 分片拆分 | 每个 BleSegmentData.data 的大小(扣除 JSON 开销后容纳于 ATT MTU 内) |
BleTransport.CHUNK_SIZE | 16 384 (16 KiB) | 文件下载字节范围 | 每个 /fs 分片请求的大小 |
BleTransport.SCAN_TIMEOUT_MS | 10 000 | BLE 扫描 | scanner.findOne 的超时 |
BleDeviceApi.NOTIFY_TIMEOUT_MS | 15 000 | RPC 响应 | requestAsync 中每次通知的等待 |
AndroidBleGattClient MTU | 517 | 连接建立 | requestMtu(517)——BLE 规范允许的最大值 |
AndroidBleGattClient 连接超时 | 10 000 | 连接建立 | 等待 STATE_CONNECTED |
AndroidBleGattClient MTU 超时 | 5 000 | 连接建立 | 等待 onMtuChanged |
AndroidBleGattClient 写入超时 | 5 000 | GATT 写入 | 等待 onCharacteristicWrite |
AndroidBleGattClient 读取超时 | 10 000 | GATT 读取 | 等待 onCharacteristicRead(真实数据不使用) |
AndroidBleGattClient 通知状态超时 | 5 000 | CCCD 写入 | 等待 CCCD 描述符写入 |
ensureConnected 重试 | 3 | 连接建立 | 最多共 4 次尝试(0..3) |
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS | 10 000 | 通知流控 | 等待 onNotificationSent |
AndroidBleGattServer notifyChunkSize | 380 | 响应分片 | 与 BleDeviceApi.CHUNK_SIZE 相同 |
IosBleGattServer 重试上限 | 10 | 通知流控 | 放弃前 updateValue 的最大重试次数 |
PeerCircuitBreaker.WINDOW_MS | 30 000 | 传输层熔断器 | 触发阈值后的开启时长 |
PeerCircuitBreaker.MAX_FAILURES | 2 | 传输层熔断器 | 窗口内打开熔断器所需的失败次数 |
DownloadQueue.MAX_CONCURRENT | 3 | 下载工作线程池 | 并发下载协程 |
BleServiceData.SHORT_ID_BYTES | 8 | 对端识别 | 截断的 SHA256 前缀字节数 |
BleServiceData.PAYLOAD_BYTES | 9 | 对端识别 | 1 个标志字节 + 8 个 shortId 字节 |
BleSegmentData.STATE_START_BIT | 1 | Layer A EOF 信令 | 多分片消息的首个分片 |
BleSegmentData.STATE_END_BIT | 2 | Layer A EOF 信令 | 最后一个分片(或单个分片) |
设计取舍回顾
延伸阅读
- Chat Architecture ——
BleTransport如何融入LAN → Aware → BLE回退链,以及更宏观的聊天发送/接收 管道。 - Pairing Flow —— 每个 BLE 载荷使用的共享 ChaCha20 密钥如何 建立,以及 NEARBY 特征值如何 用于配对握手。