资讯详情

资讯详情

OpenHarmony上用Flutter开发血压记录App:环境搭建与状态管理实战

最近我在做一个 OpenHarmony 平台上的个人健康管理 App里面最核心也最刚需的一个模块就是血压记录。本来这个功能用 ArkTS 写也能做但我最后还是选了 Flutter原因很简单团队本来的移动端技术栈就是 Flutter直接复用组件和状态管理方案省掉一大半重复工作。OpenHarmony 官方这几年一直在推 ArkTS但 Flutter 生态并没有闲着已经有比较成熟的移植分支日常的 UI 搭建、状态管理、本地存储这些能力基本都能跑起来。这篇就把“添加血压记录”从零到一的完整实现过程拆开讲一遍。不光是贴代码还会说清楚为什么要这么设计Provider 到底解决什么问题表单校验怎么做得不容易被吐槽以及在 OpenHarmony 真机上调试时最容易踩的几个坑。如果你正准备在 OpenHarmony 上起一个 Flutter 项目或者想给自己的健康类 App 加一个记录模块这篇可以直接当参考。1. 方案选型为什么我在 OpenHarmony 上用 Flutter 做健康记录 App1.1 Flutter 与 ArkTS 的取舍别被“谁更流行”带偏网上关于“ArkTS 和 Flutter 谁更流行”的讨论一直没停过。流行度这种事情其实跟你手头要解决的问题没什么关系。ArkTS 确实是 OpenHarmony 应用层的亲儿子系统组件适配最到位调原生能力最直接。但如果你只是想在 OpenHarmony 上快速落地一个偏业务向的 AppFlutter 的优势非常明显跨端复用、热重载开发效率高、Dart 语言本身的上手门槛比 TypeScript 衍生出来的 ArkTS 低。OpenHarmony 系统底层并不是用 ArkTS 写的ArkTS 只是应用层推荐语言。真到了工程化层面Flutter 的一套 UI 描述、组件渲染、状态管理思路完全可以平移到 OpenHarmony 上前提是拿到对应的 Flutter for OpenHarmony SDK。我的实际体感是列表、表单、卡片这种典型业务页面Flutter 写起来比 ArkTS 顺手很多尤其是状态一多的时候Provider 配合声明式 UI 直接省掉一堆 setter 回调。题外话Flutter 的渲染引擎 Impeller 在移动端逐渐成为默认选项OpenHarmony 适配分支上还是以 Skia 为主。这个细节对业务开发影响不大但你要是做复杂动画或者排查渲染异常得知道有这么个差异。1.2 血压记录模块到底需要哪些功能很多新手做健康类 App 一上来就铺功能今天加心率、明天加血糖、后天加 BMI结果每个模块都做得稀烂。我建议把一个血压记录模块先收敛好。我的做法是先列核心使用场景用户打开 App看到最近的血压记录列表。用户点击“添加血压”进入填写页面。用户输入收缩压、舒张压、心率可选填备注。保存后回到列表新纪录排在最前面。如果某条记录填错了可以删除。就这么点需求不涉及云同步不涉及图表曲线先把链路跑通。等基础版本稳定了再考虑趋势图表、用药提醒、多用户分权之类的进阶能力。过早设计复杂度只会让自己在真机调试的时候怀疑人生。1.3 项目整体结构与模块划分模块划分上我用了最朴素的分层方式没有引一堆架构库lib/ ├── main.dart // 入口配置 Provider ├── models/ │ └── blood_pressure_record.dart // 数据模型 ├── providers/ │ └── blood_pressure_provider.dart // 状态管理 ├── pages/ │ ├── home_page.dart // 列表首页 │ ├── record_list.dart // 血压记录列表组件 │ └── add_record_page.dart // 添加记录页 ├── utils/ │ └── validators.dart // 校验工具 └── services/ └── local_store.dart // 本地存储封装这套结构没有刻意做抽象但每个文件职责明确。后续想加血糖模块照着 models、providers、pages 这三层复制一套就行。本质上所有“记录类功能”都是同一个套路数据模型 表单 列表 持久化。2. 环境搭建与工程接入OpenHarmony 版 Flutter 的“第一道坎”2.1 环境到底要配哪些东西OpenHarmony 上跑 Flutter 并不是装个官方 Flutter SDK 就行需要用针对 OpenHarmony 的 Flutter 分支。这套分支一般由 OpenHarmony 生态伙伴在维护支持 OpenHarmony 的 ArkUI 组件桥接和系统 API 调用。我当时配置环境花了大半天核心步骤整理如下安装 DevEco Studio 和 OpenHarmony SDK这是基础运行环境。准备对应版本的 Flutter for OpenHarmony SDK下载后配置到环境变量。创建 Flutter 工程后通过集成方式接入到 OpenHarmony 工程里不是直接flutter run就能完事。用 DevEco Studio 打开 harmony 目录配置签名和调试证书。这个步骤听起来简单但版本匹配是个大坑。OpenHarmony SDK 版本、Flutter 分支版本、DevEco Studio 版本三者必须对齐跨版本经常出现编译过但运行崩溃的情况。我的建议是直接看分支仓库的 README上面会写清楚对应关系。2.2 新建项目跑不起来的常见原因“flutter 新建项目后跑不起来”这个问题我周围同事遇到太多了搜索引擎里也是高频词。常见的表现包括编译到一半报 Gradle 错误、真机连接不上、Dart VM 初始化异常。根据我的排查经验绝大多数情况下不是代码问题而是环境不匹配。比如那种you are applying flutters main gradle plugin imperatively using the apply的报错就是 Gradle 插件应用方式不对。Flutter 的新版本 Gradle 插件要求在 settings.gradle 里声明插件而不是在 build.gradle 里用老式apply方式。解决方案就是删掉原来 build.gradle 里的apply from: $flutterRoot/packages/flutter_tools/gradle/flutter.gradle改成标准插件声明方式。再比如运行时报[error:flutter/runtime/dart_vm_initializer.cc(41)] unhandled这种神秘的 Dart 异常多半是原生插件注册失败或 SDK 版本不匹配。我在 OpenHarmony 真机上遇到过一次最后发现是某个依赖库的 so 库只编译了 ARM64没有适配目标设备的 ABI。建议升级 Flutter 分支版本之后最好清理一遍 build 目录执行flutter clean再重新构建。增量编译有时候真的会让人找半天错误。2.3 两个值得提前知道的渲染与调试细节先说 Impeller。OpenHarmony 版 Flutter 默认渲染引擎和 Android 官方版不完全一样我跑下来没有发现明显渲染问题但如果你做了复杂的自定义绘制或者大量图片动画建议尽早针对目标设备做一轮真机测试别在模拟器上看得挺流畅就当没事了。还有就是日志排查OpenHarmony 的日志系统是 hilogFlutter 的日志输出不一定全在 Flutter console 里有时候异常信息只在 DevEco Studio 的 Log 窗口里才有两边配合着看效率才高。3. 添加血压记录的完整实现3.1 先定数据模型字段校验比 UI 重要血压记录听起来就是“收缩压 舒张压 心率 时间”但实际做的时候会有一堆细节。数据模型我这样定义class BloodPressureRecord { final int systolic; final int diastolic; final int pulse; final DateTime measuredAt; final String note; const BloodPressureRecord({ required this.systolic, required this.diastolic, required this.pulse, required this.measuredAt, this.note , }); double get meanArterialPressure (systolic 2 * diastolic) / 3; bool get isNormal systolic 90 systolic 140 diastolic 60 diastolic 90 pulse 50 pulse 100; MapString, dynamic toMap() { return { systolic: systolic, diastolic: diastolic, pulse: pulse, measuredAt: measuredAt.millisecondsSinceEpoch, note: note, }; } factory BloodPressureRecord.fromMap(MapString, dynamic map) { return BloodPressureRecord( systolic: map[systolic] as int, diastolic: map[diastolic] as int, pulse: map[pulse] as int, measuredAt: DateTime.fromMillisecondsSinceEpoch( (map[measuredAt] as int)), note: map[note] as String? ?? , ); } }字段都设成final保证不可变性这样在状态管理里替换数据时不容易出现引用混乱。meanArterialPressure和isNormal是计算属性分别算平均动脉压和是否正常范围这两个值界面展示时会用到直接放在模型里比放在 UI 逻辑里干净很多。注意医学范畴的“正常血压”有更严谨的定义这里只是做逻辑演示真实产品里应该由专业医学指南来定义判定规则。时间戳这里我直接用millisecondsSinceEpoch存不要存格式化字符串否则后续做按天、按周分组统计时会非常痛苦。格式化是展示层的事情存储层只处理最原始的数据。3.2 表单页实现数字输入、单位提示与交互反馈添加血压记录的页面是整个模块交互的核心表单校验做得好不好直接影响用户对产品的信任感。血压值不是随便填的收缩压正常区间在 90 到 140 mmHg舒张压在 60 到 90 mmHg心率在 50 到 100 次/分钟超出合理范围的输入必须阻止并给出明确提示。我用了FormTextFormFieldTextInputFormatter的组合数字键盘 只能输入数字的正则过滤从源头挡住非法字符import package:flutter/material.dart; import package:flutter/services.dart; class AddRecordPage extends StatefulWidget { const AddRecordPage({super.key}); override StateAddRecordPage createState() _AddRecordPageState(); } class _AddRecordPageState extends StateAddRecordPage { final _formKey GlobalKeyFormState(); final _systolicCtrl TextEditingController(); final _diastolicCtrl TextEditingController(); final _pulseCtrl TextEditingController(); final _noteCtrl TextEditingController(); override void dispose() { _systolicCtrl.dispose(); _diastolicCtrl.dispose(); _pulseCtrl.dispose(); _noteCtrl.dispose(); super.dispose(); } String? _validatePressure(String? value, int min, int max, String label) { if (value null || value.isEmpty) { return 请输入$label; } final number int.tryParse(value); if (number null) { return $label必须是数字; } if (number min || number max) { return $label需在$min-$max之间; } return null; } void _save() { if (!_formKey.currentState!.validate()) { return; } final record BloodPressureRecord( systolic: int.parse(_systolicCtrl.text), diastolic: int.parse(_diastolicCtrl.text), pulse: int.parse(_pulseCtrl.text), measuredAt: DateTime.now(), note: _noteCtrl.text.trim(), ); context.readBloodPressureProvider().addRecord(record); Navigator.of(context).pop(); } override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text(添加血压记录)), body: Form( key: _formKey, child: ListView( padding: const EdgeInsets.all(16), children: [ TextFormField( controller: _systolicCtrl, decoration: const InputDecoration( labelText: 收缩压, suffixText: mmHg, border: OutlineInputBorder(), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(3), ], validator: (v) _validatePressure(v, 60, 260, 收缩压), ), const SizedBox(height: 16), TextFormField( controller: _diastolicCtrl, decoration: const InputDecoration( labelText: 舒张压, suffixText: mmHg, border: OutlineInputBorder(), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(3), ], validator: (v) _validatePressure(v, 40, 200, 舒张压), ), const SizedBox(height: 16), TextFormField( controller: _pulseCtrl, decoration: const InputDecoration( labelText: 心率, suffixText: 次/分钟, border: OutlineInputBorder(), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(3), ], validator: (v) _validatePressure(v, 30, 200, 心率), ), const SizedBox(height: 16), TextFormField( controller: _noteCtrl, decoration: const InputDecoration( labelText: 备注可选, hintText: 比如晨起后测量, border: OutlineInputBorder(), ), maxLines: 2, ), const SizedBox(height: 32), FilledButton( onPressed: _save, child: const Text(保存记录), ), ], ), ), ); } }几个细节说明一下FilteringTextInputFormatter.digitsOnly直接过滤掉非数字字符用户输入不了字母和符号校验压力小很多。LengthLimitingTextInputFormatter(3)限制三位数字收缩压最大超不过 999配合范围校验双重兜底。suffixText在输入框尾部显示单位用户体验比把单位放在 label 里强很多一眼就能看懂填的是什么量纲。校验函数_validatePressure做成了通用工具收缩压、舒张压、心率共用一套逻辑只是传不同范围参数。页面写到这里已经可以交互了但它是“死”的点击保存只是跳走页面数据没地方去。接下来就要靠 Provider 了。3.3 Provider 状态管理为什么我不用 setState很多 Flutter 新手从 setState 开始学页面少的时候能用但一旦涉及跨页面数据共享setState 就非常难受。在血压记录这个场景里添加页保存了记录列表页要立刻更新历史记录详情要能查到同一条数据。如果每个页面各管一份数据凭空多出一堆同步逻辑。所以这里用 Provider 做全局状态管理这也是搜索“flutter provider 怎么用”时最典型的应用场景。Provider 是个纯 Dart 库不依赖 Android/iOS 原生通道所以在 OpenHarmony 的 Flutter 分支上可以放心用。整个实现分三步第一步定义 Provider。继承ChangeNotifier持有一份ListBloodPressureRecord提供增删方法import package:flutter/foundation.dart; import ../models/blood_pressure_record.dart; class BloodPressureProvider extends ChangeNotifier { ListBloodPressureRecord _records []; ListBloodPressureRecord get records List.unmodifiable(_records); void addRecord(BloodPressureRecord record) { _records.insert(0, record); notifyListeners(); } void removeRecord(String id) { _records.removeWhere((r) r.measuredAt.millisecondsSinceEpoch id); notifyListeners(); } void loadRecords(ListBloodPressureRecord records) { _records List.of(records); notifyListeners(); } }这里返回List.unmodifiable(_records)是为了防止外部直接修改列表所有变更必须走 Provider 的方法保证状态流动是单向的、可控的。第二步在入口用MultiProvider注册。不止一个状态的时候MultiProvider比层层嵌套ChangeNotifierProvider好看得多import package:flutter/material.dart; import package:provider/provider.dart; import providers/blood_pressure_provider.dart; import pages/home_page.dart; void main() { runApp( MultiProvider( providers: [ ChangeNotifierProvider(create: (_) BloodPressureProvider()), ], child: const HealthApp(), ), ); } class HealthApp extends StatelessWidget { const HealthApp({super.key}); override Widget build(BuildContext context) { return MaterialApp( title: 健康记录, theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true, ), home: HomePage(), ); } }第三步在具体页面里使用。关键点在context的使用时机很多人在这里踩坑。添加页保存时用context.readBloodPressureProvider().addRecord(record)因为它是一次性动作只需要读取 Provider 实例不需要监听。列表页展示时用context.watchBloodPressureProvider()因为列表需要响应数据变化每次notifyListeners()被调用列表页就会重新构建。我的列表页核心逻辑import package:flutter/material.dart; import package:provider/provider.dart; import ../providers/blood_pressure_provider.dart; import ../models/blood_pressure_record.dart; class RecordList extends StatelessWidget { const RecordList({super.key}); override Widget build(BuildContext context) { final provider context.watchBloodPressureProvider(); final records provider.records; if (records.isEmpty) { return const Center( child: Text(还没有血压记录点击右下角按钮添加), ); } final recent records.take(3).toList(); final avgSystolic recent.foldint(0, (sum, r) sum r.systolic) ~/ recent.length; final avgDiastolic recent.foldint(0, (sum, r) sum r.diastolic) ~/ recent.length; return Column( children: [ Padding( padding: const EdgeInsets.all(16), child: Card( child: Padding( padding: const EdgeInsets.all(16), child: Row( mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _MetricItem(label: 平均收缩压, value: $avgSystolic mmHg), _MetricItem(label: 平均舒张压, value: $avgDiastolic mmHg), ], ), ), ), ), Expanded( child: ListView.builder( itemCount: records.length, itemBuilder: (context, index) { final record records[index]; return _RecordCard(record: record); }, ), ), ], ); } }这套逻辑清晰地解释了“flutter 组件通信”最核心的问题状态提升 共享数据源。页面之间不直接通信而是通过共同的 Provider 中转。这也解决了flutter 组件通信里经常被问到的“ListView 点击某个 item 更新另一个组件”的问题本质就是让两个组件监听同一个状态。3.4 数据持久化先本地存再谈云同步Flutter 原生的shared_preferences插件依赖于 Android/iOS 的原生实现在 OpenHarmony 上能不能直接用取决于适配版分支有没有把对应平台通道接上。我当时的方案比较稳妥数据量不大时用 shared_preferences 的 OpenHarmony 适配版本把记录序列化成 JSON 数组存读出来再反序列化成对象。如果数据量大了、要按时间区间查询了再考虑接 SQLite 或者 OpenHarmony 的分布式数据库。我写了一个简单的LocalStore封装主要解决两个问题读取时 JSON 解析容错写入时同步阻塞还是异步落盘。import dart:convert; import package:shared_preferences/shared_preferences.dart; import ../models/blood_pressure_record.dart; class LocalStore { static const _key blood_pressure_records; static const _maxRecords 500; static FutureListBloodPressureRecord loadRecords() async { final prefs await SharedPreferences.getInstance(); final jsonString prefs.getString(_key); if (jsonString null || jsonString.isEmpty) { return []; } try { final list jsonDecode(jsonString) as Listdynamic; return list .map((e) BloodPressureRecord.fromMap(e as MapString, dynamic)) .toList(); } catch (_) { return []; } } static Futurevoid saveRecords(ListBloodPressureRecord records) async { final prefs await SharedPreferences.getInstance(); final limited records.take(_maxRecords).toList(); final jsonString jsonEncode( limited.map((r) r.toMap()).toList(), ); await prefs.setString(_key, jsonString); } }_maxRecords 500是容量保护防止记录无限膨胀把 SharedPreferences 撑爆。每条记录包含时间戳和备注纯文本 JSON500 条也就几十 KB完全可控。真到了 500 条之后我会引导用户做数据导出或清理那已经是另一个功能了。在 Provider 里把加载和保存串起来启动时加载变更时保存class BloodPressureProvider extends ChangeNotifier { ListBloodPressureRecord _records []; Futurevoid init() async { _records await LocalStore.loadRecords(); notifyListeners(); } Futurevoid addRecord(BloodPressureRecord record) async { _records.insert(0, record); notifyListeners(); await LocalStore.saveRecords(_records); } }启动时在 main 里调一次provider.init()数据就自动从磁盘恢复了。这里要注意SharedPreferences的读写是异步的如果用户快速添加多条记录理论上会有并发写覆盖的问题。我的经验是本地单机场景用户操作频率不会高到触发竞态真在意的话可以做一个简单的写入队列串行化写操作。3.5 记录列表与趋势展示列表展示阶段我做了两层信息顶部是三天的平均血压卡片下面是每条记录的完整卡片。平均血压其实不算复杂逻辑就是取最近三条记录求算术平均但用户看到“平均收缩压 118 mmHg”这种信息时会觉得这个 App 不是简单的记账本而是有基础分析能力的。单条记录卡片我用_RecordCard组件实现卡片左侧是日期时间中间是收缩压/舒张压的/形式右侧是心率。正常的记录用常规底色超出正常范围的记录用浅红色底纹标注一眼就能看出异常class _RecordCard extends StatelessWidget { final BloodPressureRecord record; const _RecordCard({required this.record}); override Widget build(BuildContext context) { final normal record.isNormal; return Card( color: normal ? null : Colors.red.shade50, child: ListTile( title: Text(${record.systolic}/${record.diastolic} mmHg), subtitle: Text( 心率 ${record.pulse} 次/分钟\n${record.note}, ), trailing: Text( ${record.measuredAt.month}-${record.measuredAt.day} ${record.measuredAt.hour}:${record.measuredAt.minute.toString().padLeft(2, 0)}, ), onLongPress: () _showDeleteDialog(context), ), ); } }删除交互我用的是长按弹出确认框。这里有个产品细节像血压记录这种健康数据用户误删的代价很高所以删除必须二次确认绝对不能滑动直接删。这是我做健康类产品的一个坚持。4. 实战踩坑与排查实录4.1 Flutter for OpenHarmony 编译与启动报错速查我在真机调试过程中整理了几个典型报错几乎每个 Flutter 开发者最终都会遇到。为了节省大家的时间直接做成速查表。报错特征根因解决方案you are applying flutters main gradle plugin imperatively using the applyGradle 插件应用方式过时在 settings.gradle 中用plugins { id dev.flutter.flutter-plugin-loader }标准方式声明删除 build.gradle 里的 apply 语句e/flutter unhandled exception dart_vm_initializer插件注册失败 / SDK 版本不匹配检查 pubspec 依赖是否为 OpenHarmony 适配版清理 build 目录重新编译真机安装后秒退ABI 架构不匹配检查是否缺少对应设备的 so 库确认 Flutter 分支对目标 CPU 架构支持界面中文显示为方块字体资源未打包在工程中显式配置中文字体文件避免依赖系统字体项目跑不起来的情况下第一步永远是看完整日志而不是搜报错关键句。很多报错的根因和表现症状相差十万八千里日志里往往前几行就有真正的线索。我见过太多人盯着Unhandled Exception查半天结果根因是某个插件版本不兼容。补充一个习惯每次改完依赖锁文件第一时间flutter clean再跑能够规避很多“莫名其妙”的启动失败。4.2 Provider 与组件通信的常见坑Provider 使用过程中遇到的坑大多集中在context的误用上。最常见的错误是在build方法里调用context.read去读取数据做异步操作或者在dispose之后再去访问 Provider 实例。另外一个高频问题MultiProvider的位置。ChangeNotifierProvider必须在MaterialApp外层或者至少是home的祖先级不然页面里context.watch找不到 Provider 会直接抛异常。如果出现ProviderNotFoundException先看提供者在组件树上是不是被包在正确位置。组件通信也是这样父组件数据往子组件传用构造参数跨层级共享用 Provider这个边界要清晰。我常用的排查技巧在 Provider 的构造方法里打印日志确认它被实例化的时机在notifyListeners()前故意打一个断点看数据变化的调用链路。这两个方法基本能解决 80% 的状态问题。4.3 真机调试与发布阶段的小经验OpenHarmony 真机调试和 Android 略不一样连接设备后先用hdc list targets确认设备在线。日志方面hilog和 Flutter 的print输出位置不同调试时开着 DevEco Studio 的 Log 面板同时开 Flutter 的 log 面板两边对照看。我之前调试血压保存逻辑时Flutter 侧只打印到Saved但存储实际失败了最后是看 DevEco 侧的进程日志才发现是权限问题。OpenHarmony 对文件读写、数据持久化有权限管控manifest 里缺了权限声明数据写入可能被静默丢弃很坑。发布阶段的注意事项App 图标、应用名称、版本号在 DevEco 工程里配置签名证书要提前申请。OpenHarmony 应用发布审核没有安卓那么复杂但对隐私权限的描述要求很严格健康类应用尤其如此涉及血压数据属于敏感健康信息在产品里要做隐私政策提示在代码层面不要做任何未经用户授权的数据上报。作为开发者这一点要有底线意识。一点个人体会把血压记录这个模块完整跑通之后我最大的感受是Flutter 在 OpenHarmony 上的成熟度比网上的评价要乐观但你需要接受“官方文档不够集中”的现实。大多数适配细节要靠自己读源码、看 issue、跑 demo 来摸索。这个项目的核心逻辑也就是数据模型 表单校验 Provider 状态管理 本地存储的组合放到任何健康记录类 App 上都能复用。后续如果再做血糖记录、体重记录直接把 BloodPressure 这一套模型和页面复制改造一下一两天就能推一个新模块出来。最后分享一个小技巧在 Provider 里写loadRecords和saveRecords时可以先不接存储用内存数据跑通整个 UI 流程确认交互逻辑没问题再接持久化。这样调试的时候不用反复清数据开发效率能高不少。健康类 App 的开发功能可以慢慢加但数据准确性和操作安全性这两件事从一开始就要放在第一位。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →