做 OpenHarmony 应用开发,最头疼的往往不是业务逻辑,而是"界面怎么跑起来"这一层。尤其是团队里已经有 Flutter 技术积累的情况下,每个新系统都要维护一套原生代码,成本直接翻倍。我这次把 Flutter 的 OpenHarmony 分支用在了身体健康状况记录 App 上,专门把饮水记录模块完整做了一遍,从环境搭建、数据持久化到真机调试都踩过不少坑,这篇就把核心实现和排坑记录整理出来,给同样想用 Flutter 做 OpenHarmony 应用的朋友做个参考。
先说清楚这个 App 是干什么的。身体健康状况记录 App,核心场景是帮用户每天记录心率、血压、体重、睡眠、饮水这些健康指标,然后形成趋势图。饮水记录是其中最简单也最有代表性的模块:用户点按钮添加一杯水,系统记录时间和毫升数,首页展示今日累计饮水量和进度。麻雀虽小,但它覆盖了"UI 交互 + 本地存储 + 状态管理 + 页面刷新"这条完整链路,非常适合作为 Flutter 在 OpenHarmony 上落地的第一个实战模块。
1. 项目概述:为什么选 Flutter 来做 OpenHarmony 应用
1.1 这套技术组合怎么来的
OpenHarmony 官方主推的是 ArkTS + ArkUI 声明式开发,组件生态也在快速补齐,这个必须承认。但我接触到的实际业务团队里,不少核心开发是 Flutter 背景,仓库里已经有大量现成的 Dart 业务组件和状态管理逻辑。如果全部迁到 ArkTS,光把页面重写一遍就是两到三周的工作量,还没算上调试和回归测试的时间。
Flutter for OpenHarmony 走的是社区 SIG 维护路线,把 Flutter engine 的 OpenHarmony 后端补齐了,Dart 层 API 和上游保持基本一致。这意味着团队里熟悉 Flutter 的人几乎不需要重新学习,就能把应用跑到 OpenHarmony 设备上。实际用下来,这个分支的完成度比我预想的高,常规页面开发完全可行,甚至比一些早期预览版流畅得多。
1.2 饮水记录模块要解决什么问题
饮水记录功能表面上很简单,但真正做起来有几个核心问题绕不开:数据存哪里、怎么保证 App 杀掉重启后数据还在、今日累计值怎么实时刷新。这几个问题如果不在最开始设计好,后面加心率、血压模块时会非常痛苦。
我的做法是把它拆成三层:数据层负责 WaterRecord 模型的增删改查;逻辑层负责计算今日总饮水量、距离目标的进度;界面层拆成首页进度环和添加记录的快捷面板。这样模块之间边界清晰,后面加"饮水提醒"或者"周统计报表"时,只需要在对应层做扩展。
1.3 适合哪些人来参考
如果你正在评估 OpenHarmony 应用的技术选型,或者已经决定用 Flutter 但卡在环境搭建和真机运行上,这篇对你会很有用。我会把操作细节和坑点讲得比较细,就算你是第一次接触 OpenHarmony,跟着走也能把工程跑起来。如果你已经在做 OpenHarmony 原生开发,这篇也能帮你了解 Flutter 方案的数据存储和页面交互是怎么实现的,做个技术对比。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建与工程创建
2.1 Flutter 的 OpenHarmony 分支怎么配置
首先要明确一件事:官方 Flutter SDK 默认不支持 OpenHarmony,直接 flutter create 出来的工程没有 ohos 平台目录。必须使用 OpenHarmony 社区维护的 flutter_flutter 分支,并配合对应的 flutter_packages 仓库。
实际操作时我建议这样配置,先准备好两个目录:
bash复制# 克隆 OpenHarmony 的 Flutter SDK 分支
git clone https://gitee.com/openharmony-sig/flutter_flutter.git -b master
# 克隆插件仓库
git clone https://gitee.com/openharmony-sig/flutter_packages.git
然后把 flutter_flutter 的 bin 目录加到 PATH 里,和本机已有的标准 Flutter SDK 区分开。这里有个很容易踩的坑:如果你之前用过标准 Flutter,环境变量里可能已经有一个 flutter 指向了官方 SDK。我建议在 .bashrc 或 .zshrc 里单独加一个别名,比如 fho,专门指向 OpenHarmony 分支,避免两个 SDK 混用。
bash复制export PATH="$HOME/dev/flutter_flutter/bin:$PATH"
配置完之后跑一下 flutter doctor,如果看到 Flutter (Channel master, XX.XX.X) 并且底下有 OpenHarmony 相关的提示,说明 SDK 部分基本就绪。
2.2 创建项目和添加 ohos 平台目录
标准 Flutter 工程创建项目后,默认只有 android、ios、web 这些目录。要支持 OpenHarmony,需要执行社区提供的脚本或命令来生成 ohos 目录。不同版本方式不太一样,但大致思路是编译 tool 后执行:
bash复制flutter create --platforms ohos my_health_app
cd my_health_app
如果当前分支支持 --platforms ohos,会直接生成 ohos/ 目录。如果版本还没有这个参数,需要手动把 flutter_packages 里的模板工程拷过来,再修改包名和应用名。
生成后的工程结构大概是这样的:
code复制my_health_app/
├── lib/ # Dart 业务代码
├── ohos/ # OpenHarmony 原生工程
│ ├── entry/src/main/
│ │ ├── ets/ # ArkTS 入口与 UIAbility
│ │ └── resources/ # 应用图标、字符串资源
│ └── build-profile.json5
├── pubspec.yaml
└── ...
我实际开发时发现,ohos 目录里的 module.json5 需要手动改一下应用图标和应用名,否则安装到设备上会显示默认名字。这个细节文档里写得不多,但上线前肯定会遇到。
2.3 真机与模拟器的选择
OpenHarmony 目前主流的调试设备是 RK3568 开发板,也有 RK3588 这类性能更强的板子。社区热词里经常有人问"rk3568 有许多设备树到底咋选",我也在这上面卡过一段时间。
不同厂商的开发板,设备树 dtb 文件都不一样。选错 dtb 会导致启动卡在 logo 或者触摸屏没反应。我的经验是先去开发板厂商的文档里找到对应的固件版本,然后把板子接串口看启动日志,日志里通常会直接提示缺少哪个 dtb。如果没有串口,就一个个试,每次烧录后注意观察 HDMI 是否有输出、触摸是否生效。这个试错过程比较枯燥,但没什么捷径。
如果不想用开发板,OpenHarmony 也支持模拟器。不过模拟器对 GPU、宿主机内存要求较高,而且 Flutter 的渲染在模拟器上明显比真机卡。我自己的习惯是:日常页面布局调试用模拟器,涉及性能、传感器这类功能直接上真机。
3. 饮水记录的数据层设计
3.1 数据模型:一杯水需要记录什么
饮水记录看起来就是"记录喝水",但数据字段设计得合不合理,直接关系到后面的统计功能。我最开始只存了时间和毫升数,结果做"按杯型筛选"的时候发现根本没存杯型,只能把所有记录都当成普通杯。所以后来调整成了这样:
dart复制class WaterRecord {
final int? id; // 主键,自增
final int amount; // 饮水量,单位 ml
final DateTime time; // 记录时间
final String cupType; // 杯型:glass / bottle / custom
final String? note; // 备注,比如"饭后喝水"
}
amount 用 int 而不是 double,因为饮水记录不会出现小数毫升,避免浮点精度问题。time 存的是毫秒时间戳对应的 DateTime,查询某一天的记录时用 >= 当天零点 && < 次日零点 这个区间条件,而不是用 date 字符串比对,这样索引效率更高,也省去时区转换的麻烦。
cupType 看起来多余,但实际很有用。用户选"一整瓶 500ml"和"一杯 250ml"时,快捷按钮会根据 cupType 展示不同的文案,后面做"常见杯型管理"也只需要在这张表上做聚合统计。
3.2 存储方案选型:为什么选 sqflite
Flutter 在 Android/iOS 上最常用的本地数据库是 sqflite,但 OpenHarmony 这边情况不太一样。一开始我尝试直接用 OpenHarmony 官方的分布式数据管理服务,也就是 RDB 关系型数据库,但需要通过 platform channel 从 Dart 侧发起调用,代码量明显加大,而且要自己维护一套 JSON 序列化。
后来我换了思路,直接用 sqflite_common_ffi 配合 sqlite3 的 FFI 实现。OpenHarmony 系统自带了 sqlite3 原生库,所以 FFI 方式可以直接调用,不需要额外编译 C 代码。这个方案的好处是 Dart 层代码和 Android/iOS 上写的完全一样,业务逻辑可以无缝复用。
pubspec.yaml 里加这几个依赖:
yaml复制dependencies:
sqflite_common_ffi: ^2.3.0
path: ^1.9.0
path_provider: ^2.1.2
注意一个细节:path_provider 在 OpenHarmony 上需要安装社区的适配版本 path_provider_ohos,否则运行时拿不到应用的文档目录。这是我踩过的一个坑,后面在问题排查部分会详细展开。
3.3 数据库初始化和 DAO 封装
数据库初始化时,我封装了一个单例工具类,统一管理数据库版本和表结构:
dart复制class AppDatabase {
AppDatabase._();
static final AppDatabase instance = AppDatabase._();
Database? _db;
Future<Database> get database async {
_db ??= await _initDb();
return _db!;
}
Future<Database> _initDb() async {
final dbPath = await getDatabasesPath();
final path = p.join(dbPath, 'health_app.db');
return openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE water_record (
id INTEGER PRIMARY KEY AUTOINCREMENT,
amount INTEGER NOT NULL,
time INTEGER NOT NULL,
cup_type TEXT NOT NULL DEFAULT 'glass',
note TEXT
)
''');
await db.execute(
'CREATE INDEX idx_water_time ON water_record(time)'
);
},
);
}
}
建表时我给 time 字段加了索引。这个很重要,因为后面"查询某天饮水记录"和"计算今日总量"都是按时间范围过滤的,没有索引的表在数据量上来后查询会明显变慢。我测试过,当记录量到 1 万条时,全表扫描和走索引的耗时差距能有十倍以上。
DAO 层我主要封装了三个方法:
dart复制class WaterRecordDao {
Future<int> insert(WaterRecord record) async {
final db = await AppDatabase.instance.database;
return db.insert('water_record', {
'amount': record.amount,
'time': record.time.millisecondsSinceEpoch,
'cup_type': record.cupType,
'note': record.note,
});
}
Future<List<WaterRecord>> queryByDay(DateTime day) async {
final db = await AppDatabase.instance.database;
final start = DateTime(day.year, day.month, day.day);
final end = start.add(const Duration(days: 1));
final result = await db.query(
'water_record',
where: 'time >= ? AND time < ?',
whereArgs: [start.millisecondsSinceEpoch, end.millisecondsSinceEpoch],
orderBy: 'time DESC',
);
return result.map((e) => WaterRecord.fromMap(e)).toList();
}
Future<int> sumToday(DateTime day) async {
final db = await AppDatabase.instance.database;
final start = DateTime(day.year, day.month, day.day);
final end = start.add(const Duration(days: 1));
final result = await db.rawQuery(
'SELECT COALESCE(SUM(amount), 0) AS total FROM water_record '
'WHERE time >= ? AND time < ?',
[start.millisecondsSinceEpoch, end.millisecondsSinceEpoch],
);
return (result.first['total'] as num?)?.toInt() ?? 0;
}
}
sumToday 用 SQL 聚合而不是把记录全部拉到内存再算,这样既能减少 Dart 侧内存占用,也能利用 SQLite 的索引更快地完成求和。数据量小的时候看不出差别,但养成这个习惯之后,做周统计、月统计就会省很多事。
4. 界面设计与状态管理
4.1 首页布局拆解
饮水记录的界面我分成了三块:顶部是今日饮水进度环,中间是记录列表,底部是快捷添加按钮区域。这三块对应三个不同职责,放在一个页面里但代码上拆成了独立的 widget,方便后续单独复用。
进度环我用 CustomPaint 实现,没有引入额外的图表库。圆环绘制的思路是:先用背景色画一个完整的圆弧,再根据进度值画一个前景圆弧,用 SweepGradient 做渐变效果。核心代码大概是:
dart复制class WaterProgressRing extends StatelessWidget {
final double progress; // 0.0 ~ 1.0
final int current; // 当前饮水量
final int target; // 目标饮水量
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _RingPainter(progress),
child: Center(
child: Column(
children: [
Text('$current ml'),
Text('目标 $target ml'),
],
),
),
);
}
}
_RingPainter 的 paint 方法里需要注意一点:startAngle 要从 -pi / 2 开始,也就是圆环顶部,而不是默认的右侧。否则用户看起来进度环是从 3 点钟方向开始转的,和常见的健康类 App 手势习惯不一致。
4.2 快捷添加:把常用动作放在手指范围内
底部快捷添加区我放了三个按钮:小杯 200ml、中杯 300ml、大杯 500ml。这个设计参考了目标用户的实际场景——喝水时通常不会去精确计算毫升数,而是按杯子大小估算。三个按钮排列在底部,拇指可以轻松够到,比在页面中间弹输入框更符合移动端单手操作习惯。
点击按钮后的逻辑很简单:插入一条记录,然后刷新页面数据。但我特意加了一个轻微的弹跳动画,让用户能感知到"记录已添加"。这个反馈很重要,如果没有动画,用户连续点两下会觉得没反应又点一次,反而产生重复记录。动画用 AnimatedScale 实现,点击时 scale 从 1.0 缩到 0.92 再回弹。
用户如果需要自定义毫升数,可以点"自定义"按钮,弹出一个底部弹窗,里面放一个滑动条加一个输入框。这里有个 Flutter 常见的坑:底部弹窗里有 TextField 时,键盘弹起容易遮挡输入框。解决办法是在 showModalBottomSheet 里设置 isScrollControlled: true,然后给弹窗内容包一层 Padding,padding 值用 MediaQuery.of(context).viewInsets.bottom,这样弹窗会随着键盘一起上移。
4.3 状态管理:用 ChangeNotifier 而不是 setState
我一开始图省事,直接在页面 State 里用 setState 管理今日总量。但很快就发现问题:记录列表和进度环都要显示同一份数据,如果只在页面顶层 setState,每次刷新都会重建整个页面,列表滚动位置还会丢失。
后来改用 ChangeNotifier + AnimatedBuilder 的组合。WaterRecordController 继承 ChangeNotifier,负责调用 DAO 层方法并持有今日记录和总量数据:
dart复制class WaterRecordController extends ChangeNotifier {
final WaterRecordDao _dao = WaterRecordDao();
List<WaterRecord> todayRecords = [];
int todayTotal = 0;
Future<void> loadToday() async {
final now = DateTime.now();
todayRecords = await _dao.queryByDay(now);
todayTotal = await _dao.sumToday(now);
notifyListeners();
}
Future<void> addRecord(WaterRecord record) async {
await _dao.insert(record);
await loadToday();
}
}
页面里用 AnimatedBuilder 监听 controller,进度环和列表分别作为独立子树,只有依赖到通知的部分才刷新。这样添加记录时列表不会整个重建,用户体验更顺滑,代码也更容易测试。
如果项目继续变大,比如后面加了心率、血压模块,可以考虑引入 Provider 或 Riverpod 做全局状态管理。但就饮水记录这个模块来说,一个 ChangeNotifier 完全够用,没必要为了用框架而用框架。
5. 实操过程:从数据到界面的完整链路
5.1 添加记录的完整流程
把整个流程串起来看,用户点一下"中杯 300ml"按钮,背后发生了什么:
- 按钮的 onPressed 回调里构造一个
WaterRecord对象,amount 是 300,time 是当前时间,cupType 是glass。 - 调用
controller.addRecord(record)。 - controller 调 DAO 的
insert方法,把记录写入 SQLite。 - insert 成功后,controller 重新查询今天的记录和总量。
- 查询完成后调用
notifyListeners()。 AnimatedBuilder监听到通知,更新进度环的 progress 值和列表数据。- 列表如果有新增记录,滚动到最顶部显示最新一条。
这里的第 4 步"重新查询",我一开始觉得有点多余,想着插入成功后直接把内存里的数据 append 到列表末尾就行。但实际发现不行,因为用户可能从"自定义"弹窗里添加记录,或者是未来从手表端同步过来,数据源不止一个入口,统一从数据库查才能保证一致性。这个"写入后回读"的习惯,帮我避免了好几次数据不一致的 bug。
5.2 今日进度和累计值的计算逻辑
今日目标我默认设置为 2000ml,参考了常见健康建议。进度值计算方式是:
dart复制double get progress {
if (target == 0) return 0;
return (todayTotal / target).clamp(0.0, 1.0);
}
注意 clamp 到 1.0,也就是超过目标后进度环不会溢出。当今日总量超过目标时,我在进度环中间显示一个"已达标"的文案,替代默认的"还差 xxx ml"。
这个计算逻辑放在 controller 的 getter 里,不放在 widget build 方法里,理由是方便单元测试。我可以直接构造一个 controller,手动设置 todayTotal,然后断言 progress 值对不对,不需要启动整个页面。
5.3 列表项和时间显示
记录列表我用了 ListView.builder,每项显示杯型图标、毫升数和时间。时间格式用 HH:mm,不需要显示日期,因为列表本身只展示今天的记录。这里要注意时区问题,不能用 record.time.toIso8601String() 这种时间戳转字符串的默认行为,而是要用本地时区格式化。
dart复制final timeStr = '${record.time.hour.toString().padLeft(2, '0')}:'
'${record.time.minute.toString().padLeft(2, '0')}';
这个写法看起来有点啰嗦,但比直接用 intl 包省去了额外依赖,而且逻辑完全可控。如果后面要做周统计,再引入 intl 的 DateFormat 也来得及。
列表项右侧我放了一个删除按钮,支持左滑删除。删除逻辑走 controller 的 deleteRecord 方法,同样删除后回读数据库刷新界面。这里提醒一句:删除操作最好加一个确认弹窗,不然用户误触滑删除会很难恢复,毕竟 SQLite 删除后没有回收站。
6. 常见问题与排查技巧实录
6.1 构建报错:plugin 加载失败
很多人在拿到 OpenHarmony 的 Flutter 工程后,第一次构建会遇到类似 Error resolving plugin [id: 'dev.flutter.flutter-plugin-loader'] 的报错。这个问题本质上是因为 OpenHarmony 构建系统不认识标准 Flutter Gradle 插件,需要在 ohos/ 工程的配置文件里做适配。
我的排查思路是:先去 ohos/build-profile.json5 里确认有没有声明 flutter 插件相关依赖,再检查工程的 oh-package.json5 是否包含了 @ohos/flutter_ohos 这些原生依赖。社区模板工程一般自带这些配置,但如果你是从标准 Flutter 工程手动加的 ohos 目录,很容易漏掉。
这类问题没有统一的万能解法,因为不同版本依赖版本号不一样。建议直接对照 flutter_packages 仓库里 sample 工程的配置,把 allprojects、buildscript、dependencies 逐项比对,通常能很快定位是哪一项缺失。
6.2 path_provider 在 OpenHarmony 上拿不到路径
这是个比较隐蔽的问题。我在 Android 上写好的代码,getApplicationDocumentsDirectory() 用得很流畅,但跑到 OpenHarmony 设备上直接抛 MissingPluginException。原因很简单:OpenHarmony 的 Flutter 插件生态还没有完全覆盖所有官方插件,path_provider 的官方实现没有注册到 ohos 平台。
解决方式是安装社区适配的 path_provider_ohos,然后在代码里导入:
dart复制import 'package:path_provider_ohos/path_provider_ohos.dart';
但更优雅的做法是在自己的工具类里做一层封装,根据平台自动选择:
dart复制Future<String> getAppDocDir() async {
if (Platform.isOpenHarmony) {
final path = await OhosPathProvider.getApplicationDocumentsDirectory();
return path.path;
}
return (await getApplicationDocumentsDirectory()).path;
}
这样才能保证同一套代码以后还能跑回 Android/iOS。我踩过这个坑后,把所有涉及平台能力的地方都统一加了判断,虽然代码多了一点,但后面做多端发布时完全不用返工。
6.3 真机运行卡顿与渲染性能
饮水记录页面不复杂,但刚移植到 OpenHarmony 真机上时,进度环的动画有明显掉帧。排查后发现罪魁祸首是进度环的 paint 方法里每次绘制都创建新的 Paint 对象和 SweepGradient,没有做缓存。
优化方式很简单,把 Paint 对象提升为 painter 类的成员变量,只在构造时初始化一次;gradient 如果颜色固定,也可以做成 static final。这样绘制时只更新 sweepAngle,不会频繁触发 GC。优化后帧率从肉眼可见的卡顿恢复到 60 帧流畅。
另外一个容易被忽略的点是:不要在 build 方法里做数据库查询。我见过有人把 sumToday 直接写在 build 里,这会导致每次 rebuild 都触发一次查询。正确的做法是在 initState 时加载一次数据,数据变化时通过状态管理通知刷新。这条建议适用于所有 Flutter 项目,不只是 OpenHarmony。
6.4 设备树选择和其他真机问题速查
下面整理一个我在开发过程中遇到的典型问题对照表,都是真实踩过的:
| 现象 | 原因 | 解决办法 |
|---|---|---|
| 开发板启动卡在开机 logo | 设备树 dtb 选错 | 接串口看日志,核对开发板型号对应的固件版本 |
| 触摸屏无响应 | dtb 中 touch 节点不匹配 | 换厂商提供的对应内核镜像,或检查驱动是否加载 |
| Flutter 页面白屏 | 原生入口没加载 Flutter 容器 | 检查 ets 的 UIAbility 是否正确调用了 Flutter 引擎 |
| 调试模式很卡 | 打开了 debug 模式 | 构建 release 包测试,性能差距很明显 |
| SQLite 打开失败 | 数据目录不存在 | 先调用 path_provider 创建目录,再 openDatabase |
这里的设备树问题我多说一句:OpenHarmony 设备不像 Android 手机那样有统一的硬件抽象层,RK3568、RK3588 的板子,不同厂家的引脚定义和外设配置完全不同,选 dtb 本质上是在给内核提供一份"你长什么样"的说明书。选错就会启动异常。我的建议是尽量别用网上随便下载的通用固件,去开发板厂商官方渠道拿对应 SDK,能省大量排查时间。
7. 实测后的个人心得
整个饮水记录模块从零到跑通,包括环境搭建、数据层、界面和真机调试,我前后花了大概三天半的时间。这个效率比预期高,主要得益于 Flutter 业务代码在 Android 和 OpenHarmony 之间的高复用率,数据模型、DAO、状态管理这部分代码完全没改,只有平台相关的那几个插件调用做了适配。
我个人实际操作中的体会是:用 Flutter 开发 OpenHarmony 应用,最大的价值不是"省掉学 ArkTS 的时间",而是让团队已有的 Dart 业务资产能够直接沉淀到新平台。如果你所在团队已经积累了 Flutter 组件库,这条路值得认真评估。反过来,如果是从零开始的新团队且业务完全围绕 OpenHarmony,那直接学 ArkTS 可能更稳妥,毕竟官方支持力度和维护保障都在跟上。
最后再分享一个小技巧:在 OpenHarmony 设备上调试 Flutter 应用,强烈建议一开始就把 release 模式的构建流程跑通,不要一直用 debug 模式。debug 模式在 OpenHarmony 上的性能损耗比 Android 更明显,很多时候你以为是代码问题,实际上只是模式问题。先把 release 包安装成功,再回过来调代码,能少走很多弯路。饮水记录模块的完整代码我已经整理好放到了项目仓库里,有需要的可以直接参考,下一篇文章我打算写心率记录模块的实现,到时候见。
