關於消費此傳輸層的更宏觀聊天架構,請參見 Chat Architecture。關於兩台裝置如何取得 用於加密每個 BLE 負載的共享 ChaCha20 金鑰,請參見 Pairing Flow。
目錄
- 為什麼需要 BLE 傳輸層?
- GATT 服務布局
- 對端識別:shortId,而非 MAC
- 兩層分塊設計
- RPC 基元:
BleDeviceApi.requestAsync - 線路封套格式
- 訊息傳送路徑(端對端)
- 檔案下載路徑(端對端)
- 優先順序:聊天如何在實務中勝過檔案
- 並行控制與靜態 GATT 佇列
- 連線生命週期與 MTU 協商
- 通知的流量控制
- 錯誤處理:TransportUnavailable 與真實失敗
- 關鍵常數參考
- 設計取捨回顧
為什麼需要 BLE 傳輸層? {#why-a-ble-transport-at-all}
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 服務布局 {#gatt-service-layout}
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 {#peer-identification-shortid-not-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 的對端——即使用者已選擇與之互動的對端。
兩層分塊設計 {#two-layer-chunking-design}
這是 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 {#the-rpc-primitive-bledeviceapirequestasync}
每則 BLE 聊天訊息與每個檔案分塊都是一次
BleDeviceApi.requestAsync(service, requestData) 呼叫——一個 suspend 函式,
傳回一個 BleResult。從呼叫端的角度看它是同步的:
一次請求 → 一次完整重組的回應,沒有流水線化。
關鍵不變式
- 一次請求 → 一次回應。
requestAsync從呼叫端角度看是同步的—— 它只在完整回應重組完成後才傳回。沒有流水線化。 - 每次呼叫啟用通知。 用戶端在每次
requestAsync開始時寫入 CCCD, 並在結束時停用。這很浪費(每次呼叫多兩次 GATT 寫入),但讓協定保持 無狀態——伺服器不需追蹤哪些用戶端正在「聆聽」。 - RPC 內不重試。 如果任何單次
writeCharacteristic逾時 (5 秒),整個 RPC 中止。只有ensureConnected會重試(連線失敗時 3 次嘗試)。粗糙的傳輸層級退避由PeerCircuitBreaker提供,而非 RPC 層。
線路封套格式 {#wire-envelope-format}
Layer A 分片內的負載是一個巢狀 JSON 封套。剝除分片後, 邏輯結構為:
回應形態
回應沿相反方向流經相同的 Layer A
分片,但內部 JSON 是一個 BleHttpResponse,包含三個欄位:
s(HTTP 狀態碼)、h(回應標頭對應)與 b(主體)。主體
始終由 BleHttpCall.encodeResponse() 進行 base64 編碼,即使
為空也是如此——回應可能是二進位(加密的 GraphQL 位元組、原始 /fs
檔案位元組),而 BLE 傳輸層僅支援字串,因此同一個 JSON 封套
同時承載文字與二進位負載。
訊息傳送路徑(端對端) {#message-send-path-end-to-end}
將一切組合起來——當一則聊天訊息透過 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 成本(約數秒)。這是有意的取捨——請參見 設計取捨。
檔案下載路徑(端對端) {#file-download-path-end-to-end}
透過 BLE 的下載是串流式——檔案以 16 KiB 分塊讀取,
並在到達時寫入暫存檔,因此 10 MB 的檔案不需要 10 MB 的
RAM。訣竅在於每個分塊的 RPC 是一次獨立的 requestAsync 呼叫,
而分塊被推入一個 ByteChannel,由消費者並行
讀取。
為什麼用串流而不是一次大 RPC?
一個 10 MB 的檔案以單一 RPC 傳送意味著約 280 000 個通知片段, 全都在兩端駐留記憶體中,回應才能開始——而且 整個傳輸必須在回報任何進度之前就成功。更糟的是, 中間丟失一個通知就會毀掉整個 下載。
分塊設計有三個優勢:
- 常數記憶體。 一次只有一個 16 KiB 分塊在傳輸中。
- 即時進度。
DownloadQueue.notifyProgressUpdate()每秒 觸發一次,UI 顯示下載進度列。 - 韌性。 失敗的分塊可獨立重試(
DownloadQueue在任務層級支援暫停/恢復/重試; 串流中斷會留下部分暫存檔,不過目前 下載器會在失敗時刪除它——見取捨)。
為什麼 onClose 會取消下載工作
DownloadedResponse.onClose 回呼會呼叫 downloadJob.cancel()。這
非常關鍵,因為下載迴圈在一個子協程中執行,如果消費者提前
放棄通道(例如使用者點選 Pause),它否則會永遠執行下去。DownloadedResponse 上的 AutoCloseable 契約意味著消費者的 use { ... } 區塊在結束時
會自動呼叫 onClose,取消 BLE 下載協程並在協程的 finally 區塊中
拆除 GATT 連線。
優先順序:聊天如何在實務中勝過檔案 {#prioritization-how-chat-beats-files-in-practice}
這是任何聊天應用程式最重要的問題:當慢速 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 連線可以共存,但它們的 write/read/notify 操作 在佇列層級是交錯的。在實務上 這沒問題(每個操作約數毫秒),但在高並行下這是一個微妙的全域 瓶頸。 - 不搶佔。 進行中的下載無法暫停讓 聊天訊息通過。聊天傳送只是並行執行並 競爭無線電時間。
未來的改進可以是為 BleTransport.send
與 downloadFile 加上每個對端的 Mutex,並在佇列上加一個優先順序欄位——但目前的
設計依賴於聊天 RPC 短到讓競爭
鮮少被使用者察覺這個事實。
並行控制與靜態 GATT 佇列 {#concurrency-control--the-static-gatt-queue}
這值得單獨成節,因為它是 Android BLE 實作中最微妙的層面。
為什麼是靜態(行程層級)?
Android BLE 堆疊不允許在單一
BluetoothGatt 實例上並行 GATT 操作——在另一個
寫入進行中時呼叫 writeCharacteristic 會傳回 false 並靜默丟棄第二次寫入。標準的解決方法是每個 BluetoothGatt 一個佇列。PlainApp 更進一步使用
行程層級 佇列(在 companion object 中),
這過度保守但正確:它保證應用程式中沒有任何兩個 GATT 操作
同時執行。
代價是長時間 BLE 檔案下載的 write/read/notify 操作 會排在(並被排在)任何其他對端的 GATT 操作之後。 由於每個個別操作約數毫秒,這鮮少是使用者可見的瓶頸—— 但在對多個對端進行密集並行 BLE 流量時,可能會成為 瓶頸。
傳輸層沒有每對端鎖
BleDeviceApi.requestAsync 是一個普通的 suspend fun,沒有 mutex、沒有
佇列、沒有每對端串行化。對同一對端的兩個並行
BleTransport.send 呼叫各自會開啟自己的 GATT
連線並獨立進行。串行化隱含地發生在
GATT 操作層級(在 Android 上透過靜態佇列,在 iOS 上透過
循序的 await)。
連線生命週期與 MTU 協商 {#connection-lifecycle--mtu-negotiation}
為什麼 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 包裝開銷後)。
通知的流量控制 {#flow-control-for-notifications}
伺服器以通知形式傳送回應片段,但 BLE 通知 沒有內建流量控制——如果伺服器傳送通知的速度快於 控制器能傳輸的速度,它們會被靜默丟棄。PlainApp 實作了明確的 ack 型流量控制:
若沒有此流量控制,連續通知會在 BLE 控制器內部傳送佇列填滿時被靜默
丟棄——這是 BleGattServer 介面
註解中記載的一個著名 Android BLE 問題。每裝置單一傳輸中規則保證每個
通知要麼被傳輸,要麼觸發逾時(然後被
視為傳輸失敗)。
錯誤處理:TransportUnavailable 與真實失敗 {#error-handling-transportunavailable-vs-real-failure}
TransportUnavailable 是告訴 PeerTransportRouter ** fall through 到下一個傳輸層** 的訊號。其他任何錯誤都是回傳給呼叫端的真實失敗。
下載失敗的微妙之處
BleTransport.downloadFile 立即傳回 DownloadedResponse(200, channel, onClose)
——分塊下載迴圈在一個背景協程中執行,
寫入通道。如果某個分塊 RPC 在串流中失敗,迴圈會呼叫
channel.close(TransportUnavailable(...)),這意味著消費者
(PeerFileDownloader.downloadAsync)會看到錯誤以從 channel.readAvailable(buf) 拋出例外
的形式出現。
這意味著 PeerTransportRouter.downloadFile 呼叫本身已成功
(傳回 DownloadedResponse),因此斷路器不會為串流中的下載錯誤記錄
失敗。只有連線時與掃描時的
失敗會被路由器捕獲。這是有意的設計選擇——
串流中失敗不應永久停用該對端的 BLE(對端
可能只是暫時超出範圍)。
關鍵常數參考 {#key-constants-reference}
| 常數 | 值 | 所在位置 | 用途 |
|---|---|---|---|
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 信號 | 最後一個片段(或單一片段) |
設計取捨回顧 {#design-trade-offs-recap}
延伸閱讀
- Chat Architecture ——
BleTransport如何融入LAN → Aware → BLE備援鏈與更宏觀的聊天傳送/接收 管線。 - Pairing Flow —— 每個 BLE 負載使用的共享 ChaCha20 金鑰如何建立, 以及 NEARBY 特徵值如何用於 配對握手。