
最近在做一款身体健康状况记录 App目标是让用户每天记录饮水、睡眠、体重和运动情况。技术路线当时没怎么纠结直接定了 Flutter 跨端方案——团队本来就有不少 Flutter 代码沉淀后面如果要覆盖 Android 和 iOS这一套还能继续复用现在多适配一个 OpenHarmony 平台边际成本是可控的。第一个跑通的模块就是饮水记录从工程初始化到数据库选型、再到图表统计中间踩了不少 OpenHarmony 适配的坑。这篇就把“添加饮水记录”这整个功能从设计到落地的过程完整拆开代码和思路都放出来给正在做同类 App 的同学一个可以直接参考的路径。1. 为什么选 Flutter 在 OpenHarmony 上做健康记录类 App1.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, }); MapString, dynamic toJson() { id: id, recordTime: recordTime.millisecondsSinceEpoch, amountMl: amountMl, drinkType: drinkType, }; factory WaterRecord.fromJson(MapString, 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 抽象层给未来留条后路存储层一定要抽象出来不要直接在页面里操作数据库。我定义了一个 interfaceabstract class WaterRecordRepository { Futurevoid insert(WaterRecord record); FutureListWaterRecord getByDate(String dateKey); FutureMapString, int getGroupedTotal({DateTime start, DateTime end}); Futurevoid delete(int id); }HiveWaterRecordRepository 实现这个接口。以后如果数据量大了要换 sqlite或者要多端同步只需要新写一个实现类页面代码完全不用动。日期 key 的生成也留个心。直接用时间戳做日期比较在纯内存里还好一旦存到数据库时区就很容易出问题。我统一用本地时间生成日期 keyString 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 的机制简单直接成员上手成本低。创建一个 WaterRecordModelclass WaterRecordModel extends ChangeNotifier { final WaterRecordRepository _repository; ListWaterRecord _todayRecords []; int _todayTotalMl 0; int targetMl 2000; WaterRecordModel(this._repository); ListWaterRecord 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); } Futurevoid refreshToday() async { final today dateKey(DateTime.now()); _todayRecords await _repository.getByDate(today); _todayTotalMl _todayRecords.fold(0, (sum, r) sum r.amountMl); notifyListeners(); } Futurevoid addRecord(int ml, {String type water}) async { if (ml 0) return; await _repository.insert(WaterRecord( recordTime: DateTime.now(), amountMl: ml, drinkType: type, )); await refreshToday(); } Futurevoid 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 作为目标值自动补间TweenAnimationBuilderdouble( 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 里加了撤销按钮Futurevoid _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 天的MapdateKey, totalMlFutureMapString, int getGroupedTotal({required DateTime end, int days 7}) async { final result String, int{}; final box await Hive.openBoxMap(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.castMap(); 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 不等于 adbOpenHarmony 调试习惯要改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 问题。这张表格做在前面能帮你避开最浪费时间的中途换方案。我这套饮水记录模块后面又跑出了几个版本的迭代现在看当初先把这些前期准备工作做扎实是效率最高的决定。