需求:Android App 通過藍牙 (經典藍牙/低功耗藍牙) 接收十六進制數據流,實時繪製波形圖。本文提供從藍牙連接、數據解析、緩衝隊列、繪圖方案的完整工程化落地指南。
[藍牙設備] → (RFID/GATT) → [Android BluetoothSocket/BluetoothGatt]
→ [讀取線程: InputStream.read()/onCharacteristicChanged]
→ [解析: Hex String → byte[] → float/int 數值]
→ [環形緩衝區/ConcurrentQueue]
→ [UI 線程: Handler/RunOnUiThread/Flow]
→ [繪圖: Canvas + Path / MPAndroidChart / GraphView]
| 類型 | UUID/屬性 | Android API | 適用場景 |
|---|---|---|---|
| 經典藍牙 (SPP) | 固定 UUID: 00001101-0000-1000-8000-00805F9B34FB | BluetoothSocket (RFCOMM) | 串口透傳、HC-05/06、傳統醫療/工業設備 |
| 低功耗藍牙 (BLE/GATT) | 自定義 Service/Characteristic UUID (128-bit) | BluetoothGatt, BluetoothLeScanner | 現代感測器、穿戴設備、Nordic/ESP32 BLE |
題目數據樣本 顯示 `S500\r\n` `B128\r\n` `Q466\r\n` 等 ASCII 文本行,極大概率為 **經典藍牙 SPP 透傳**,每行一個採樣點,`S`=採樣、`B`/`Q`=標記/事件。
// 1. 獲取 BluetoothAdapter
BluetoothAdapter btAdapter = BluetoothAdapter.getDefaultAdapter();
// 2. 獲取遠程設備 (已配對或掃描)
BluetoothDevice device = btAdapter.getRemoteDevice("MAC_ADDRESS");
// 3. 建立 RFCOMM Socket (需在子線程)
BluetoothSocket socket = device.createRfcommSocketToServiceRecord(
UUID.fromString("00001101-0000-1000-8000-00805F9B34FB"));
socket.connect(); // 阻塞直到連接成功
// 4. 獲取輸入流,啟動讀取線程
InputStream inputStream = socket.getInputStream();
new Thread(() -> {
byte[] buffer = new byte[1024];
int bytes;
StringBuilder lineBuilder = new StringBuilder();
while (!Thread.currentThread().isInterrupted()) {
try {
bytes = inputStream.read(buffer);
if (bytes > 0) {
String chunk = new String(buffer, 0, bytes, StandardCharsets.US_ASCII);
lineBuilder.append(chunk);
// 按行分割處理 (\r\n 或 \n)
int newlineIndex;
while ((newlineIndex = lineBuilder.indexOf("\n")) != -1) {
String line = lineBuilder.substring(0, newlineIndex).trim(); // 去除 \r
lineBuilder.delete(0, newlineIndex + 1);
parseAndEnqueue(line); // 解析並入隊
}
}
} catch (IOException e) {
// 連接斷開處理
break;
}
}
}).start();
// 共享緩衝隊列 (生產者: 藍牙線程, 消費者: UI/繪圖線程)
private final BlockingQueue dataQueue = new ArrayBlockingQueue<>(2000);
private void parseAndEnqueue(String line) {
if (line.isEmpty()) return;
char prefix = line.charAt(0);
String valStr = line.substring(1); // 去掉前綴 S/B/Q
try {
float value = Float.parseFloat(valStr); // "500" → 500.0f
// 根據業務邏輯決定是否入隊
if (prefix == 'S') { // 僅採樣數據繪圖
if (!dataQueue.offer(value)) {
dataQueue.poll(); // 滿則丟最舊
dataQueue.offer(value);
}
}
// B/Q 可作為事件標記單獨處理
} catch (NumberFormatException ignored) {}
}
| 方案 | 優點 | 缺點 | 適用場景 |
|---|---|---|---|
| 自定義 View + Canvas | 零依賴、完全可控、效能最高、支援幾萬點流式 | 需自寫座標映射、網格、縮放、觸控 | 高頻 (100Hz+)、長時長、嵌入式風格 |
| MPAndroidChart | 功能豐富(縮放、游標、動畫)、社區成熟 | 大數據量(>5000點) 卡頓、方法數大 | 中低頻、需豐富交互、快速原型 |
| GraphView | 輕量、API 簡單、支援實時滾動 | 自定義程度低、大數據量效能一般 | 簡單實時波形、教學/演示 |
public class WaveformView extends View {
private final float[] buffer = new float[2000]; // 環形緩衝
private int writePos = 0;
private float minVal = -1000, maxVal = 1000; // 動態或固定量程
public void addPoint(float v) {
buffer[writePos] = v;
writePos = (writePos + 1) % buffer.length;
postInvalidateOnAnimation(); // 觸發重繪
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int w = getWidth(), h = getHeight();
if (w == 0 || h == 0) return;
Path path = new Path();
float xStep = (float) w / buffer.length;
float yZero = h * 0.5f; // 中線
float scale = h / (maxVal - minVal) * 0.8f; // 留邊距
// 從最舊點開始畫 (writePos 是最新點的下一個)
for (int i = 0; i < buffer.length; i++) {
int idx = (writePos + i) % buffer.length;
float x = i * xStep;
float y = yZero - (buffer[idx] - minVal) * scale;
if (i == 0) path.moveTo(x, y);
else path.lineTo(x, y);
}
canvas.drawPath(path, paint);
}
}
參考開源專案:MPAndroidChart、GraphView、SimpleBluetoothTerminal (完整 SPP 終端示例)。