最近在做一款身体健康状况记录 App,目标是让用户每天记录饮水、睡眠、体重和运动情况。技术路线当时没怎么纠结,直接定了 Flutter 跨端方案——团队本来就有不少 Flutter 代码沉淀,后面如果要覆盖 Android 和 iOS,这一套还能继续复用,现在多适配一个 OpenHarmony 平台,边际成本是可控的。第一个跑通的模块就是饮水记录,从工程初始化到数据库选型、再到图表统计,中间踩了不少 OpenHarmony 适配的坑。这篇就把“添加饮水记录”这整个功能从设计到落地的过程完整拆开,代码和思路都放出来,给正在做同类 App 的同学一个可以直接参考的路径。
1. 为什么选 Flutter 在 OpenHarmony 上做健康记录类 App
1.1 OpenHarmony 生态下的应用开发现状
OpenHarmony 设备这几年的增长速度很快,不只是开发板、智能家居,一些面向消费者的移动设备也陆续在搭载。但客观讲,它的应用生态还在建设期,原生应用数量和质量跟 Android 相比有差距。对个人开发者和小团队来说,这个阶段反而是机会——越早进入,越容易吃到生态早期的流量红利。
问题在于,OpenHarmony 原生开发用的是 ArkTS 和 ArkUI,虽然语法和主流前端框架有相似之处,但整个技术栈是独立的。如果团队已经有 Flutter 代码资产,或者团队成员对 Flutter 更熟,从零转向 ArkTS 的代价不低。这时候用 Flutter 跑 OpenHarmony 就是一个很自然的折中方案:业务代码不变,只换宿主工程和一部分平台通道的适配。
1.2 健康记录类 App 的场景特征与 Flutter 的契合点
健康记录类 App 有很明显的特点:UI 组件密集、交互反馈多、需要大量图表和动画、强依赖本地数据持久化。这些场景恰好是 Flutter 的舒适区。
饮水记录这个功能尤其典型。它的业务闭环短:用户点一下加号,选择 200ml 或 300ml,页面上的进度环马上更新,当天累计量跟着变,历史记录里多一条。整个过程数据流向清晰,非常适合用来验证 Flutter 在 OpenHarmony 上的整体链路是否打通。
另外,健康记录 App 天然要做趋势反馈,用户坚持记录的动机来源于看到变化——这周喝水比上周规律了、每天摄入量更稳定了。图表绘制和动画渲染在 Flutter 里有一套成熟生态,这对产品留存很重要。
1.3 原生 ArkUI 与 Flutter 在这个项目里的取舍
做这个选择的时候我列过一个简单对比,不吹不黑,两边各有优势:
| 维度 | Flutter 方案 | 原生 ArkUI 方案 |
|---|---|---|
| 跨端复用 | 一套代码可覆盖 Android/iOS/OpenHarmony | 只面向 OpenHarmony |
| 图表与动画生态 | fl_chart 等成熟库可用,上手快 | 需要自己绘制或找组件库 |
| 插件生态 | 存量 Flutter 插件多,但对 OpenHarmony 适配参差不齐 | 原生能力齐全,生态还比较薄 |
| 学习成本 | 取决于团队现有 Flutter 基础 | 需要重新学 ArkTS/ArkUI |
| 性能 | 自绘引擎,UI 流畅度好,包体积偏大 | 原生渲染,启动更快,包更小 |
最终我选了 Flutter,核心原因就是想保住跨端能力。但我必须提醒:如果项目只做 OpenHarmony 单平台,团队又愿意投入学 ArkTS,那原生方案其实是更稳的,省掉一堆插件适配的事。Flutter 的优势在于“多端一致”,而不是“单端最优”。
2. 开工前的环境准备:Flutter for OpenHarmony 版本选型与工程初始化
2.1 版本选型是第一个大坑
你以为装个最新版 Flutter 就能跑 OpenHarmony?不是。官方稳定版 Flutter SDK 是不带 ohos 平台支持的,必须在 OpenHarmony 社区维护的 Flutter SDK 分支里选一个版本,然后再跟 OpenHarmony SDK 版本对应起来。
这一步千万不能图省事直接拉最新的分支,而是要查清楚版本对应关系。我当时就是装了某个新分支,结果跟手里的 OpenHarmony SDK 版本对不上,编译时报了一堆 NDK 和 API level 的错误,排查了很久才发现是版本矩阵的问题。
建议流程:
- 先确认设备或模拟器上的 OpenHarmony SDK 版本
- 查社区维护的 Flutter SDK 分支版本,找到与之匹配的 Flutter 版本
- 把本机 PATH 路径切到对应的 Flutter SDK 目录,执行
flutter --version验证 - 改完环境变量记得新开终端,旧终端的 PATH 是缓存的,这也是很多人遇到“命令找不到”的原因
2.2 创建支持 ohos 平台的 Flutter 工程
环境就绪后,创建工程很简单:
flutter create --platforms ohos health_app注意,这个命令生成的工程结构里会多出一个ohos/目录,它就相当于 Android 里的android/目录、iOS 里的ios/目录,是 OpenHarmony 的宿主工程。业务代码全部在lib/里,这个结构和原来的 Flutter 工程完全一样。
health_app/ ├── android/ ├── ios/ ├── ohos/ ├── lib/ │ ├── main.dart │ ├── models/ │ ├── repositories/ │ ├── pages/ │ └── widgets/ ├── pubspec.yaml └── ...2.3 真机运行与常见的初始化报错
OpenHarmony 设备不用 adb,用 hdc。连接设备后:
hdc list targets flutter devices flutter run -d <device-id>如果flutter devices里看不到设备,先确认 hdc 有没有识别,再用 DevEco Studio 打开ohos/宿主工程,跑一次原生构建,确认宿主工程没问题后再回到 Flutter 侧flutter run。
我还遇到一个很常见的问题:flutter pub get卡住或者依赖拉不下来。这通常是网络环境导致的,优先检查PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL这两个环境变量有没有配置对,同时确认pubspec.yaml里各依赖包的版本有没有冲突。Flutter 版本不一致导致的依赖解析失败,报错信息里一般会提示得比较明确,按提示锁版本就行。
3. 饮水记录的数据模型与本地存储方案选型
3.1 先拆业务需求,再谈技术设计
饮水记录这个功能看起来简单,认真想还是有不少细节。我拆出来的核心需求清单:
- 记录一次饮水行为:时间、容量、饮品类型(白水/茶水/咖啡等)
- 设置每日目标饮水量,默认 2000ml
- 实时计算今日已饮总量和进度百分比
- 按日期查看历史饮水记录
- 统计最近 7 天或 30 天的饮水趋势
这里面最容易忽略的是“今日”这个概念。如果用户晚上 11 点喝水,凌晨 1 点又喝了一杯,这两杯水必须划到不同日期,不能简单用时间戳当 key。
3.2 sqflite 在 OpenHarmony 上的现实问题
在 Android 上做本地数据库,第一反应是 sqflite。但放到 OpenHarmony 平台,情况就变了:sqflite 依赖 sqlite 的原生插件,而原生插件的 OpenHarmony 实现不一定跟你的 Flutter 分支完美匹配。我试过几个声称适配 ohos 的版本,有的编译能过但运行时崩溃,有的干脆编译期就报错。
这不是说 OpenHarmony 上不能用 sqlite,而是普通 Flutter 开发者直接拿来用的风险偏高。你得花时间确认版本兼容性,甚至可能要自己改源码重新编译,性价比比较低。
3.3 我最终采用的存储方案:hive 加 repository 抽象
综合考虑后,第一阶段我选了 hive——一个纯 Dart 实现的 NoSQL 数据库。它的核心优势是没有原生的平台通道依赖,理论上只要能拿到文件目录,OpenHarmony 上就能跑。
数据模型先定义清楚:
class WaterRecord { final int? id; final DateTime recordTime; final int amountMl; final String drinkType; WaterRecord({ this.id, required this.recordTime, required this.amountMl, this.drinkType = 'water', }); Map<String, dynamic> toJson() => { 'id': id, 'recordTime': recordTime.millisecondsSinceEpoch, 'amountMl': amountMl, 'drinkType': drinkType, }; factory WaterRecord.fromJson(Map<String, dynamic> json) => WaterRecord( id: json['id'] as int?, recordTime: DateTime.fromMillisecondsSinceEpoch(json['recordTime'] as int), amountMl: json['amountMl'] as int, drinkType: json['drinkType'] as String? ?? 'water', ); }hive 本身可以存对象,但是要注册 TypeAdapter。我图省事,直接在 repository 层把 WaterRecord 序列化成 Map,再存 hive 的 box。
关键点:hive 初始化的时候需要一个可写的目录。在 OpenHarmony 上path_provider这类插件不一定有适配好的版本,我当时的兜底方案是在原生侧用 MethodChannel 暴露一个获取应用文件目录的方法,Flutter 侧收到目录后再调Hive.init()。这个思路后面第 6 章详细讲。
3.4 Repository 抽象层:给未来留条后路
存储层一定要抽象出来,不要直接在页面里操作数据库。我定义了一个 interface:
abstract class WaterRecordRepository { Future<void> insert(WaterRecord record); Future<List<WaterRecord>> getByDate(String dateKey); Future<Map<String, int>> getGroupedTotal({DateTime start, DateTime end}); Future<void> delete(int id); }HiveWaterRecordRepository 实现这个接口。以后如果数据量大了要换 sqlite,或者要多端同步,只需要新写一个实现类,页面代码完全不用动。
日期 key 的生成也留个心。直接用时间戳做日期比较在纯内存里还好,一旦存到数据库,时区就很容易出问题。我统一用本地时间生成日期 key:
String dateKey(DateTime dt) { final local = dt.toLocal(); return '${local.year}-${local.month.toString().padLeft(2, '0')}-${local.day.toString().padLeft(2, '0')}'; }这样跨天、跨月、跨年都不会乱。
4. 添加饮水交互与状态管理实现细节
4.1 页面结构:顶部进度环、快捷按钮、今日列表
饮水首页从上到下分三块:
- 顶部:今日饮水进度环,中间显示“已饮 / 目标”毫升数
- 中部:快捷添加按钮(200ml、300ml、500ml)加一个自定义输入入口
- 底部:今日饮水记录列表,按时间倒序排列
这个布局不复杂,但信息层级很清晰:用户一眼看到目标差距,然后能快速操作,最后能查看明细。
4.2 状态管理:Provider 与 ChangeNotifier
项目规模不大,状态管理选了 Provider。它是 Flutter 社区最普及的方案,ChangeNotifier 的机制简单直接,成员上手成本低。
创建一个 WaterRecordModel:
class WaterRecordModel extends ChangeNotifier { final WaterRecordRepository _repository; List<WaterRecord> _todayRecords = []; int _todayTotalMl = 0; int targetMl = 2000; WaterRecordModel(this._repository); List<WaterRecord> get todayRecords => List.unmodifiable(_todayRecords); int get todayTotalMl => _todayTotalMl; double get progress { if (targetMl <= 0) return 0; return (_todayTotalMl / targetMl).clamp(0.0, 1.0); } Future<void> refreshToday() async { final today = dateKey(DateTime.now()); _todayRecords = await _repository.getByDate(today); _todayTotalMl = _todayRecords.fold(0, (sum, r) => sum + r.amountMl); notifyListeners(); } Future<void> addRecord(int ml, {String type = 'water'}) async { if (ml <= 0) return; await _repository.insert(WaterRecord( recordTime: DateTime.now(), amountMl: ml, drinkType: type, )); await refreshToday(); } Future<void> deleteRecord(int id) async { await _repository.delete(id); await refreshToday(); } }进度计算注意边界:目标为 0 时直接返回 0,避免除零;进度值用 clamp 限制在 0 到 1 之间,防止动画溢绘。
4.3 CustomPainter 自绘进度环
进度环不用第三方圆形进度条插件,直接 CustomPaint 自绘。原因有二:第三方插件在 OpenHarmony 的渲染兼容性要实测,自绘完全可控;而且饮水进度环要的是“从 0 增长到当前位置”的动效,自绘好做渐变和动画。
核心 painter 长这样:
class WaterProgressPainter extends CustomPainter { final double progress; final Color progressColor; final Color trackColor; WaterProgressPainter({ required this.progress, required this.progressColor, required this.trackColor, }); @override void paint(Canvas canvas, Size size) { final strokeWidth = 14.0; final rect = Rect.fromLTWH( strokeWidth / 2, strokeWidth / 2, size.width - strokeWidth, size.height - strokeWidth, ); final trackPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..color = trackColor; canvas.drawArc(rect, 0, 2 * pi, false, trackPaint); final progressPaint = Paint() ..style = PaintingStyle.stroke ..strokeWidth = strokeWidth ..strokeCap = StrokeCap.round ..shader = SweepGradient( startAngle: 0, endAngle: pi * 2, colors: [progressColor.withOpacity(0.4), progressColor], ).createShader(rect); canvas.drawArc(rect, -pi / 2, 2 * pi * progress, false, progressPaint); } @override bool shouldRepaint(covariant WaterProgressPainter oldDelegate) { return oldDelegate.progress != progress; } }进度环的起始角度设在 -90 度,也就是正上方,这样视觉上符合“从零开始灌水”的直觉。动画用 TweenAnimationBuilder 包裹,把 progress 作为目标值,自动补间:
TweenAnimationBuilder<double>( tween: Tween(begin: 0, end: model.progress), duration: Duration(milliseconds: 600), builder: (context, value, child) { return CustomPaint( painter: WaterProgressPainter( progress: value, progressColor: Colors.blueAccent, trackColor: Colors.blueGrey.withOpacity(0.2), ), child: SizedBox( width: 180, height: 180, child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Text('${model.todayTotalMl} / ${model.targetMl} ml', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold)), Text('${(model.progress * 100).toStringAsFixed(0)}%', style: TextStyle(fontSize: 14, color: Colors.grey)), ], ), ), ), ); }, )4.4 快捷添加按钮与自定义水量输入
三个快捷按钮 200ml、300ml、500ml 用 Row + Expanded 等宽布局。按钮高度我做到 64dp,比默认的按钮高不少。健康类 App 的使用场景很多是运动刚结束或者烧水壶旁边单手操作,点击区域大一点,误触率会低很多。
自定义添加弹一个底部弹窗或者 AlertDialog,里面有数字输入框和加减号。校验逻辑很简单:范围限制在 1 到 2000ml 之间,不允许负数,不允许 0。数字键盘弹出来后,默认聚焦输入框,减少一次点击。
4.5 记录列表与删除撤销
列表用 ListView.separated,每一项显示时间(HH:mm)、类型标签、容量。每条记录右侧放一个删除图标。删除操作一定要给撤销机会——用户很容易误触,而且健康记录这种数据,丢了很难找回。我直接在 SnackBar 里加了撤销按钮:
Future<void> _handleDelete(WaterRecord record) async { final id = record.id; if (id == null) return; final deleted = await model.deleteRecord(id); if (deleted && context.mounted) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar( content: Text('已删除本次饮水记录'), action: SnackBarAction( label: '撤销', onPressed: () => model.restore(record), ), )); } }为了支持撤销,WaterRecordModel 里加一个_lastDeletedRecord字段,restore()重新插入这条记录。这里要注意,restore 的recordTime必须保持原样,不能改成当前时间,否则数据语义就错了。
4.6 空态提示
今日列表为空时,不要留白,显示一个友好的空态:“今天还没有喝水记录,点击上面的按钮记录第一杯吧”。配合一个淡色的水滴图标。空态是很多初级开发者容易忽略的细节,但对健康 App 来说,用户第一次打开看到的就是这个页面,质感全在细节里。
5. 历史统计图表与数据聚合的落地
5.1 聚合查询:最近 7 天按日分组
历史趋势是这个功能最有价值的部分。用户记录两三天的水,可能还没什么感觉,但看到 7 天柱状图,发现自己周末喝水明显偏少,才会有意识地调整。
repository 里提供 getGroupedTotal 方法,返回从指定日期往前 N 天的Map<dateKey, totalMl>:
Future<Map<String, int>> getGroupedTotal({required DateTime end, int days = 7}) async { final result = <String, int>{}; final box = await Hive.openBox<Map>('water_records'); final startDate = end.subtract(Duration(days: days - 1)); final startKey = dateKey(startDate); final endKey = dateKey(end); await for (final entry in box.watch()) { /* 略 */ } // 实际读取所有记录后按日期 key 累加 for (var i = 0; i < days; i++) { final d = startDate.add(Duration(days: i)); result[dateKey(d)] = 0; } final items = box.values.cast<Map>(); for (final item in items) { final key = dateKey(DateTime.fromMillisecondsSinceEpoch(item['recordTime'] as int)); if (result.containsKey(key)) { result[key] = result[key]! + (item['amountMl'] as int); } } return result; }这段逻辑里特别注意:先把最近 7 天的日期 key 全部生成好并初始化为 0,再遍历数据累加。这样即使某天没有记录,图表上也会显示 0,而不会出现“日期缺失”导致柱状图对不齐的问题。
5.2 fl_chart 在 OpenHarmony 上的适配验证
图表库选的是 fl_chart。它本身是纯 Dart 绘制,不依赖原生组件,所以在 OpenHarmony 上的兼容性比那些要原生 View 的图表库好很多。实际跑下来,BarChart 和 LineChart 的核心功能正常,动画也流畅。
柱状图展示最近 7 天:
BarChart( BarChartData( alignment: BarChartAlignment.spaceAround, maxY: (maxDailyMl + 500).toDouble(), titlesData: FlTitlesData( bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, getTitlesWidget: (value, meta) { final date = DateTime.now().subtract(Duration(days: 6 - value.toInt())); return Text('${date.month}/${date.day}'); }, ), ), ), barGroups: entries, ), )这里有一个实际调整:默认的横轴标签显示的是索引,我需要显示日期,所以通过getTitlesWidget根据下标反算日期。这个映射要跟 repository 返回的 Map 顺序严格一致,否则标签和柱形对不上。
5.3 折线图加目标线
除了柱状图,我还加了一个折线图展示连续多天的摄入量,配合一条 2000ml 的目标线。目标线用 ExtraLinesData 实现:
LineChartData( extraLinesData: ExtraLinesData( horizontalLines: [ HorizontalLine( y: 2000, color: Colors.orange, strokeWidth: 1.5, dashArray: [6, 4], label: HorizontalLineLabel( show: true, labelResolver: (_) => '目标 2000ml', ), ), ], ), lineBarsData: [...], )虚线目标线的作用是让用户一眼看出“我今天有没有及格”,比单纯看柱子高度直观得多。
5.4 图表页面的空态与边界
图表数据全为 0 时,最好显示占位提示,而不是让一个空坐标轴杵在页面上。我是在计算完 totalMl 后加个判断:如果 7 天总量为 0,直接显示“还没有足够的数据生成趋势图,快去记录第一杯水吧”。
另外,柱状图点击柱子下钻到当天明细,这是一个加分项。fl_chart 的 BarTouchTooltipData 可以配置点击回调,拿到柱形索引后,对应到具体的日期,跳到该日期的记录详情页。下钻功能让“趋势图”和“记录列表”产生了联动,产品体验会完整很多。
6. 真机调试踩坑、性能优化与后续扩展
6.1 hdc 不等于 adb:OpenHarmony 调试习惯要改
OpenHarmony 的工具链和 Android 不同,调试命令要切换到 hdc。几个常用命令我列一下:
hdc list targets # 查看连接设备 hdc hilog # 查看设备日志 hdc install <path.hap> # 安装应用包 hdc shell # 进入设备 shellFlutter 层面,flutter run -d <device-id>依然好用,但遇到 run 不上去的情况,第一反应不要是重装 Flutter,而是先用 DevEco Studio 打开ohos/宿主工程,单独编译安装一次 HAP 包。原生宿主工程能装上,再回到 Flutter 侧flutter attach连接调试。排查问题时要一层层分段定位,先确认原生侧通不通,再谈 Dart 侧。
6.2 插件缺失与 MissingPluginException 的兜底策略
这是 Flutter 开发 OpenHarmony 最头疼的问题。很多 Flutter 插件只实现了 Android 和 iOS 的平台通道,OpenHarmony 上没有对应的原生实现。一调用就抛 MissingPluginException。
我的兜底策略分三层:
- 优先找 ohos 适配版插件。社区里确实有一些插件的 ohos fork,比如偏好存储、路径获取、网络请求等基础能力都有替代品。
- 换纯 Dart 方案。像本地数据库这种基础能力,能不用原生插件就不用,hive 就是典型例子。
- 自己写 MethodChannel。必须用原生能力的场景,比如相册、支付、传感器,就只能自己写桥接。
自己写桥接没有想象中复杂。原生侧在 MainAbility 的入口注册一个 Channel,处理 Flutter 侧发来的方法调用。比如获取应用文件目录:
// OpenHarmony 原生侧示意 import { rpc } from '@ohos.rpc'; class AppInfoRemoteObject extends rpc.RemoteObject { onRemoteMessageRequest(method: string, data: rpc.MessageParcel, reply: rpc.MessageParcel): boolean { if (method === 'getFilesDir') { reply.writeString(this.filesDir); return true; } return false; } }Flutter 侧:
const channel = MethodChannel('com.example.health_app/app_info'); final String filesDir = await channel.invokeMethod('getFilesDir');桥接需要注意:Channel 名称和方法名的拼写必须完全一致,大小写都不能错,Flutter 侧和原生侧任何一处不一致都会导致 MethodChannel 调用失败。排错时先打印日志,确认方法有没有被分发到。
6.3 性能优化:进度环动画与列表渲染
上面提到的进度环,如果每次页面刷新都重建整个页面,低端设备上会有掉帧感。优化思路是让动画只发生在 CustomPaint 那一层:
- 进度环播放动画时用 Transition 组件配合 AnimatedBuilder,把动画值粒化到绘制层
- 列表项用
const构造函数,减少不必要的 widget 重建 - 固定列表项高度,用
itemExtent减少滚动时的测量计算 - 给列表包
RepaintBoundary,避免列表滚动时和进度环动画相互影响重绘
写入频繁时的性能也要注意。hive 的写入虽然比普通文件 IO 快,但在用户连续点击添加按钮时,短时间内会有大量写入操作。我做了个简单的防抖:在 addRecord 里先更新内存状态,立即 notifyListeners 刷新 UI,然后异步写库。这样界面响应是即时的,写入排队处理,用户感知不到卡顿。
6.4 OpenHarmony 特有的 UI 适配细节
有几个 UI 细节藏在细节里:
- 安全区:OpenHarmony 设备存在打孔屏、挖孔屏和底部手势条,页面必须用 SafeArea 包一层,否则进度环可能被摄像头孔挡住。
- 字体与行距:OpenHarmony 的默认字体和 Android 不完全一样,中文场景下建议在 ThemeData 里统一指定 fontFamily,避免不同设备显示效果漂移。
- 深色模式:Material3 的 ColorScheme.fromSeed 可以自动生成深色配色,但饮水进度环比方的浅色、深色背景色要提前验证对比度,不要用硬编码的蓝色,否则深色模式下看不清。
6.5 下一步扩展:提醒、云同步、更多健康模块
饮水记录模块跑通后,这个架构可以直接复用到其他健康记录功能上。
定时提醒是最值得先做的:用户不记录,产品就没有数据。但通知能力需要原生侧支持,OpenHarmony 的通知服务要走原生接口,可以通过 MethodChannel 桥接。思路是先在原生侧注册定时通知,Flutter 侧配置提醒时间和重复规则。
云同步的方向,repo 抽象层已经铺好了路。只要新写一个远程 Repository,实现同样的接口,把本地数据同步到服务端,然后加个合并策略就行。本地优先、远程备份,这个模型比较适合健康记录 App。
睡眠、体重、运动这几个模块的数据模型结构跟饮水记录很接近,都是“时间 + 数值 + 类型”的格式,完全可以复用这套 repository 和列表展示模式。
最后再分享一个小技巧:进入 Flutter for OpenHarmony 项目之前,先花半天时间把计划用到的所有第三方插件列一张兼容性表格。每个插件查一下有没有 ohos 适配版本、作者有没有声明支持、issue 里有没有人反馈过 OpenHarmony 问题。这张表格做在前面,能帮你避开最浪费时间的中途换方案。我这套饮水记录模块后面又跑出了几个版本的迭代,现在看当初先把这些前期准备工作做扎实,是效率最高的决定。