返回部落格
Transport15 min read

BLE 傳輸層設計 —— 訊息與檔案下載

本文說明當 LAN 與 Wi-Fi Aware 皆不可用時,PlainApp 如何透過藍牙低功耗(BLE)推播聊天訊息並下載檔案。BLE 是保證的備援方案:速度慢,但無需任何 IP 連線即可運作。本文涵蓋線路格式、兩層分塊設計、並行流量如何(以及如何不)被優先排程,以及為何每次請求後都要拆除連線。

關於消費此傳輸層的更宏觀聊天架構,請參見 Chat Architecture。關於兩台裝置如何取得 用於加密每個 BLE 負載的共享 ChaCha20 金鑰,請參見 Pairing Flow

目錄

為什麼需要 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 掃描回應中廣播。

Diagram 1
1

GATT 服務布局 {#gatt-service-layout}

PlainApp 廣播一個單一的自訂 GATT 服務,包含兩個特徵值。 沒有註冊的 16-bit UUID——該服務使用一個 128-bit UUID,其末尾位元組按 ASCII 解碼後為 plpai\x01:

Diagram 2
2

為什麼是兩個特徵值?

這兩個協定具有完全不同的信任模型與負載形態:

  • 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.requestAsyncBleServerProtocol.handleWriteAndroidBleGattServer.sendChunkedResponse

對端識別:shortId,而非 MAC {#peer-identification-shortid-not-mac}

BLE 廣播封包很小(31 位元組),並且 BLE 的 MAC 位址會被 Android 每約 15 分鐘隨機化一次——因此無法將其用作 穩定識別碼。PlainApp 改為在掃描回應中廣播一個 9 位元組的 serviceData 負載:

Diagram 3
3

為什麼用截斷雜湊而不是完整的 clientId?

一個 13 字元的 clientId 可以塞進 13 位元組,但 PlainApp 選擇 8 位元組的 SHA-256 截斷,原因有二:

  1. 穩定的位元組預算。 共 9 位元組可以舒適地容納在 31 位元組的 廣播負載中,與服務 UUID(16 位元組)、長度及類型欄位共存 (約使用 27 位元組,留有 4 位元組餘量)。
  2. 隱私。 被動掃描 BLE 的觀察者無法從 shortId 還原出 clientId (SHA-256 雜湊的 8 位元組前綴在實務上不可逆)。他們只能 識別 出之前已見過廣播同一 shortId 的對端—— 無法列舉 PlainApp 使用者。

完整的 clientId 只會揭露給真正透過 GATT 連線並交換了 DDiscoverReply 的對端——即使用者已選擇與之互動的對端。

兩層分塊設計 {#two-layer-chunking-design}

這是 BLE 傳輸層中最微妙的部分,務必同時理解兩層,因為它們有完全不同的大小與用途:

Diagram 4
4

為什麼是 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。從呼叫端的角度看它是同步的: 一次請求 → 一次完整重組的回應,沒有流水線化。

Diagram 5
5

關鍵不變式

  1. 一次請求 → 一次回應。 requestAsync 從呼叫端角度看是同步的—— 它只在完整回應重組完成後才傳回。沒有流水線化。
  2. 每次呼叫啟用通知。 用戶端在每次 requestAsync 開始時寫入 CCCD, 並在結束時停用。這很浪費(每次呼叫多兩次 GATT 寫入),但讓協定保持 無狀態——伺服器不需追蹤哪些用戶端正在「聆聽」。
  3. RPC 內不重試。 如果任何單次 writeCharacteristic 逾時 (5 秒),整個 RPC 中止。只有 ensureConnected 會重試(連線失敗時 3 次嘗試)。粗糙的傳輸層級退避由 PeerCircuitBreaker 提供,而非 RPC 層。

線路封套格式 {#wire-envelope-format}

Layer A 分片內的負載是一個巢狀 JSON 封套。剝除分片後, 邏輯結構為:

Diagram 6
6

回應形態

回應沿相反方向流經相同的 Layer A 分片,但內部 JSON 是一個 BleHttpResponse,包含三個欄位: s(HTTP 狀態碼)、h(回應標頭對應)與 b(主體)。主體 始終由 BleHttpCall.encodeResponse() 進行 base64 編碼,即使 為空也是如此——回應可能是二進位(加密的 GraphQL 位元組、原始 /fs 檔案位元組),而 BLE 傳輸層僅支援字串,因此同一個 JSON 封套 同時承載文字與二進位負載。

訊息傳送路徑(端對端) {#message-send-path-end-to-end}

將一切組合起來——當一則聊天訊息透過 BLE 傳送時發生什麼事:

Diagram 7
7

值得注意的設計選擇

  • 與 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,由消費者並行 讀取。

Diagram 8
8

為什麼用串流而不是一次大 RPC?

一個 10 MB 的檔案以單一 RPC 傳送意味著約 280 000 個通知片段, 全都在兩端駐留記憶體中,回應才能開始——而且 整個傳輸必須在回報任何進度之前就成功。更糟的是, 中間丟失一個通知就會毀掉整個 下載。

分塊設計有三個優勢:

  1. 常數記憶體。 一次只有一個 16 KiB 分塊在傳輸中。
  2. 即時進度。 DownloadQueue.notifyProgressUpdate() 每秒 觸發一次,UI 顯示下載進度列。
  3. 韌性。 失敗的分塊可獨立重試( 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 中繼資料,與訊息對下載的排序無關。

取而代之的是一組架構上的分隔,它們作為湧現性質產生 期望的行為:

Diagram 9
9

為什麼實務上能運作

讓聊天「感覺有優先權」的分隔是結構性的:

  1. 聊天傳送不經過 DownloadQueue 它們由 PeerGraphQLClientPeerTransportRouterBleTransport.send 直接發出。因此 聊天訊息永遠不會排在檔案下載佇列後面。
  2. 每次 BleTransport 呼叫都開啟自己的 GATT 連線。 一個 長時間執行的下載佔用一個連線不會阻止聊天 傳送對同一對端開啟第二個連線。Android 支援多個同時的 GATT 連線。
  3. 聊天 RPC 短暫。 單一則聊天訊息是一次 requestAsync 往返(連線後約 1 秒)。即使無線電忙於 下載,聊天傳送也會在數秒內完成。

設計不足之處

「無明確優先順序」的取捨:

  • 連線延遲。 聊天與下載每次都支付 connect→discover→MTU 成本(約數秒),因為連線不重用。在 下載期間到達的聊天訊息無法搭便車使用下載 現有的連線——它會開啟一個新的。
  • Android 上的靜態佇列。 AndroidBleGattClient 中行程層級的 operationQueue 將所有對端、所有連線的 GATT 操作 串行化。因此雖然兩個 GATT 連線可以共存,但它們的 write/read/notify 操作 在佇列層級是交錯的。在實務上 這沒問題(每個操作約數毫秒),但在高並行下這是一個微妙的全域 瓶頸。
  • 不搶佔。 進行中的下載無法暫停讓 聊天訊息通過。聊天傳送只是並行執行並 競爭無線電時間。

未來的改進可以是為 BleTransport.senddownloadFile 加上每個對端的 Mutex,並在佇列上加一個優先順序欄位——但目前的 設計依賴於聊天 RPC 短到讓競爭 鮮少被使用者察覺這個事實。

並行控制與靜態 GATT 佇列 {#concurrency-control--the-static-gatt-queue}

這值得單獨成節,因為它是 Android BLE 實作中最微妙的層面。

Diagram 10
10

為什麼是靜態(行程層級)?

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}

Diagram 11
11

為什麼 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 型流量控制:

Diagram 12
12

若沒有此流量控制,連續通知會在 BLE 控制器內部傳送佇列填滿時被靜默 丟棄——這是 BleGattServer 介面 註解中記載的一個著名 Android BLE 問題。每裝置單一傳輸中規則保證每個 通知要麼被傳輸,要麼觸發逾時(然後被 視為傳輸失敗)。

錯誤處理:TransportUnavailable 與真實失敗 {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable 是告訴 PeerTransportRouter ** fall through 到下一個傳輸層** 的訊號。其他任何錯誤都是回傳給呼叫端的真實失敗。

Diagram 13
13

下載失敗的微妙之處

BleTransport.downloadFile 立即傳回 DownloadedResponse(200, channel, onClose) ——分塊下載迴圈在一個背景協程中執行, 寫入通道。如果某個分塊 RPC 在串流中失敗,迴圈會呼叫 channel.close(TransportUnavailable(...)),這意味著消費者 (PeerFileDownloader.downloadAsync)會看到錯誤以從 channel.readAvailable(buf) 拋出例外 的形式出現。

這意味著 PeerTransportRouter.downloadFile 呼叫本身已成功 (傳回 DownloadedResponse),因此斷路器不會為串流中的下載錯誤記錄 失敗。只有連線時與掃描時的 失敗會被路由器捕獲。這是有意的設計選擇—— 串流中失敗不應永久停用該對端的 BLE(對端 可能只是暫時超出範圍)。

關鍵常數參考 {#key-constants-reference}

常數所在位置用途
BleDeviceApi.CHUNK_SIZE380GATT 分片每個 BleSegmentData.data 的大小(扣除 JSON 開銷後容納於 ATT MTU 內)
BleTransport.CHUNK_SIZE16 384 (16 KiB)檔案下載位元組範圍每個 /fs 分塊請求的大小
BleTransport.SCAN_TIMEOUT_MS10 000BLE 掃描scanner.findOne 的逾時
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000RPC 回應requestAsync 中每次通知的等待
AndroidBleGattClient MTU517連線設定requestMtu(517) —— BLE 規範允許的最大值
AndroidBleGattClient 連線逾時10 000連線設定等待 STATE_CONNECTED
AndroidBleGattClient MTU 逾時5 000連線設定等待 onMtuChanged
AndroidBleGattClient 寫入逾時5 000GATT 寫入等待 onCharacteristicWrite
AndroidBleGattClient 讀取逾時10 000GATT 讀取等待 onCharacteristicRead(不用於真實資料)
AndroidBleGattClient 通知狀態逾時5 000CCCD 寫入等待 CCCD 描述元寫入
ensureConnected 重試3連線設定最多 4 次總嘗試(0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000通知流量控制等待 onNotificationSent
AndroidBleGattServer notifyChunkSize380回應分片BleDeviceApi.CHUNK_SIZE 相同
IosBleGattServer 重試上限10通知流量控制放棄前的最大 updateValue 重試次數
PeerCircuitBreaker.WINDOW_MS30 000傳輸斷路器達到閾值後的開啟持續時間
PeerCircuitBreaker.MAX_FAILURES2傳輸斷路器視窗內開啟所需的失敗次數
DownloadQueue.MAX_CONCURRENT3下載工作者集區並行的下載協程
BleServiceData.SHORT_ID_BYTES8對端識別截斷的 SHA256 前綴位元組
BleServiceData.PAYLOAD_BYTES9對端識別1 旗標位元組 + 8 shortId 位元組
BleSegmentData.STATE_START_BIT1Layer A EOF 信號多片段訊息的第一個片段
BleSegmentData.STATE_END_BIT2Layer A EOF 信號最後一個片段(或單一片段)

設計取捨回顧 {#design-trade-offs-recap}

Diagram 14
14

延伸閱讀

  • Chat Architecture —— BleTransport 如何融入 LAN → Aware → BLE 備援鏈與更宏觀的聊天傳送/接收 管線。
  • Pairing Flow —— 每個 BLE 負載使用的共享 ChaCha20 金鑰如何建立, 以及 NEARBY 特徵值如何用於 配對握手。