
适用项目:桑拿控制 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:趋势图数据点
- Hive 模型
-
-
蓝牙与解析层
-
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 等)
- GetX Binding:在路由进入血氧页面时,注入
-
-
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 自身。
解析逻辑:
- 遍历一次 notify 收到的 buffer,寻找
0xAA 0x55 - 读出
token和length - 根据 length 计算总帧长
totalLen = 2 + 1 + 1 + length - 截出完整 frame,校验 CRC
- 只处理
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 相关的逻辑:
-
扫描附近设备
-
连接指定设备
-
发现服务和特征值
-
找到能
notify的特征,订阅数据 -
将原始字节流交给
Pc60fProtocolParser,拆成参数 + 波形 -
对外暴露干净的
Stream:scanStream:Stream<List<ScanResult>>dataStream:Stream<Pc60fOxiData>waveStream:Stream<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转成多语言
-
图表数据
spo2Trend:List<Spo2TrendPoint>,最近 60 秒 SpO₂ 趋势waveformPoints:List<double>,最近 200 个波形点
-
历史记录
history:List<OximeterRecord>,最多 20 条_historyBox:Hive box
5.2 启动与初始化流程
onInit() 中:
-
注册 Hive Adapter(typeId = 31)
-
打开 Hive box:
oximeter_history -
从 box 读取历史记录 → 排序 → 取最近 20 条 → 填充
history -
订阅 BLE service:
scanStream.listen(...)更新devicesdataStream.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
结构:
-
AppBar
-
标题:
'oxi_title'.tr(血氧监测 / Oximeter) -
右侧图标:
- 历史按钮(进入
/oximeter/history) - 电池图标(根据
batteryLevel显示不同图标)
- 历史按钮(进入
-
-
未连接状态视图
-
顶部提示条(橙色背景):
- 文案:
'oxi_not_connected_hint'.tr - 按钮:
'oxi_go_connect'.tr→ 跳转连接页
- 文案:
-
中央:"请首先连接血氧仪"
'oxi_please_connect_first'.tr
-
-
已连接状态视图
-
顶部卡片:当前测量数据
-
标题:
'oxi_measuring_title'.tr(血氧监测中) -
状态文本:
c.statusText.value.tr(正常 / 探头未连接 / 信号弱...) -
三列数值:
- SpO₂:
'oxi_label_spo2'.tr+ 当前值 - PR:
'oxi_label_pr'.tr+ 当前值 - PI:
'oxi_label_pi'.tr+ 当前值
- SpO₂:
-
-
SpO₂ 趋势图:
Spo2TrendChart() -
波形图:
OxiWaveformChart() -
心率心形动画:
HeartBeatWidget(bpm: c.pr.value, size: 150)
-
-
右下角浮动按钮
FloatingActionButton.extended- 文案:
'oxi_save_current'.tr(保存本次测量) - 调用:
c.saveCurrentToHistory()
你后来把心形放到了两个图表下面,整体更符合视觉层次(先数据 & 图,再用"心跳"暗示状态)。
6.2 HeartBeatWidget
功能:根据当前 PR 动态调整心形的缩放动画周期,让用户直观感受到心率节奏。
- PR 高 → 心跳频率更快 → 动画周期缩短
- PR 低 → 心跳频率变慢 → 周期拉长
- 动画节奏可以做成"咚,咚咚"这种有呼吸感的 sequence(你选了 A:缩放心形方案)
6.3 Spo2TrendChart
-
使用
fl_chart的LineChart -
X 轴表示时间(0~60 秒)
- 0:现在(显示"现在")
- 60:1 分钟前(显示"1分钟前")
-
Y 轴表示 SpO₂:
- 70 / 85 / 100 三个刻度
-
内部逻辑:
- 根据当前时间
now和每个点的时间差,计算 x 坐标 - 只保留最近 60 秒
- 根据当前时间
适用场景:观察 SpO₂ 是否稳定在 95% 以上,有没有缓慢下降的趋势。
6.4 OxiWaveformChart
- 同样使用
fl_chart的LineChart - 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:心形图标 -
title:SpO₂ xx% · PR yy -
subtitle:PI + 电量文案 + 时间-
电量文案:
oxi_battery_low:电量:低 / Battery: lowoxi_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_mode:system/light/dark)
血氧模块 UI 使用的都是标准 Material 组件 + ThemeData 默认配色,因此自动支持暗黑模式,无需额外处理。
8. 模块可移植性指南
未来如果要把这个血氧模块迁移到另一个 Flutter 项目,大致步骤:
-
复制代码目录
- 把整个
oximeter_module文件夹(含所有 dart 文件)复制到新项目 - 把
oximeter_models.dart和对应的.g.dart拷贝,并在新项目中配置好 Hive
- 把整个
-
添加依赖
在新项目的
pubspec.yaml中确保有:yamldependencies: flutter_blue_plus: ^1.36.x get: ^4.6.x hive: ^2.2.x hive_flutter: ^1.1.x fl_chart: ^0.65.x -
初始化 Hive 和存储
在
main()中类似你现在的做法:dartawait AppStorage().init(); // 里面完成 Hive.initFlutter + openBox 等并记得注册适配器:
dartHive.registerAdapter(OximeterRecordAdapter()); -
注册路由和 Binding
在新项目的路由配置中加入:
dartGetPage( 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>等依赖注入。 -
权限和平台配置
- 安卓:
AndroidManifest.xml里确保有蓝牙、定位权限(flutter_blue_plus文档要求那套) - iOS:Info.plist 里增加 BLE 使用说明(
NSBluetoothAlwaysUsageDescription等)
- 安卓: