第4章 从回调中解析蓝牙数据:字节数组转换、协议解析、校验与异常处理
4.1 原理概述
蓝牙低功耗(BLE)的 notify 回调每次返回一个完整的 byte[] 包,但应用层通常需要从中提取结构化信息。解析链路包含三个核心环节:字节数组转换(Hex 可视化、子串提取)、协议解析(帧头/长度/命令/负载)、校验与异常处理(CRC/校验和/超时)。任何环节失败都可能导致数据丢弃或应用崩溃,因此必须建立容错机制。
4.2 实现流程
流程遵循“接收→转换→校验→解析→分发”五步,每一步都可能抛出异常,需独立捕获。
- 字节数组 → 十六进制字符串:便于日志记录和边界检查。
- 长度 & 帧头验证:检查最小包长、帧头魔数(如
0xAA)。 - 协议解析:按字段偏移提取命令、序列号、数据负载。
- 校验计算:对负载部分计算 CRC-8 或累加和,与包内校验值比对。
- 异常处理:校验失败 → 丢弃+计数;解析越界 → 日志+重置状态机。
设计原则:解析函数应保持纯函数风格(无副作用),只返回解析结果或抛出明确的异常类型,便于上层统一处理。
4.3 字节数组转换与协议解析(代码示例)
4.3.1 字节数组转十六进制字符串
public static String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(String.format("%02X", b & 0xFF));
}
return sb.toString();
}
4.3.2 协议帧定义与解析
假设自定义协议:帧头 0xAA (1B)、长度 (1B)、命令字 (1B)、数据负载 (N-3B)、校验和 (1B)。最小包长 4 字节。
public static class BlePacket {
public byte cmd;
public byte[] payload;
public byte checksum;
}
public static BlePacket parsePacket(byte[] raw) throws IllegalArgumentException {
if (raw == null || raw.length < 4) {
throw new IllegalArgumentException("Packet too short");
}
if (raw[0] != (byte) 0xAA) {
throw new IllegalArgumentException("Invalid frame header");
}
int length = raw[1] & 0xFF; // 期望负载长度
if (length + 3 != raw.length) { // 帧头1+长度1+校验1 = 3
throw new IllegalArgumentException("Length field mismatch");
}
BlePacket packet = new BlePacket();
packet.cmd = raw[2];
packet.payload = new byte[length];
System.arraycopy(raw, 3, packet.payload, 0, length);
packet.checksum = raw[raw.length - 1];
return packet;
}
4.3.3 校验计算(累加和)
public static byte calcChecksum(byte[] data, int offset, int len) {
int sum = 0;
for (int i = offset; i < offset + len; i++) {
sum += (data[i] & 0xFF);
}
return (byte) (sum & 0xFF);
}
// 使用示例
byte expected = calcChecksum(raw, 2, raw.length - 3);
if (expected != packet.checksum) {
throw new SecurityException("Checksum mismatch: expected " +
String.format("%02X", expected) +
" but got " + String.format("%02X", packet.checksum));
}
4.4 调试方法
| 调试场景 | 方法 | 工具/日志 |
|---|---|---|
| 查看原始数据 | 使用 bytesToHex() 打印完整包 | Log.d(TAG, "RX: " + hex) |
| 校验错误定位 | 记录计算与接收的校验值 | 对比两个 hex 差 |
| 协议字段越界 | 在解析前打印 length 字段 | 断点或条件日志 |
| 多包粘包 | 开启字节缓存并检查帧尾 | 轮询缓存长度 >= 最小帧 |
4.5 常见错误与处理
陷阱1: unsigned byte 在 Java 中转换为 int 时,必须先
陷阱2: 校验和仅覆盖数据部分,漏掉帧头会导致双方计算不一致。
陷阱3: 蓝牙回调可能跨线程,解析过程需保证线程安全(建议在回调线程内直接解析,只将结果 post 到主线程)。
& 0xFF,否则负数会导致长度计算错误。陷阱2: 校验和仅覆盖数据部分,漏掉帧头会导致双方计算不一致。
陷阱3: 蓝牙回调可能跨线程,解析过程需保证线程安全(建议在回调线程内直接解析,只将结果 post 到主线程)。
4.6 性能与稳定性优化
- 预分配缓冲区:避免在解析循环中频繁 new byte[],使用
ByteArrayOutputStream池或固定大小数组。 - 异常降级:校验失败时不抛出 RuntimeException,而是返回
null并记录失败计数器,防止崩溃。 - 分包重组:若协议未保证一次回调完成一帧,需维护状态机合并字节缓存后再解析。
4.7 内联流程框图:蓝牙数据回调解析链路
4.8 完整解析调用示例
public void onCharacteristicChanged(BluetoothGatt gatt,
BluetoothGattCharacteristic characteristic) {
byte[] data = characteristic.getValue();
Log.d(TAG, "Raw hex: " + bytesToHex(data));
try {
BlePacket packet = parsePacket(data);
byte checksum = calcChecksum(data, 2, data.length - 3);
if (checksum != packet.checksum) {
throw new SecurityException("Checksum mismatch");
}
// 成功解析,post到主线程存Room
handler.post(() -> repository.insert(packet));
} catch (IllegalArgumentException | SecurityException e) {
Log.w(TAG, "Parse failed: " + e.getMessage());
}
}
生产建议:在
catch 块内增加统计计数器(如 AtomicInteger),当异常次数超过阈值时发出警告,辅助运维判断设备端或链路问题。