Quay lại blog
Transport15 min read

Thiết kế tầng vận chuyển BLE — Tin nhắn và tải tệp xuống

Bài viết này trình bày cách PlainApp đẩy tin nhắn trò chuyện và tải tệp xuống qua Bluetooth Low Energy khi cả LAN lẫn Wi-Fi Aware đều không khả dụng. BLE là tầng dự phòng được đảm bảo: chậm, nhưng hoạt động mà không cần bất kỳ kết nối IP nào. Bài viết đề cập đến định dạng đường truyền, thiết kế phân khối hai lớp, cách lưu lượng đồng thời được (và không được) ưu tiên, và lý do mỗi kết nối đều bị hủy bỏ sau mỗi yêu cầu.

Để tìm hiểu về kiến trúc trò chuyện rộng hơn có sử dụng tầng vận chuyển này, xem Chat Architecture. Để biết cách hai thiết bị lấy khóa ChaCha20 dùng chung để mã hóa mọi tải trọng BLE, xem Pairing Flow.

Mục lục

Tại sao cần tầng vận chuyển BLE? {#why-a-ble-transport-at-all}

PlainApp là serverless và offline-first. Tầng vận chuyển là một chuỗi dự phòng có thứ tự: LAN → Wi-Fi Aware → BLE. LAN là đường dẫn tối ưu (HTTPS qua Wi-Fi, ~10 ms mỗi lượt đi về). Wi-Fi Aware đề cập đến các peer liên mạng con (SSID khác nhau, VLAN khách và IoT). Cả hai đều yêu cầu một kiểu kết nối IP nào đó. BLE là tầng vận chuyển duy nhất hoạt động:

  • Khi các thiết bị không hề ở cùng một mạng IP.
  • Khi Wi-Fi tắt hoặc ở chế độ máy bay (đài phát BLE tách biệt).
  • Khi Wi-Fi Aware không được hỗ trợ (Android < 13, tất cả biến thể iOS của PlainApp).

BLE chậm — hàng chục KB/s, độ trễ tính bằng giây mỗi yêu cầu — nhưng nó được đảm bảo cho bất kỳ peer đã ghép cặp nào, vì thứ duy nhất nó cần là clientId của peer, luôn được quảng bá trong phản hồi quét BLE.

Diagram 1
1

Bố cục dịch vụ GATT {#gatt-service-layout}

PlainApp quảng bá một dịch vụ GATT tùy chỉnh duy nhất với hai characteristic. Không có UUID 16-bit đã đăng ký — dịch vụ sử dụng UUID 128-bit có các byte cuối giải mã ASCII thành plpai\x01:

Diagram 2
2

Tại sao hai characteristic?

Hai giao thức có mô hình tin cậy và hình dáng tải trọng hoàn toàn khác nhau:

  • NEARBY mang tin nhắn ghép cặp. Chúng đến trước khi peer được ghép cặp (chưa có khóa chung), nên chúng dùng các tải trọng JSON có chữ ký Ed25519 riêng với định tuyến tiền tố riêng. Body là một chuỗi thuần văn bản.
  • HTTP mang toàn bộ lưu lượng sau ghép cặp (trò chuyện, tệp, trạng thái hiện diện). Luôn được mã hóa ChaCha20 với khóa chung và dùng cùng HttpRouteRegistry như máy chủ Ktor trên LAN, nên các bộ xử lý route (/peer_graphql, /fs, /peer_status) được viết một lần và tái sử dụng cho cả hai tầng vận chuyển.

Tại sao dùng thông báo thay vì đọc?

Giao thức BLE ATT giới hạn một lần đọc thuộc tính đơn lẻ ở 512 byte. Một phản hồi GraphQL hoặc một chunk tệp 16 KB có thể lớn hơn nhiều. PlainApp khắc phục điều này bằng cách không bao giờ dùng readCharacteristic cho dữ liệu thựconCharacteristicReadRequest của máy chủ trả về tải trọng rỗng với GATT_SUCCESS. Thay vào đó, client ghi yêu cầu vào characteristic, và máy chủ phản hồi bằng cách gửi một chuỗi thông báo đã phân khối mà client ráp lại. Điều này được tài liệu hóa trong BleDeviceApi.requestAsync, BleServerProtocol.handleWrite, và AndroidBleGattServer.sendChunkedResponse.

Nhận diện peer: shortId, không phải MAC {#peer-identification-shortid-not-mac}

Gói quảng bá BLE rất nhỏ (31 byte) và địa chỉ BLE MAC được Android ngẫu nhiên hóa mỗi ~15 phút — nên không thể dùng làm định danh ổn định. PlainApp thay vào đó quảng bá một tải trọng serviceData 9 byte trong phản hồi quét:

Diagram 3
3

Tại sao dùng hash cắt cụt thay vì clientId đầy đủ?

Một clientId 13 ký tự sẽ vừa trong 13 byte, nhưng PlainApp chọn SHA-256 cắt cụt 8 byte vì hai lý do:

  1. Ngân sách byte ổn định. Tổng cộng 9 byte vừa thoải mái trong tải trọng quảng bá 31 byte cùng với service UUID (16 byte), length, và type field (~27 byte dùng, 4 byte headroom).
  2. Quyền riêng tư. Một quan sát viên thụ động quét BLE không thể phục hồi clientId từ shortId (tiền tố 8 byte của hash SHA-256 là không thể đảo ngược trong thực tế). Chúng chỉ có thể nhận ra một peer mà chúng đã từng thấy quảng bá cùng shortId — không thể liệt kê người dùng PlainApp.

clientId đầy đủ chỉ được tiết lộ cho một peer đã thực sự kết nối qua GATT và trao đổi DDiscoverReply — tức là một peer mà người dùng đã chọn tương tác.

Thiết kế phân khối hai lớp {#two-layer-chunking-design}

Đây là phần tinh tế nhất của tầng vận chuyển BLE, và cần thiết để hiểu cả hai lớp vì chúng có kích thước và mục đích hoàn toàn khác nhau:

Diagram 4
4

Tại sao 380 ký tự?

ATT MTU được đàm phán là 517 byte trên Android (requestMtu(517) — giá trị tối đa được đặc tả BLE cho phép) và ~185+ trên iOS (tự động đàm phán bởi CoreBluetooth). Trừ ATT header (~3 byte) và chi phí JSON wrapper của BleSegmentData ({"d":"...","s":N} cộng ~12 byte), 380 ký tự tải trọng vừa thoải mái trong một ATT MTU trên cả hai nền tảng. Giá trị đối xứng (cả mảnh yêu cầu của client và mảnh thông báo của máy chủ đều dùng 380), giúp giữ code đơn giản.

Tại sao 16 KiB cho chunk tệp?

Một chunk tệp 16 KiB mã hóa base64 thành ~22 KiB JSON, phân mảnh thành ~58 đoạn thông báo GATT. Mỗi lượt đi về requestAsync mất vài giây qua BLE, nên ít-but-larger chunk giảm chi phí trên mỗi chunk. Lớn hơn nữa sẽ rủi ro hit timeout RPC BLE và cho phản hồi tiến độ kém (người dùng chỉ thấy tiến độ cập nhật một lần mỗi chunk). 16 KiB là điểm tối ưu được điều chỉnh thực nghiệm — đủ lớn cho throughput, đủ nhỏ cho UI tiến độ đáp ứng.

Nguyên thủy RPC: BleDeviceApi.requestAsync {#the-rpc-primitive-bledeviceapirequestasync}

Mỗi tin nhắn trò chuyện BLE và mỗi chunk tệp là một lời gọi tới BleDeviceApi.requestAsync(service, requestData) — một suspend function trả về BleResult. Nó đồng bộ từ góc nhìn caller: một yêu cầu → một phản hồi được ráp lại đầy đủ, không pipelining.

Diagram 5
5

Các bất biến chính

  1. Một yêu cầu → một phản hồi. requestAsync đồng bộ từ góc nhìn caller — nó chỉ trả về sau khi toàn bộ phản hồi đã được ráp lại. Không có pipelining.
  2. Thông báo được bật cho mỗi lần gọi. Client ghi CCCD ở đầu mỗi requestAsync và vô hiệu hóa nó ở cuối. Điều này lãng phí (hai GATT write thêm mỗi lần gọi) nhưng giữ giao thức stateless — máy chủ không phải theo dõi client nào đang "lắng nghe".
  3. Không retry trong một RPC. Nếu bất kỳ writeCharacteristic nào hết thời gian (5 s), toàn bộ RPC bị hủy. Chỉ ensureConnected retry (3 lần thử khi lỗi kết nối). Backoff thô ở tầng vận chuyển được cung cấp bởi PeerCircuitBreaker, không phải bởi tầng RPC.

Định dạng phong bì đường truyền {#wire-envelope-format}

Tải trọng bên trong đoạn Layer A là một phong bì JSON lồng nhau. Bỏ phần phân mảnh, cấu trúc logic là:

Diagram 6
6

Hình dáng phản hồi

Phản hồi chảy theo hướng ngược lại qua cùng sự phân mảnh Layer A, nhưng JSON bên trong là một BleHttpResponse với ba trường: s (mã trạng thái HTTP), h (bản đồ header phản hồi), và b (body). Body luôn luôn được mã hóa base64 bởi BleHttpCall.encodeResponse(), ngay cả khi rỗng — phản hồi có thể là binary (byte GraphQL đã mã hóa, byte tệp /fs thô) và tầng vận chuyển BLE là string-only, nên cùng một phong bì JSON mang cả tải trọng văn bản và binary.

Đường gửi tin nhắn (End-to-End) {#message-send-path-end-to-end}

Ghép tất cả lại — điều gì xảy ra khi một tin nhắn trò chuyện được gửi qua BLE:

Diagram 7
7

Các lựa chọn thiết kế đáng chú ý

  • Cùng key như LAN. Khóa chung ChaCha20 từ ghép cặp được tái sử dụng cho BLE — không có khóa BLE riêng. Interceptor mã hóa OkHttp được LanTransport sử dụng và chaCha20Encrypt/chaCha20Decrypt thủ công trong BleTransport là cùng nguyên thủy, chỉ được gọi khác nhau.
  • Cùng bộ xử lý route như LAN. BleHttpRequest được điều phối qua HttpRouteRegistry.matchRoute(path), cùng registry mà máy chủ Ktor LAN dùng. Nên /peer_graphql, /fs, /peer_status v.v. được implement chính xác một lần và hoạt động giống hệt qua cả hai tầng vận chuyển.
  • Không tái sử dụng kết nối. Khối finally { scanner.teardownConnection(client) } luôn chạy. Mỗi tin nhắn chịu đầy đủ chi phí connect→discoverServices→MTU (~giây). Đây là một đánh đổi có chủ đích — xem Đánh đổi thiết kế.

Đường tải tệp xuống (End-to-End) {#file-download-path-end-to-end}

Tải xuống qua BLE là streaming — tệp được đọc trong các chunk 16 KiB và ghi vào tệp tạm khi đến, nên tệp 10 MB không cần 10 MB RAM. Trick là mỗi RPC của chunk là một lời gọi requestAsync riêng biệt, và các chunk được đẩy vào một ByteChannel mà consumer đọc đồng thời.

Diagram 8
8

Tại sao streaming thay vì một RPC lớn?

Một tệp 10 MB gửi làm một RPC duy nhất nghĩa là ~280 000 đoạn thông báo, tất cả được giữ trong memory ở cả hai bên trước khi phản hồi có thể bắt đầu — và toàn bộ transfer phải thành công trước khi bất kỳ tiến độ nào được báo cáo. Tệ hơn, một thông báo bị drop ở giữa sẽ làm hỏng toàn bộ.

Thiết kế chunked có ba điểm thắng:

  1. Memory hằng số. Chỉ một chunk 16 KiB đang truyền tại một thời điểm.
  2. Tiến độ trực tiếp. DownloadQueue.notifyProgressUpdate() kích hoạt mỗi giây, và UI hiển thị thanh tải xuống.
  3. Khả năng phục hồi. Một chunk bị lỗi có thể retry độc lập (DownloadQueue hỗ trợ pause/resume/retry ở cấp task; một lỗi mid-stream để lại tệp tạm một phần, mặc dù hiện tại downloader xóa nó khi lỗi — xem đánh đổi).

Tại sao onClose hủy download job

Callback DownloadedResponse.onClose gọi downloadJob.cancel(). Điều này thiết yếu vì download loop chạy trong một child coroutine mà nếu không sẽ chạy mãi mãi nếu consumer bỏ channel sớm (ví dụ người dùng chạm Pause). Hợp đồng AutoCloseable trên DownloadedResponse nghĩa là khối use { ... } của consumer tự động gọi onClose khi thoát, hủy coroutine tải xuống BLE và đóng kết nối GATT trong khối finally của coroutine.

Ưu tiên: Cách trò chuyện vượt tệp trong thực tế {#prioritization-how-chat-beats-files-in-practice}

Đây là câu hỏi quan trọng nhất cho bất kỳ ứng dụng trò chuyện nào: khi một tải xuống tệp BLE chậm đang diễn ra, một tin nhắn trò chuyện mới có thể nhảy lên trước nó không?

Câu trả lời trung thực: không có sơ đồ ưu tiên rõ ràng

Không có trường priority, không có hàng đợi priority, không có preemption ở bất kỳ đâu trong code BLE hoặc hàng đợi tải xuống. Tôi đã kiểm chứng điều này bằng grep toàn diện — các match priority duy nhất trong shared/src là cấp độ log-priority và metadata EXIF, không liên quan đến thứ tự tin nhắn-vs-tải xuống.

Thay vào đó tồn tại một tập hợp các phân tách kiến trúc tạo ra hành vi mong muốn như một đặc tính phát sinh:

Diagram 9
9

Tại sao nó hoạt động trong thực tế

Sự phân tách khiến trò chuyện "cảm thấy được ưu tiên" là mang tính cấu trúc:

  1. Gửi trò chuyện không đi qua DownloadQueue. Chúng được phát hành trực tiếp bởi PeerGraphQLClientPeerTransportRouterBleTransport.send. Nên tin nhắn trò chuyện không bao giờ ngồi sau hàng đợi tải tệp.
  2. Mỗi lời gọi BleTransport mở kết nối GATT riêng. Một tải xuống dài hạn giữ một kết nối không ngăn cản việc gửi trò chuyện mở kết nối thứ hai đến cùng peer. Android hỗ trợ nhiều kết nối GATT đồng thời.
  3. Chat RPC ngắn. Một tin nhắn trò chuyện đơn lẻ là một lượt đi về requestAsync (~1 s sau khi kết nối). Kể cả khi radio đang bận với tải xuống, gửi trò chuyện hoàn thành trong vài giây.

Nơi thiết kế còn thiếu sót

Đánh đổi của "không có ưu tiên rõ ràng":

  • Độ trễ kết nối. Cả trò chuyện và tải xuống đều chịu chi phí connect→discover→MTU (~giây) mỗi lần, vì kết nối không được tái sử dụng. Một tin nhắn trò chuyện đến trong khi đang tải xuống không thể piggyback trên kết nối hiện có của tải xuống — nó mở một kết nối mới.
  • Hàng đợi tĩnh trên Android. operationQueue toàn tiến trình trong AndroidBleGattClient tuần tự các op GATT qua tất cả peer và tất cả kết nối. Nên trong khi hai kết nối GATT có thể cùng tồn tại, các op write/read/notify của chúng được xen kẽ ở cấp hàng đợi. Trong thực tế điều này ổn (mỗi op ~ms) nhưng là một nút thắt cổ chai tinh tế dưới đồng thời cao.
  • Không preemption. Một tải xuống đang diễn ra không thể bị tạm dừng để nhường cho tin nhắn trò chuyện. Gửi trò chuyện đơn giản chạy đồng thời và cạnh tranh cho thời gian radio.

Một cải tiến trong tương lai có thể là một Mutex per-peer quanh BleTransport.senddownloadFile, cộng với một trường priority trên hàng đợi — nhưng thiết kế hiện tại dựa vào thực tế rằng các chat RPC đủ ngắn để contention hiếm khi nhìn thấy được bởi người dùng.

Điều khiển đồng thời & Hàng đợi GATT tĩnh {#concurrency-control--the-static-gatt-queue}

Điều này xứng đáng có phần riêng vì đây là khía cạnh tinh tế nhất của implementation BLE Android.

Diagram 10
10

Tại sao tĩnh (toàn tiến trình)?

Bộ stack BLE Android không cho phép op GATT đồng thời trên một instance BluetoothGatt duy nhất — gọi writeCharacteristic khi một write khác đang truyền trả về false và âm thầm drop write thứ hai. Cách khắc phục chuẩn là hàng đợi per-BluetoothGatt. PlainApp đi thêm một bước và dùng một hàng đợi toàn tiến trình (trong companion object), quá bảo thủ nhưng đúng: nó đảm bảo không hai op GATT nào ở bất kỳ đâu trong app chạy đồng thời.

Chi phí là các op write/read/notify của một tải xuống tệp BLE dài xếp sau (và bị xếp sau) các op GATT của bất kỳ peer nào khác. Vì mỗi op riêng lẻ ~ms, hiếm khi là nút thắt nhìn thấy được — nhưng dưới lưu lượng BLE đồng thời nặng đến nhiều peer, nó có thể trở thành một.

Không có khóa per-peer ở tầng vận chuyển

BleDeviceApi.requestAsync là một suspend fun đơn giản với không mutex, không hàng đợi, không tuần tự per-peer. Hai lời gọi đồng thời tới BleTransport.send cho cùng peer sẽ mỗi cái mở kết nối GATT riêng và tiến hành độc lập. Sự tuần tự xảy ra ngầm ở cấp op GATT (qua hàng đợi tĩnh trên Android, hoặc qua await tuần tự trên iOS).

Vòng đời kết nối & Đàm phán MTU {#connection-lifecycle--mtu-negotiation}

Diagram 11
11

Tại sao requestMtu(517)?

ATT MTU mặc định là 23 byte (chỉ 20 byte tải trọng sau ATT header 3 byte). Với MTU mặc định, mỗi đoạn 380 ký tự sẽ cần ~19 GATT write thay vì 1 — chậm 19×. Yêu cầu MTU tối đa được BLE spec cho phép (517 byte) cho phép các đoạn 380 ký tự vừa trong một op ATT duy nhất, cải thiện throughput đáng kể.

iOS không expose API yêu cầu MTU rõ ràng — CoreBluetooth tự đàm phán với peripheral trong khi kết nối. Thiết bị iOS hiện đại thường đàm phán ~185 byte, vẫn thoải mái vừa các đoạn 380 ký tự (sau khi trừ ATT header + chi phí JSON wrapper).

Điều khiển luồng cho thông báo {#flow-control-for-notifications}

Máy chủ gửi các mảnh phản hồi làm thông báo, nhưng thông báo BLE không có điều khiển luồng tích hợp — nếu máy chủ gửi thông báo nhanh hơn controller có thể truyền, chúng bị âm thầm drop. PlainApp implement điều khiển luồng dựa trên ack rõ ràng:

Diagram 12
12

Không có điều khiển luồng này, các thông báo liên tiếp sẽ bị âm thầm drop bởi BLE controller khi hàng đợi gửi nội bộ của nó đầy lên — một vấn đề BLE Android nổi tiếng được tài liệu hóa trong comment interface của BleGattServer. Quy tắc single-in-flight per-device đảm bảo rằng mỗi thông báo hoặc được truyền hoặc kích hoạt timeout (sau đó được xử lý làm lỗi tầng vận chuyển).

Xử lý lỗi: TransportUnavailable vs lỗi thực {#error-handling-transportunavailable-vs-real-failure}

TransportUnavailable là tín hiệu báo cho PeerTransportRouter chuyển sang tầng vận chuyển kế tiếp. Bất kỳ thứ gì khác là một lỗi thực được trả về cho caller.

Diagram 13
13

Sự tinh tế của lỗi tải xuống

BleTransport.downloadFile trả về DownloadedResponse(200, channel, onClose)ngay lập tức — download loop phân khối chạy trong một background coroutine ghi vào channel. Nếu một chunk RPC lỗi mid-stream, loop gọi channel.close(TransportUnavailable(...)), nghĩa là consumer (PeerFileDownloader.downloadAsync) thấy lỗi như một exception được ném từ channel.readAvailable(buf).

Điều này nghĩa là lời gọi PeerTransportRouter.downloadFile đã thành công (trả về một DownloadedResponse), nên circuit breaker không ghi nhận lỗi cho các lỗi tải xuống mid-stream. Chỉ các lỗi connect-time và scan-time được catch bởi router. Đây là một lựa chọn thiết kế có chủ đích — một lỗi mid-stream không nên vô hiệu hóa BLE vĩnh viễn cho peer đó (peer có thể chỉ đi out of range tạm thời).

Tham chiếu hằng số chính {#key-constants-reference}

Hằng sốGiá trịVị tríMục đích
BleDeviceApi.CHUNK_SIZE380Phân mảnh đoạn GATTKích thước mỗi BleSegmentData.data (vừa trong ATT MTU sau overhead JSON)
BleTransport.CHUNK_SIZE16 384 (16 KiB)File-download byte-rangeKích thước mỗi yêu cầu chunk /fs
BleTransport.SCAN_TIMEOUT_MS10 000Quét BLETimeout cho scanner.findOne
BleDeviceApi.NOTIFY_TIMEOUT_MS15 000Phản hồi RPCThời gian chờ mỗi thông báo trong requestAsync
AndroidBleGattClient MTU517Thiết lập kết nốirequestMtu(517) — max được BLE spec cho phép
AndroidBleGattClient connect timeout10 000Thiết lập kết nốiChờ STATE_CONNECTED
AndroidBleGattClient MTU timeout5 000Thiết lập kết nốiChờ onMtuChanged
AndroidBleGattClient write timeout5 000GATT writeChờ onCharacteristicWrite
AndroidBleGattClient read timeout10 000GATT readChờ onCharacteristicRead (không dùng cho dữ liệu thực)
AndroidBleGattClient notify-state timeout5 000CCCD writeChờ ghi CCCD descriptor
ensureConnected retries3Thiết lập kết nốiTối đa 4 lần thử tổng cộng (0..3)
AndroidBleGattServer.NOTIFY_ACK_TIMEOUT_MS10 000Điều khiển luồng thông báoChờ onNotificationSent
AndroidBleGattServer notifyChunkSize380Phân mảnh phản hồiGiống như BleDeviceApi.CHUNK_SIZE
IosBleGattServer retry cap10Điều khiển luồng thông báoSố retry updateValue tối đa trước khi bỏ cuộc
PeerCircuitBreaker.WINDOW_MS30 000Transport circuit breakerThời lượng mở sau threshold
PeerCircuitBreaker.MAX_FAILURES2Transport circuit breakerSố lỗi trong window để mở
DownloadQueue.MAX_CONCURRENT3Download worker poolCoroutine tải xuống đồng thời
BleServiceData.SHORT_ID_BYTES8Nhận diện peerByte prefix SHA256 cắt cụt
BleServiceData.PAYLOAD_BYTES9Nhận diện peer1 byte flag + 8 byte shortId
BleSegmentData.STATE_START_BIT1Layer A EOF signalingĐoạn đầu của multi-segment message
BleSegmentData.STATE_END_BIT2Layer A EOF signalingĐoạn cuối (hoặc single segment)

Tóm tắt đánh đổi thiết kế {#design-trade-offs-recap}

Diagram 14
14

Đọc thêm {#further-reading}

  • Chat Architecture — cách BleTransport fit vào chuỗi dự phòng LAN → Aware → BLE và pipeline gửi/nhận trò chuyện rộng hơn.
  • Pairing Flow — cách khóa ChaCha20 dùng chung được dùng bởi mọi tải trọng BLE được thiết lập, và cách NEARBY characteristic được dùng cho handshake ghép cặp.