血氧仪模块设计与技术实现备忘录(PC-60FW)


适用项目:桑拿控制 APP 中的血氧仪模块

设备型号:PC-60F / PC-60FW(蓝牙血氧仪)

功能:实时显示血氧、脉率、PI,心率动画、波形、趋势图、历史记录(最多 20 条)、国际化


1. 模块整体架构概览

1.1 主要文件列表

血氧模块主要集中在 lib/app/oximeter_module/ 下(名字可能略有差异,但核心是这些):

  • 协议与数据模型

    • pc60f_protocol_parser.dart

      负责按照 PC-60F 协议解析原始字节流,拆成:

      • 参数数据 Pc60fOxiData(SpO₂、PR、PI、状态、电量)
      • 波形数据 Pc60fWaveFrame(5 个波形点)
    • oximeter_models.dart / oximeter_models.g.dart

      • Hive 模型 OximeterRecord:血氧历史记录(时间 + SpO₂ + PR + PI + 电量)
      • 业务内部模型 Spo2TrendPoint:趋势图数据点
  • 蓝牙与解析层

    • oximeter_ble_service.dart

      • 使用 flutter_blue_plus 进行扫描、连接、发现服务/特征值

      • 订阅 notify 特征,组包后交给 Pc60fProtocolParser

      • 对外暴露:

        • scanStream:扫描到的 ScanResult 列表
        • dataStream:解析后的 Pc60fOxiData
        • waveStream:解析后的 Pc60fWaveFrame
  • 业务控制层(GetX)

    • oximeter_controller.dart

      • 管理扫描、连接状态
      • 维护当前 SpO₂ / PR / PI / 电量 / 状态文本 key
      • 维护趋势图数据 spo2Trend
      • 维护波形图数据 waveformPoints
      • 处理历史记录:Hive 读写 & 最大 20 条
    • oximeter_bindings.dart

      • GetX Binding:在路由进入血氧页面时,注入 OximeterController(以及 BLE service 等)
  • UI 层

    • oximeter_home_page.dart
      血氧首页:实时数据、折线趋势图、波形图、心率心形动画、保存按钮
    • oximeter_connect_page.dart
      搜索附近血氧设备并连接
    • oximeter_history_page.dart
      列出最多 20 条历史测量记录
    • heart_beat_widget.dart
      心率动画(心形随 PR 变化"咚 / 咚咚"跳动)
    • spo2_trend_chart.dart
      使用 fl_chart 绘制最近 60 秒 SpO₂ 趋势折线图,带简单坐标
    • oxi_waveform_chart.dart
      使用 fl_chart 绘制脉搏波形(50Hz 数据),更适合看节律是否规则
  • 路由与国际化

    • 路由:在 AppPages.pages 里注册:

      • /oximeter/home
      • /oximeter/connect
      • /oximeter/history
    • i18n:在 app_translations.dart + assets/i18n/*.json 里增加 oxi_* 一组 key


2. 数据流与调用关系

用文字版的"架构图"表示整个流程:

text 复制代码
PC-60F 血氧仪 (BLE)
        │  (原始字节流 notify)
        ▼
OximeterBleService (flutter_blue_plus)
  - 扫描、连接
  - 监听 notify
  - 按 PC-60F 协议截帧 → 交给 parser
        │
        ▼
Pc60fProtocolParser
  - 校验帧头 + 长度 + CRC-8
  - 解析 SpO2 参数帧 → Pc60fOxiData
  - 解析波形帧 → Pc60fWaveFrame
        │
        ▼
OximeterController (GetX)
  - 更新 spo2 / pr / pi / batteryLevel / statusText
  - 更新 spo2Trend(60 秒窗口)
  - 更新 waveformPoints(200 点窗口)
  - 手动保存测量到 Hive (OximeterRecord, 最多 20 条)
        │
        ▼
UI 页面
  - OximeterHomePage:实时卡片 + 折线 + 波形 + 心形动画 + 保存按钮
  - OximeterHistoryPage:历史列表
  - OximeterConnectPage:搜索、选择设备

3. PC-60F 协议设计与解析

3.1 帧结构(通用)

PC-60F 的 BLE 数据包采用固定帧头 + 长度 + CRC 的结构(简化理解):

text 复制代码
Byte0  : 0xAA        // Header1
Byte1  : 0x55        // Header2
Byte2  : Token       // 0x0F 表示 SpO2 模块
Byte3  : Length      // 从 Type 开始到 CRC 结束的总长度
Byte4  : Type        // 0x01 参数包 / 0x02 波形包
Byte5~N-2 : Content  // 实际数据内容
ByteN-1: CRC         // CRC-8/CCITT 校验

Pc60fProtocolParser 里定义了:

dart 复制代码
static const int _head1 = 0xAA;
static const int _head2 = 0x55;

static const int _tokenOximeter = 0x0F;
static const int _typeSpO2Param = 0x01;
static const int _typeWaveform = 0x02;

关键点:

  • Length = Type(1字节) + Content(N字节) + CRC(1字节) 的总长度
  • 所以整帧总长度 = 固定 4 字节 + Length

3.2 CRC-8 / CCITT

协议要求使用 CRC-8/CCITT 。代码中通过查表实现 _crcTable,并提供 _crc8

dart 复制代码
int _crc8(Iterable<int> bytes) {
  var crc = 0x00;
  for (final b in bytes) {
    final index = (crc ^ (b & 0xFF)) & 0xFF;
    crc = _crcTable[index];
  }
  return crc & 0xFF;
}

计算范围:从 Header 开始到 Content 最后一个字节结束,不包含 CRC 自身

解析逻辑:

  1. 遍历一次 notify 收到的 buffer,寻找 0xAA 0x55
  2. 读出 tokenlength
  3. 根据 length 计算总帧长 totalLen = 2 + 1 + 1 + length
  4. 截出完整 frame,校验 CRC
  5. 只处理 token == 0x0F(血氧模块),其它丢弃或外面处理

3.3 参数包(SpO₂ Parameter Packet)

Type = 0x01,Len = 0x08(固定)

内容结构(代码里):

dart 复制代码
final spo2 = content[0];   // 血氧饱和度 0~100
final prL = content[1];    // PR 低字节
final prH = content[2];    // PR 高字节
final piRaw = content[3];  // PI 原始值,需 /10
final status = content[4]; // 状态字节(bit 各含义在协议里有定义)
final res = content[5];    // 预留字节,含电量信息等

然后组合:

dart 复制代码
final pr = (prH << 8) | prL; // 0~511 bpm
final pi = piRaw / 10.0;     // 0.0 ~ 25.5 %

return Pc60fOxiData(
  spo2: spo2,
  pr: pr,
  pi: pi,
  status: status,
  reserved: res,
);

UI 层使用 Pc60fOxiData 来更新当前数值。

3.4 波形包(SpO₂ Waveform Packet)

Type = 0x02,Len = 0x07

内容部分为 5 个波形点

  • 每个点 1 字节:

    • bit7:是否检测到脉搏(hasBeat)
    • bit06:波形幅值(0127)

代码解析:

dart 复制代码
final hasBeat = (b & 0x80) != 0;
final value = b & 0x7F;

points.add(Pc60fWavePoint(value: value, hasBeat: hasBeat));

帧频:设备上传频率为 50Hz,每帧 5 个点,即:

  • 1 秒 10 帧
  • 每秒 50 个 wave point

Controller 收到 Pc60fWaveFrame 后,展开成一个 List<double>,用于画实时波形。

3.5 状态字节与电量

  • status 一个字节,协议定义了各 bit 的具体含义(例如探头是否连接、是否在搜索脉搏、信号质量等)
  • reserved 预留字节中包含电量等级信息(具体 bit 在协议文档里)

在控制器中,你最终采用的是一个简化的判断逻辑:

dart 复制代码
String _statusKeyFromByte(int status) {
  if (status & 0x01 != 0) return 'oxi_status_probe_off';
  if (status & 0x02 != 0) return 'oxi_status_finger_out';
  if (status & 0x04 != 0) return 'oxi_status_searching';
  if (status & 0x08 != 0) return 'oxi_status_signal_poor';
  return 'oxi_status_normal';
}

电量等级则由 Pc60fOxiData.batteryLevel 提供,实际计算逻辑在解析层(可根据协议里对 reserved 字节的定义做映射)。


4. BLE 通信设计(OximeterBleService)

4.1 主要职责

OximeterBleService 封装了所有与 flutter_blue_plus 相关的逻辑:

  1. 扫描附近设备

  2. 连接指定设备

  3. 发现服务和特征值

  4. 找到能 notify 的特征,订阅数据

  5. 将原始字节流交给 Pc60fProtocolParser,拆成参数 + 波形

  6. 对外暴露干净的 Stream

    • scanStreamStream<List<ScanResult>>
    • dataStreamStream<Pc60fOxiData>
    • waveStreamStream<Pc60fWaveFrame>

4.2 特征值选择

逻辑大致是:

  • device.discoverServices() 后遍历所有 BluetoothService
  • 找到具有 notify 属性的 BluetoothCharacteristic,作为数据来源
  • 如需要发送命令(本项目暂时没有),则再找具有 write/writeWithoutResponse 的特征用于写入

4.3 流程

text 复制代码
startScan()
  └→ FlutterBluePlus.startScan()
  └→ scanResults.listen → 把 List<ScanResult> 发到 scanStream(给 UI 列表用)

connect(deviceId)
  └→ BluetoothDevice(remoteId: deviceId).connect()
  └→ discoverServices()
  └→ 找到 notify 特征,setNotifyValue(true)
  └→ onValueReceived.listen(_onNotifyData)

_onNotifyData(rawBytes)
  └→ parser.tryExtractFrame(rawBytes)
        ├→ 返回参数帧 → dataStream.add(Pc60fOxiData)
        └→ 返回波形帧 → waveStream.add(Pc60fWaveFrame)

5. 控制层设计(OximeterController)

5.1 状态字段

核心 observable 字段:

  • 扫描 & 连接

    • scanning:是否在扫描
    • devices:当前扫描到的设备列表
    • connected:是否已连接
    • connectedDeviceId / connectedDeviceName
  • 实时参数

    • spo2:血氧饱和度
    • pr:脉率(bpm)
    • pi:灌注指数
    • batteryLevel:电量等级(0~3)
    • statusText:状态文案 key(例如 'oxi_status_normal'),UI 用 .tr 转成多语言
  • 图表数据

    • spo2TrendList<Spo2TrendPoint>,最近 60 秒 SpO₂ 趋势
    • waveformPointsList<double>,最近 200 个波形点
  • 历史记录

    • historyList<OximeterRecord>,最多 20 条
    • _historyBox:Hive box

5.2 启动与初始化流程

onInit() 中:

  1. 注册 Hive Adapter(typeId = 31)

  2. 打开 Hive box:oximeter_history

  3. 从 box 读取历史记录 → 排序 → 取最近 20 条 → 填充 history

  4. 订阅 BLE service:

    • scanStream.listen(...) 更新 devices
    • dataStream.listen(_onOxiData)
    • waveStream.listen(_onWaveFrame)

5.3 扫描 & 连接方法

  • startScan()

    • 标记 scanning = true
    • 清空 devices
    • 调用 _ble.startScan(seconds: 10)
    • 再用一个 Future.delayed 保险地在 11 秒后将 scanning 置 false
  • connectTo(ScanResult r)

    • 避免重复连接同一设备

    • 停止扫描

    • 调用 _ble.connect(id)

    • 成功后:

      • connected = true
      • 记录 connectedDeviceId / connectedDeviceName
    • 出错则 connected = false

  • disconnect()

    • _ble.disconnect()
    • 清空连接信息和状态

5.4 实时数据处理

参数数据:

dart 复制代码
void _onOxiData(Pc60fOxiData data) {
  if (!data.isValid) return;

  spo2.value = data.spo2;
  pr.value = data.pr;
  pi.value = data.pi;
  batteryLevel.value = data.batteryLevel;
  statusText.value = _statusKeyFromByte(data.status);

  _pushSpo2Trend(data);
}

趋势图维护:

dart 复制代码
void _pushSpo2Trend(Pc60fOxiData data) {
  final now = DateTime.now();
  spo2Trend.add(Spo2TrendPoint(now, data.spo2.toDouble()));

  final cutoff = now.subtract(const Duration(seconds: 60));
  spo2Trend.removeWhere((e) => e.time.isBefore(cutoff));
}

这样保证图表只显示最近 60 秒的 SpO₂ 变化。

波形数据:

dart 复制代码
void _onWaveFrame(Pc60fWaveFrame frame) {
  for (final p in frame.points) {
    waveformPoints.add(p.value.toDouble());
  }
  const maxPoints = 200;
  if (waveformPoints.length > maxPoints) {
    waveformPoints.removeRange(0, waveformPoints.length - maxPoints);
  }
}

始终只保留最近 200 个点,避免列表无限增长导致内存问题。

5.5 历史记录保存逻辑

设计为手动保存:只有点击"保存本次测量"按钮时才写入历史。

dart 复制代码
Future<void> saveCurrentToHistory() async {
  if (spo2.value <= 0 || pr.value <= 0) return;  // 无效数据不保存

  final box = _historyBox;
  if (box == null) return;

  final record = OximeterRecord(
    timestamp: DateTime.now(),
    spo2: spo2.value,
    pr: pr.value,
    pi: pi.value,
    batteryLevel: batteryLevel.value,
  );

  final list = box.values.toList()
    ..add(record)
    ..sort((a, b) => b.timestamp.compareTo(a.timestamp)); // 新的在前

  final keep = list.take(20).toList();  // 只保留最近 20 条

  await box.clear();
  for (final r in keep.reversed) {      // 反转后依次写入,保持时间顺序
    await box.add(r);
  }

  history.assignAll(keep);
}

这样解决了你之前"只剩最后一次"的问题,现在是最多 20 条,按时间倒序显示


6. UI 设计说明

6.1 OximeterHomePage

结构:

  1. AppBar

    • 标题:'oxi_title'.tr(血氧监测 / Oximeter)

    • 右侧图标:

      • 历史按钮(进入 /oximeter/history
      • 电池图标(根据 batteryLevel 显示不同图标)
  2. 未连接状态视图

    • 顶部提示条(橙色背景):

      • 文案:'oxi_not_connected_hint'.tr
      • 按钮:'oxi_go_connect'.tr → 跳转连接页
    • 中央:"请首先连接血氧仪" 'oxi_please_connect_first'.tr

  3. 已连接状态视图

    • 顶部卡片:当前测量数据

      • 标题:'oxi_measuring_title'.tr(血氧监测中)

      • 状态文本:c.statusText.value.tr(正常 / 探头未连接 / 信号弱...)

      • 三列数值:

        • SpO₂:'oxi_label_spo2'.tr + 当前值
        • PR:'oxi_label_pr'.tr + 当前值
        • PI:'oxi_label_pi'.tr + 当前值
    • SpO₂ 趋势图:Spo2TrendChart()

    • 波形图:OxiWaveformChart()

    • 心率心形动画:HeartBeatWidget(bpm: c.pr.value, size: 150)

  4. 右下角浮动按钮

    • FloatingActionButton.extended
    • 文案:'oxi_save_current'.tr(保存本次测量)
    • 调用:c.saveCurrentToHistory()

你后来把心形放到了两个图表下面,整体更符合视觉层次(先数据 & 图,再用"心跳"暗示状态)。

6.2 HeartBeatWidget

功能:根据当前 PR 动态调整心形的缩放动画周期,让用户直观感受到心率节奏。

  • PR 高 → 心跳频率更快 → 动画周期缩短
  • PR 低 → 心跳频率变慢 → 周期拉长
  • 动画节奏可以做成"咚,咚咚"这种有呼吸感的 sequence(你选了 A:缩放心形方案)

6.3 Spo2TrendChart

  • 使用 fl_chartLineChart

  • X 轴表示时间(0~60 秒)

    • 0:现在(显示"现在")
    • 60:1 分钟前(显示"1分钟前")
  • Y 轴表示 SpO₂:

    • 70 / 85 / 100 三个刻度
  • 内部逻辑:

    • 根据当前时间 now 和每个点的时间差,计算 x 坐标
    • 只保留最近 60 秒

适用场景:观察 SpO₂ 是否稳定在 95% 以上,有没有缓慢下降的趋势。

6.4 OxiWaveformChart

  • 同样使用 fl_chartLineChart
  • X 轴只是样本序号(不显示刻度)
  • Y 轴 0~127,标记 0 / 64 / 128
  • 数据来源:waveformPoints(最多 200 点)

适用场景:观察心率节律是否规则,有无大幅度波动,适合医生/用户直观感受"波形漂亮不漂亮"。

6.5 OximeterHistoryPage

  • AppBar 标题:'oxi_history_title'.tr

  • history 为空:显示 'oxi_history_empty'.tr

  • 否则 ListView.separated

    • leading:心形图标

    • titleSpO₂ xx% · PR yy

    • subtitlePI + 电量文案 + 时间

      • 电量文案:

        • oxi_battery_low:电量:低 / Battery: low
        • oxi_battery_mid:电量:中
        • oxi_battery_high:电量:高
        • oxi_battery_unknown:电量:未知

7. 国际化与主题

7.1 国际化

血氧模块使用 GetX 的 .tr 体系,你已经支持了 4 种语言:

  • 中文:zh_CN
  • 英文:en_US
  • 法语:fr_FR
  • 西班牙语:es_ES

血氧模块相关 key 统一前缀为 oxi_,包括:

  • 页面标题:oxi_title / oxi_history_title / oxi_connect_title
  • 状态文案:oxi_status_*
  • 提示信息:oxi_not_connected_hint / oxi_please_connect_first / ...
  • 按钮文案:oxi_go_connect / oxi_save_current / oxi_scan_start
  • 电量说明:oxi_battery_low / oxi_battery_mid / oxi_battery_high / oxi_battery_unknown
  • 数值标签:oxi_label_spo2 / oxi_label_pr / oxi_label_pi

所有中文已被替换为 .tr,便于后期继续加语言。

7.2 主题(Theme)

你已经在 main.dart 中支持:

  • theme(浅色)
  • darkTheme(深色)
  • themeMode(从 GetStorage 里读取 theme_modesystem / light / dark

血氧模块 UI 使用的都是标准 Material 组件 + ThemeData 默认配色,因此自动支持暗黑模式,无需额外处理。


8. 模块可移植性指南

未来如果要把这个血氧模块迁移到另一个 Flutter 项目,大致步骤:

  1. 复制代码目录

    • 把整个 oximeter_module 文件夹(含所有 dart 文件)复制到新项目
    • oximeter_models.dart 和对应的 .g.dart 拷贝,并在新项目中配置好 Hive
  2. 添加依赖

    在新项目的 pubspec.yaml 中确保有:

    yaml 复制代码
    dependencies:
      flutter_blue_plus: ^1.36.x
      get: ^4.6.x
      hive: ^2.2.x
      hive_flutter: ^1.1.x
      fl_chart: ^0.65.x
  3. 初始化 Hive 和存储

    main() 中类似你现在的做法:

    dart 复制代码
    await AppStorage().init(); // 里面完成 Hive.initFlutter + openBox 等

    并记得注册适配器:

    dart 复制代码
    Hive.registerAdapter(OximeterRecordAdapter());
  4. 注册路由和 Binding

    在新项目的路由配置中加入:

    dart 复制代码
    GetPage(
      name: '/oximeter/home',
      page: () => const OximeterHomePage(),
      binding: OximeterBinding(),
    ),
    GetPage(
      name: '/oximeter/connect',
      page: () => const OximeterConnectPage(),
      binding: OximeterBinding(),
    ),
    GetPage(
      name: '/oximeter/history',
      page: () => OximeterHistoryPage(),
      binding: OximeterBinding(),
    ),

    其中 OximeterBinding 负责 Get.lazyPut<OximeterController> 等依赖注入。

  5. 权限和平台配置

    • 安卓:AndroidManifest.xml 里确保有蓝牙、定位权限(flutter_blue_plus 文档要求那套)
    • iOS:Info.plist 里增加 BLE 使用说明(NSBluetoothAlwaysUsageDescription 等)

相关推荐
微祎_3 小时前
Flutter 性能优化实战 2025:从 60 FPS 到 120 FPS,打造丝滑如原生的用户体验
flutter·性能优化·ux
豫狮恒4 小时前
OpenHarmony Flutter 分布式安全防护:跨设备身份认证与数据加密方案
分布式·安全·flutter·wpf·openharmony
kirk_wang4 小时前
Flutter Printing库在OpenHarmony上的适配实战
flutter·移动开发·跨平台·arkts·鸿蒙
晚霞的不甘4 小时前
[鸿蒙2025领航者闯关]Flutter + OpenHarmony 安全开发实践:构建可信、合规、防逆向的鸿蒙应用
安全·flutter·harmonyos
500844 小时前
鸿蒙 Flutter 国密算法应用:SM4 加密存储与数据传输
分布式·算法·flutter·华为·wpf·开源鸿蒙
豫狮恒4 小时前
OpenHarmony Flutter 分布式数据管理:跨设备数据同步与一致性保障方案
分布式·flutter·wpf·openharmony
遝靑4 小时前
Flutter 从原理到实战:深入理解跨平台框架核心与高效开发实践
flutter
晚霞的不甘4 小时前
[鸿蒙2025领航者闯关]Flutter + OpenHarmony 性能调优实战:打造 60fps 流畅体验与低功耗的鸿蒙应用
flutter·华为·harmonyos
解局易否结局4 小时前
UI+Widget:鸿蒙/Flutter等声明式UI框架的核心设计范式深度解析
flutter·ui·harmonyos