子女端 App 有一个核心功能:给老人端睡眠雷达设备配网。雷达设备通过 BLE 蓝牙与子女端 App 通讯,App 收到用户输入的 WiFi 密码后,通过蓝牙发给设备,设备连上 WiFi 后才能正常工作。
听起来不复杂,但坑爹的是——我们要支持多个厂商的设备,每个厂商的协议都不一样。华屹是一套协议,XKL 是另一套,跌倒检测设备又是一套。如果每加一款设备就写一套独立代码,维护成本会爆炸。
这篇博客记录我用模板方法模式 + 抽象类解决多设备协议适配的思路。
一、问题分析:协议差异在哪?
在动手设计之前,我先把已知的设备协议差异列出来:
| 维度 | 华屹 (HY) | XKL | 跌倒设备 |
|---|---|---|---|
| Service UUID | 5116 | 不同 | 不同 |
| 读特征 UUID | 3334 | 不同 | 不同 |
| 写特征 UUID | 3334 | 不同 | 不同 |
| 通知特征 UUID | 3334 | 不同 | 不同 |
| 协议头 | 5116 | 不同 | 不同 |
| CRC 校验 | 16位求和 | 不同 | 不同 |
| 配网流程 | 查状态→设服务器→扫wifi→配网 | 不同 | 不同 |
相同点: 都是通过 BLE GATT 通讯,都有 service 和 characteristic,都需要读写和订阅。
不同点: UUID、协议格式、CRC 算法、业务流程。
这种"骨架相同、细节不同"的场景,模板方法模式是最合适的。
二、模板方法模式:抽象类搭骨架
抽象类 BluetoothDeviceProxy 定义通用骨架:
abstract class BluetoothDeviceProxy {
final String tag = "BluetoothDeviceProxy";
final String serviceUUID = "";
final String readCharUUID = "";
final String writeCharUUID = "";
final String notifyCharUUID = "";
final BluetoothDevice device;
DeviceDataCallback? dataCallback;
BluetoothCharacteristic? readCharacteristic;
BluetoothCharacteristic? writeCharacteristic;
BluetoothCharacteristic? notifyCharacteristic;
bool get connected => device.isConnected;
RadarDeviceStatus status;
String get id => device.remoteId.toString();
StreamSubscription<List<int>>? _readDataSubscription;
StreamSubscription<List<int>>? _notifyDataSubscription;
Completer<bool>? _readWriteCompleter;
bool initNotify = true;
bool _isConnect = false;
Timer? waitTimer;
final int rwDelay = 20000; // 读写超时
final int delay = 60000; // 接收超时
// 子类必须实现的"差异点"
void dataListen(String value);
String createCRC(String value);
Future<void> sendWifi();
}
三个抽象方法对应三类差异:
-
dataListen(String value):协议解析,每个厂商的协议格式不同 -
createCRC(String value):CRC 算法,每个厂商不同 -
sendWifi():配网指令,每个厂商不同
抽象类提供通用的"骨架方法",比如连接、发现服务、读写、超时管理。子类只关心差异点。
三、连接与发现服务:Completer 协调异步流程
蓝牙连接是个多步骤异步流程:
-
device.connect()等待连接成功 -
device.discoverServices()发现服务 - 在服务里找到读/写/通知特征
- 订阅通知
- 读取初始数据
每一步都可能失败,每一步都需要超时保护。用 Completer 协调:
Future<void> connect(String ssid, String pwd) async {
status = RadarDeviceStatus.none;
this.ssid = ssid;
this.pwd = pwd;
// 如果上一次操作还没完成,等它完成
if (_readWriteCompleter?.isCompleted == false) {
await _readWriteCompleter!.future;
return;
}
_readWriteCompleter = Completer();
_removeCharacteristic();
try {
if (!device.isConnected) {
await device.connect();
}
_isConnect = true;
await discoverService();
} catch (e) {
if (_readWriteCompleter?.isCompleted == false) {
_readWriteCompleter?.completeError(e);
}
}
await _readWriteCompleter!.future;
}
关键点:
-
Completer串行化操作:防止用户连点连接按钮,第二次点击会等第一次完成。 -
_removeCharacteristic()在连接前清空旧引用:防止上次连接的特征残留,导致下次连接用到旧特征。 -
completeError在 catch 里:异常时也要完成 Completer,否则 await 永远不返回。
discoverService 是发现服务的核心:
Future<void> discoverService() async {
if (findAllCharacteristic()) return; // 已找到,跳过
dynamic err;
try {
List<BluetoothService> services = await device.discoverServices();
for (final item in services) {
if (item.serviceUuid.toString() == serviceUUID) {
for (final charItem in item.characteristics) {
findCharacteristic(charItem);
if (findAllCharacteristic()) break;
}
break;
}
}
if (!findAllCharacteristic()) {
throw Exception("DiscoverService Not Found");
}
await initSetNotify();
await initRead();
} catch (e) {
err = e;
}
if (_readWriteCompleter?.isCompleted == false) {
if (err != null) {
_readWriteCompleter?.completeError(err);
} else {
_readWriteCompleter?.complete(true);
}
}
}
这里有个微妙的设计:try-catch 不直接抛异常,而是把 err 存起来,最后统一处理。 因为如果直接 throw,外层 connect 里的 catch 会触发,但 Completer 还没完成,await 会一直卡住。
四、特征发现:findCharacteristic 的精细匹配
每个特征都要满足三个条件才能用:
void findCharacteristic(BluetoothCharacteristic charItem) {
if (readCharacteristic == null
&& charItem.properties.read
&& charItem.characteristicUuid.toString().toUpperCase() == readCharUUID) {
readCharacteristic = charItem;
setReadSubscription();
}
if (notifyCharacteristic == null
&& charItem.properties.notify
&& charItem.characteristicUuid.toString().toUpperCase() == notifyCharUUID) {
notifyCharacteristic = charItem;
setNotifySubscription();
}
if (writeCharacteristic == null
&& (charItem.properties.write || charItem.properties.writeWithoutResponse)
&& charItem.characteristicUuid.toString().toUpperCase() == writeCharUUID) {
writeCharacteristic = charItem;
}
}
三个条件:
- UUID 匹配:跟子类定义的 UUID 对比
- 属性匹配:read 特征必须有 read 属性,notify 特征必须有 notify 属性
-
未被赋值:
null检查避免重复赋值(一个特征可能同时支持 read 和 notify)
注意 write 特征的属性检查:
charItem.properties.write || charItem.properties.writeWithoutResponse
write 和 writeWithoutResponse 是两种不同的写模式:
-
write:设备会回应,确认收到 -
writeWithoutResponse:设备不回应,速度快但不可靠
不同厂商用不同模式,所以两个都要支持。
五、十六进制协议解析:generateData 与 parseData
蓝牙协议通常是十六进制字符串,但 write 接收的是 List<int>(字节数组)。需要互转:
List<int>? generateData(String value) {
value = value.replaceAll(RegExp(r'[^0-9a-fA-F]'), ''); // 去除非十六进制字符
// 确保字符串长度为偶数(每两个字符代表一个字节)
if (value.length % 2 != 0) {
value = value.substring(0, value.length - 1);
}
// 每两个字符转换为一个十进制整数
return List.generate(
value.length ~/ 2,
(i) => int.parse(value.substring(i * 2, i * 2 + 2), radix: 16),
);
}
String parseData(List<int> data) {
String hexValue = data.map((b) => b.toRadixString(16).padLeft(2, '0')).join('');
return hexValue.toUpperCase();
}
几个细节:
- 正则去除非十六进制字符:协议文档里可能有空格、冒号等分隔符,实际传输时要先清掉。
- 奇数长度截断:十六进制字符串必须是偶数长度(一个字节两个字符),奇数时截掉最后一个字符。
-
padLeft(2, '0'):字节转十六进制时,0x0F要写成"0F"而不是"F",否则解析会错位。
字符串到字节也有需要:
String toHex(String value) {
List<int> bytes = utf8.encode(value);
return bytes.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join();
}
String? hexToString(String value) {
String? result;
try {
List<int>? bytes = generateData(value);
if (bytes?.isEmpty != false) return null;
result = utf8.decode(bytes!);
} catch (_) {}
return result;
}
WiFi 名称和密码是字符串,要转成十六进制才能塞进协议帧。
六、状态机:RadarDeviceStatus
配网过程是个状态机:
enum RadarDeviceStatus {
none, // 初始
fail, // 连接失败
normal, // 设备正常
bleWifi, // 蓝牙获取的WiFi列表
customWifi, // 用户自定义WiFi
wifiGetSuccess, // WiFi列表获取成功
wifiSetting, // 正在配置WiFi
wifiSetSuccess, // WiFi配置成功
wifiSetFail, // WiFi配置失败
timeout, // 超时
writeFail, // 写入失败
}
每个状态对应协议帧的一个阶段。子类的 dataListen 方法根据当前状态解析数据:
@override
void dataListen(String value) async {
if (!value.startsWith("5116")) return; // 不是5116协议,忽略
if (value.startsWith("511672") && status == RadarDeviceStatus.none) {
// 连接后查询状态
if (value == "51167201010074") {
await setServer();
} else {
status = RadarDeviceStatus.fail;
receiveData(null);
}
} else if (value.startsWith("511676") && status == RadarDeviceStatus.normal) {
status = RadarDeviceStatus.wifiGetSuccess;
receiveData(null);
} else if (value.startsWith("511678")
&& [RadarDeviceStatus.bleWifi, RadarDeviceStatus.customWifi,
RadarDeviceStatus.wifiGetSuccess].contains(status)) {
// 收到 WiFi 列表
// ...
} else if (value.startsWith("511671")
&& [RadarDeviceStatus.wifiGetSuccess].contains(status)) {
// WiFi 信息解析结果
// ...
}
// ...
}
这种"协议头 + 状态"双过滤的设计很关键:
-
协议头过滤:
5116是华屹的协议头,不是这个头的数据直接忽略。 -
状态过滤:
511678是 WiFi 列表数据,但只有在bleWifi/customWifi/wifiGetSuccess状态下才处理。其他状态下收到这个数据,可能是设备状态错乱,忽略更安全。
七、CRC 校验:每个厂商算法不同
华屹的 CRC 是 16 位求和:
@override
String createCRC(String value) {
List<int> bytes = generateData(value)!;
int sum = bytes.fold(0, (acc, byte) => acc + byte);
sum = sum & 0xFFFF; // 取16位
return sum.toRadixString(16).padLeft(4, '0');
}
& 0xFFFF 是取低 16 位。padLeft(4, '0') 确保 4 个字符(16 位 = 4 个十六进制字符)。
不同厂商的 CRC 算法差异很大:CRC8、CRC16、CRC32、甚至自定义校验。抽象类里只声明 createCRC,子类各自实现,是最灵活的做法。
八、配网流程:状态机驱动的多步操作
华屹的配网流程:
- App 连接设备
- App 发送查询状态指令(隐式,连接后自动收到
511672) - 设备返回状态正常 → App 发送服务器域名
- App 读取版本信息
- 设备返回 WiFi 列表(多次返回,分片传输)
- App 解析 WiFi 列表,用户选择 WiFi
- App 发送 WiFi 配置
- 设备返回配网进度
- 设备返回配网成功/失败
- App 发送
0x04指令进入正常模式
代码实现:
@override
void dataListen(String value) async {
// ...
if (value.startsWith("511678")) {
// 收到 WiFi 列表(分片)
String wifiListStr = getPayloadData(value, "511678") ?? "";
int count = generateData(wifiListStr.substring(0, 2))!.first; // 总数
if (count > 0) {
int index = generateData(wifiListStr.substring(2, 4))!.first; // 当前索引
if (wifiIndex < index) {
wifiIndex = index;
wifiStr += wifiListStr.substring(4); // 累积分片
}
}
if (count == 0 || wifiIndex == count - 1) {
// 全部分片收到,解析
status = RadarDeviceStatus.wifiGetSuccess;
parseWifiList(wifiStr);
}
}
// ...
}
Future<void> setServer() async {
status = RadarDeviceStatus.normal;
await write("5116070F696F742E68756179696D742E636F6D05FE");
await readVersion();
}
几个关键设计:
-
WiFi 列表分片传输:蓝牙单次传输数据量有限(通常 20 字节),WiFi 列表多时要分片。用
wifiIndex跟踪当前收到的分片,全部分片到齐才解析。 -
状态驱动流程:每收到一帧数据,根据当前状态决定下一步动作。比如收到
511672(状态查询响应)后,自动调用setServer(),无需上层干预。 -
配网重试机制:
_recheckCount限制最多重试 3 次,避免"没配置域名"的死循环:
if (value.startsWith("5116740107007C") && _recheckCount < _maxRecheckCount) {
_recheckCount++;
await readVersion(); // 重新走扫描wifi流程
}
九、超时管理:waitTimer
蓝牙通讯每一步都可能卡住。waitTimer 提供超时保护:
void startWait({int? waitDelay}) {
closeWait();
waitTimer = Timer(Duration(milliseconds: waitDelay ?? delay), () {
waitTimeout();
});
}
Future<void> waitTimeout() async {
dataCallback?.call(RadarDeviceStatus.timeout, null);
}
void closeWait() {
waitTimer?.cancel();
waitTimer = null;
}
调用时机:
- 写指令后启动 waitTimer:等待设备响应
-
收到响应时关闭 waitTimer:在
getData里调用closeWait() -
waitTimer 触发时通知超时:调用
dataCallback通知上层
void getData(List<int> value) {
closeWait(); // 收到数据,关闭超时
String newValue = parseData(value);
dataListen(newValue);
}
Future<void> write(String value, ...) async {
try {
List<int>? newData = generateData(value);
if (newData?.isNotEmpty == true) {
await (characteristic ?? writeCharacteristic)?.write(newData!)
.timeout(Duration(milliseconds: rwDelay));
startWait(waitDelay: waitDelay); // 写完后启动等待
}
} catch (e) {
if (catchErr) {
dataCallback?.call(RadarDeviceStatus.writeFail, null);
} else {
startWait(waitDelay: waitDelay);
}
}
}
两层超时:
-
rwDelay = 20000:单次读写超时,20 秒 -
delay = 60000:等待响应超时,60 秒
20 秒和 60 秒是踩坑后的经验值。蓝牙通讯本身慢,太短会误判,太长用户等不及。
十、平台差异:Android 与 iOS 的 notify
Future<void> setNotify(BluetoothCharacteristic? characteristic) async {
try {
await characteristic?.setNotifyValue(true,
forceIndications: Platform.isAndroid ? true : false);
} catch (_) {}
}
Future<void> initSetNotify() async {
if (initNotify && notifyCharacteristic != null) {
await setNotify(notifyCharacteristic);
}
}
forceIndications 参数:
- Android:用 indications(带确认),更可靠
- iOS:用 notify(不带确认),更快
这种差异源于两个系统对 BLE 协议栈的实现不同。Android 上 indications 更稳定,iOS 上 indications 有时根本收不到。
initNotify 标志位让子类可以禁用自动 notify。某些设备不需要 notify,强制启用会出错。
十一、踩过的坑
坑 1:writeWithoutResponse 的数据丢失
某款设备用 writeWithoutResponse,App 写了 10 个字节,设备只收到 8 个。原因是 writeWithoutResponse 没有流控,连续写会丢包。
修复: 把大数据分片,每片之间 await Future.delayed(Duration(milliseconds: 50))。
坑 2:iOS 上 setNotifyValue 报错
iOS 上调用 setNotifyValue(true) 偶尔抛 PlatformException。这是 iOS BLE 协议栈的 bug,重连才能恢复。
修复: try-catch 吞掉异常,让流程继续。下次连接会重新订阅。
坑 3: discoverServices 返回空
某些 Android 手机连接后立即调用 discoverServices 返回空列表。这是设备还没准备好。
修复: 在 connect 后加 await Future.delayed(Duration(milliseconds: 500)),等设备就绪。
坑 4:状态机的死循环
配网时设备返回"没有配置域名",App 重新读版本,设备又返回"没有配置域名"……死循环。
修复: 加 _recheckCount 计数器,最多重试 3 次。
十二、收获
- 模板方法模式比继承更克制。抽象类定义骨架,子类只实现差异点,扩展时不用动骨架。
- Completer 是协调异步流程的利器。Completer + await 可以把多步骤异步流程串行化,比 Future.then 链清晰得多。
- 状态机 + 协议头双过滤。任何协议帧都先 check 协议头,再 check 状态,避免错乱状态下处理错误数据。
- 超时管理是必须的。蓝牙通讯每一步都可能卡住,没有超时整个流程会僵死。
-
平台差异要封装。Android 和 iOS 的 BLE 行为不同,差异点集中在一个
Platform.isAndroid判断里,比散落在各处好维护。 -
分片传输要跟踪索引。WiFi 列表分片传输时,用
wifiIndex跟踪当前分片,乱序也能正确组装。 - 配网重试要限制次数。无限重试会死循环,有限重试 + 错误提示是更好的体验。
十三、扩展新设备的步骤
有了这套架构,加一款新设备只需要:
- 阅读厂商协议文档
- 创建子类继承
BluetoothDeviceProxy - 覆写 4 个 UUID 字段
- 实现
dataListen(协议解析) - 实现
createCRC(CRC 算法) - 实现
sendWifi(配网指令) - 如果流程特殊,覆写
discoverService
通常一个新设备 1-2 天就能接入。对比最初写华屹花了一周多,这就是架构的价值。
这是蓝牙实战系列的第一篇。下一篇我会写极光推送 ID 上传的指数退避重试机制,那是另一种"重试"的故事。