基于模板方法模式的蓝牙多设备协议适配:从一份协议文档到一套可扩展架构

子女端 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();
}

三个抽象方法对应三类差异:

  1. dataListen(String value):协议解析,每个厂商的协议格式不同
  2. createCRC(String value):CRC 算法,每个厂商不同
  3. sendWifi():配网指令,每个厂商不同

抽象类提供通用的"骨架方法",比如连接、发现服务、读写、超时管理。子类只关心差异点。

三、连接与发现服务:Completer 协调异步流程

蓝牙连接是个多步骤异步流程:

  1. device.connect() 等待连接成功
  2. device.discoverServices() 发现服务
  3. 在服务里找到读/写/通知特征
  4. 订阅通知
  5. 读取初始数据

每一步都可能失败,每一步都需要超时保护。用 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;
}

关键点:

  1. Completer 串行化操作:防止用户连点连接按钮,第二次点击会等第一次完成。
  2. _removeCharacteristic() 在连接前清空旧引用:防止上次连接的特征残留,导致下次连接用到旧特征。
  3. 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;
  }
}

三个条件:

  1. UUID 匹配:跟子类定义的 UUID 对比
  2. 属性匹配:read 特征必须有 read 属性,notify 特征必须有 notify 属性
  3. 未被赋值: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();
}

几个细节:

  1. 正则去除非十六进制字符:协议文档里可能有空格、冒号等分隔符,实际传输时要先清掉。
  2. 奇数长度截断:十六进制字符串必须是偶数长度(一个字节两个字符),奇数时截掉最后一个字符。
  3. 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,子类各自实现,是最灵活的做法。

八、配网流程:状态机驱动的多步操作

华屹的配网流程:

  1. App 连接设备
  2. App 发送查询状态指令(隐式,连接后自动收到 511672)
  3. 设备返回状态正常 → App 发送服务器域名
  4. App 读取版本信息
  5. 设备返回 WiFi 列表(多次返回,分片传输)
  6. App 解析 WiFi 列表,用户选择 WiFi
  7. App 发送 WiFi 配置
  8. 设备返回配网进度
  9. 设备返回配网成功/失败
  10. 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();
}

几个关键设计:

  1. WiFi 列表分片传输:蓝牙单次传输数据量有限(通常 20 字节),WiFi 列表多时要分片。用 wifiIndex 跟踪当前收到的分片,全部分片到齐才解析。
  2. 状态驱动流程:每收到一帧数据,根据当前状态决定下一步动作。比如收到 511672(状态查询响应)后,自动调用 setServer(),无需上层干预。
  3. 配网重试机制:_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 次。

十二、收获

  1. 模板方法模式比继承更克制。抽象类定义骨架,子类只实现差异点,扩展时不用动骨架。
  2. Completer 是协调异步流程的利器。Completer + await 可以把多步骤异步流程串行化,比 Future.then 链清晰得多。
  3. 状态机 + 协议头双过滤。任何协议帧都先 check 协议头,再 check 状态,避免错乱状态下处理错误数据。
  4. 超时管理是必须的。蓝牙通讯每一步都可能卡住,没有超时整个流程会僵死。
  5. 平台差异要封装。Android 和 iOS 的 BLE 行为不同,差异点集中在一个 Platform.isAndroid 判断里,比散落在各处好维护。
  6. 分片传输要跟踪索引。WiFi 列表分片传输时,用 wifiIndex 跟踪当前分片,乱序也能正确组装。
  7. 配网重试要限制次数。无限重试会死循环,有限重试 + 错误提示是更好的体验。

十三、扩展新设备的步骤

有了这套架构,加一款新设备只需要:

  1. 阅读厂商协议文档
  2. 创建子类继承 BluetoothDeviceProxy
  3. 覆写 4 个 UUID 字段
  4. 实现 dataListen(协议解析)
  5. 实现 createCRC(CRC 算法)
  6. 实现 sendWifi(配网指令)
  7. 如果流程特殊,覆写 discoverService

通常一个新设备 1-2 天就能接入。对比最初写华屹花了一周多,这就是架构的价值。


这是蓝牙实战系列的第一篇。下一篇我会写极光推送 ID 上传的指数退避重试机制,那是另一种"重试"的故事。

©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容