☰
Flutter for OpenHarmony实战:体重记录功能完整开发指南
2026/10/3 14:30:11 网站建设 项目流程

最近在做一个基于 Flutter for OpenHarmony 的身体健康状况记录 App,主攻模块就是从添加体重记录开始的数据采集、存储与展示全链路。这篇文章不打算泛泛而谈"鸿蒙上跑 Flutter"的概念,直接把"添加体重记录"这个功能从需求分析、技术选型、工程搭建、数据建模、页面实现到联调排错完整走一遍,把 Flutter 跨端框架在 OpenHarmony 上遇到的真实问题和我最终的落地方案摊开来讲。适合两类人看:一是准备在 OpenHarmony 设备上做应用、但不想放弃 Flutter 技术栈的开发者;二是想做健康管理类 App、正在纠结数据存储和表单交互怎么设计的 Flutter 开发者。两种需求在这篇文章里都能找到对应的实操内容。

1. 项目定位与整体设计思路

1.1 从"记录体重"这个动作说起

体重记录这个功能,从用户视角看就三步:输入一个数字,选一下时间,点保存。但真要把这个模块做成一个能长期用的健康管理功能,背后牵扯的问题比想象中多。首先是输入层,计量单位用千克还是斤?小数点允许几位?用户误输非数字字符怎么办?其次是数据层,记录存哪里,本地还是云端?要不要支持历史修改?再次是交互层,用户记录完体重之后,列表页怎么刷新,趋势图表怎么同步,编辑和删除怎么处理——这些全是需求,不是一个输入框加一个保存按钮就能糊弄过去的。

我接手项目时的目标很明确:做一款能在 OpenHarmony 设备上稳定运行的健康状况记录 App,首期核心功能聚焦体重记录,支持添加、编辑、删除、按时间浏览历史,所有数据离线可用。选 Flutter 而不直接用 ArkUI 原生开发,最直接的原因是团队已有成熟的 Flutter 技术栈,另外这个 App 后续要同步发布 Android 端,同一套 UI 代码两边跑,能省掉至少一倍的界面开发工作量。Flutter for OpenHarmony 的方案正好命中这个需求。

1.2 为什么是 Flutter for OpenHarmony 这个组合

很多人的第一反应是"Flutter 真的能在 OpenHarmony 上跑吗"。实际上 OpenHarmony 社区早就有了独立的 Flutter 适配分支,维护在 OpenHarmony SIG 仓库里。它把 Flutter Engine 重新编译到了 OpenHarmony 的 Native API 之上,同时把 dart:ui 底层接口映射到 OpenHarmony 的图形与事件子系统。应用跑起来之后,渲染路径是 Flutter 自己的 Skia/Impeller 引擎,跟其他平台用的是同一套逻辑,只是窗口、输入、平台通道这几层被重新实现了一遍。

这对业务层开发者来说是个好消息:你的 Dart 代码几乎不用动,页面、路由、动画、状态管理还是 Flutter 那套熟悉的语法。区别主要体现在工程层面——要用专门分支的 Flutter SDK,工程里比普通 Flutter 项目多出一个 ohos 平台目录,最终打包产物是 .hap 而不是 .apk。所以这不是什么虚无缥缈的概念验证,而是可以真正用来交付 OpenHarmony 应用的开发方案。我跑完整套流程之后最直观的感受是:业务代码的跨端复用比例接近百分之百,坑全部集中在工具链和打包环节。

1.3 体重记录模块的架构拆解

把范围缩小到"添加体重记录"这个页面上,我当时定的架构分成四层。UI 层负责表单展示、输入交互和校验反馈,用 Flutter 的 Form + TextFormField 组合;逻辑层负责数据组装、校验结果处理和保存编排,页面内用 setState,同时把业务操作抽到独立的 Service 里;数据层负责体重记录的增删改查,用 sqflite 封装 DAO,单例管理数据库连接;平台层对接 OpenHarmony 特有能力,比如后续要接入系统健康服务的话,通过 MethodChannel/EventChannel 与 ArkTS 侧通信。

层级职责对应实现
UI 层表单展示、输入交互、校验反馈Flutter Widget,Form + TextFormField
逻辑层数据组装、保存编排页面 setState + RecordService
数据层记录持久化、查询统计sqflite 封装 DAO,单例数据库
平台层访问 OpenHarmony 系统能力MethodChannel / EventChannel

这个分层结构换来的好处很具体:以后要把本地存储迁到远端同步,只替换数据层;以后要接入 OpenHarmony 健康服务做体重自动同步,只需在平台层加 Channel 实现,UI 层完全不用动。对于一个人维护的小项目来说,这种边界划分可能显得有点"重",但等需求开始迭代的时候你就会庆幸当初没有把代码全堆在 Widget 里。

2. 开发环境搭建与工程创建实战

2.1 先搞定 Flutter for OpenHarmony 专用 SDK

第一步最关键,也最容易被忽略:不能用 flutter.dev 官方下载的稳定版 SDK。官方版不认识 OpenHarmony 平台,你执行 flutter create 的时候根本看不到 ohos 这个平台选项。需要从 OpenHarmony SIG 的仓库拉 flutter_flutter 分支,把它作为本地的 Flutter SDK 替代品。拉下来之后不要随便切分支,直接用仓库文档里标注的稳定版本,因为 Flutter for OpenHarmony 的版本号和上游 Flutter 版本不是一一对应的关系,用错版本后续编译 HAP 时会出现各种匪夷所思的报错。

配置动作是典型的"三步走":克隆仓库到本地目录,把本地 Flutter SDK 的 PATH 环境变量指向这个目录,最后跑一遍 flutter doctor 确认 dart、flutter 命令都能正常执行。这里有个容易自我怀疑的坑:flutter --version 输出的版本号会是类似 "OpenHarmony 3.7.x" 这样的自定义标识,这是正常的,别以为自己装错又去重新折腾。

2.2 DevEco Studio 与 hvigor 的配合方式

光有 Flutter SDK 还不够,OpenHarmony 应用最终要打成 HAP 包,依赖的是 DevEco Studio 工具链里的 hvigor 构建系统。正常情况下,Flutter 工程生成 ohos 目录之后,需要用 DevEco Studio 打开该目录来编译 HAP;也可以在命令行里配好 hvigor 环境后直接触发 flutter build hap。两种方式我在项目里都试过,日常开发建议用 DevEco Studio,因为 OpenHarmony 真机调试必须配置签名信息才能安装 HAP,命令行模式下签名的配置和排查都比较费劲。

实操中有一个版本对齐问题必须重视。DevEco Studio、OpenHarmony SDK、以及刚才那个 Flutter 分支,三者之间存在官方推荐的版本组合。项目初期我没在意,随手用了最新的 DevEco Studio,结果 hvigor 同步一直失败,报错信息指向依赖解析异常,排查了半天才发现是 SDK 版本和构建工具的兼容问题。换成兼容矩阵里推荐的组合之后,一次通过。

注意:克隆 flutter_flutter 之前,先看仓库 README 里的兼容矩阵,确认 Flutter 分支版本、DevEco Studio 版本、OpenHarmony SDK 版本三者的对应关系。这个前置检查能省下你一整天的排错时间。

2.3 创建支持 ohos 平台的 Flutter 工程

工程创建有两种方式。方式一是在现有 Flutter 工程根目录执行 flutter create --platforms=ohos .,它会自动补生成 ohos/ 目录;方式二是在 flutter create 新建工程时直接带上 ohos 参数。我更推荐第二种,从头生成的目录结构干净,不会有残留配置互相干扰。

工程生成后,根目录下新增的 ohos 文件夹是一个标准的 OpenHarmony 工程结构,包含 entry 模块、AppScope、build-profile.json5、hvigorfile.ts 等。Flutter 引擎产物会以依赖库的形式集成进 entry 模块,你在 ohos 目录的依赖配置里能看到 Flutter 相关包。这个目录平时不要手动改构建配置,尤其是 build-profile.json5,改坏了工程直接起不来。第一次用 DevEco Studio 打开工程时,会自动同步构建依赖,这一步会比较慢,耐心等。同步失败的话,优先检查网络、SDK 路径、以及 JAVA_HOME 是否指向 DevEco Studio 自带的 JDK。

3. 数据模型设计与本地存储方案

3.1 记录模型:别只存一个数字

体重记录的数据模型是整个模块的地基。很多人第一版只存两个字段:体重值和记录时间。但做两个星期就会发现不够用——用户早上和晚上各称一次体重,想标注一下是否空腹,没有备注字段就记不下来;用户记录错了想改,没有主键就无法定位具体哪条记录。最终我定义的 WeightRecord 模型包含四个字段:id、weight、recordedAt、note。

class WeightRecord { final int id; final double weight; final DateTime recordedAt; final String? note; WeightRecord({ this.id = 0, required this.weight, required this.recordedAt, this.note, }); Map<String, dynamic> toMap() { return { 'id': id, 'weight': weight, 'recorded_at': recordedAt.millisecondsSinceEpoch, 'note': note, }; } factory WeightRecord.fromMap(Map<String, dynamic> map) { return WeightRecord( id: map['id'] as int, weight: map['weight'] as double, recordedAt: DateTime.fromMillisecondsSinceEpoch(map['recorded_at'] as int), note: map['note'] as String?, ); } }

weight 字段用 double,单位千克,UI 层录入时限制一位小数。recordedAt 字段用 int 存毫秒时间戳,而不是字符串日期。为什么不用字符串?因为后续要做"最近7天""最近30天"这类范围查询,整数比较比字符串比较可靠且快得多。note 字段可空,存用户随手填的备注。

3.2 存储选型:为什么最终选了 sqflite

在 OpenHarmony 上做本地存储,候选方案有几个:shared_preferences、Hive、sqflite。shared_preferences 适合存键值对,放设置项没问题,但体重记录是结构化数据、会持续增长,用键值对存既不优雅也不适合查询。Hive 是纯 Dart 实现的嵌入式数据库,读写速度快且支持类型安全,记录量在千条以内完全够用,但它的短板是不支持 SQL 聚合查询。要做"最近30天平均体重"这类统计,用 Hive 就得自己在 Dart 里写循环过滤,性能和代码可读性都差。

最终选了 sqflite。它在 OpenHarmony 上有一个适配版本,底层通过 OpenHarmony 的数据管理能力实现 SQL 接口,但 API 和 Android 版 sqflite 保持一致,业务代码写 SQL 完全不受影响。这个选择还有一层隐藏好处:以后 App 要回到 Android 平台发布,数据层代码几乎可以原样复用,只需要调整依赖声明。

补充一句:如果你只是做原型验证,shared_preferences 完全够用。但健康记录类 App,"记录越来越多、需要统计"是必然走向,趁早用数据库,别等数据量大了再迁移,到时候哭都来不及。

3.3 建表、索引与 DAO 封装

建表前我先把数据访问单独拆了一个类,不直接让页面操作数据库。数据库辅助类采用单例模式,避免重复打开数据库句柄。建表语句本身很简单,但有一个容易忽略的细节:索引。体重记录最常用的查询路径是按时间倒序浏览,所以我在 recorded_at 字段上建了索引。记录量到几千条时,有索引和没索引的查询速度差距很直观。

class DatabaseHelper { static final DatabaseHelper _instance = DatabaseHelper._internal(); DatabaseHelper._internal(); static DatabaseHelper get instance => _instance; static Database? _database; Future<Database> get database async { if (_database != null) return _database!; _database = await _initDatabase(); return _database!; } Future<Database> _initDatabase() async { final dbPath = await getDatabasesPath(); final path = join(dbPath, 'health_records.db'); return openDatabase( path, version: 1, onCreate: (db, version) async { await db.execute(''' CREATE TABLE weight_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, weight REAL NOT NULL, recorded_at INTEGER NOT NULL, note TEXT ) '''); await db.execute( 'CREATE INDEX idx_weight_records_recorded_at ' 'ON weight_records(recorded_at)', ); }, ); } Future<int> insertWeightRecord(WeightRecord record) async { final db = await database; return db.insert('weight_records', record.toMap()); } }

DAO 层还提供了按时间倒序查询、按 id 更新、按 id 删除等方法。所有方法返回 Future,UI 层 await 拿到结果后再刷新界面,保证数据写入完成前不跳转。这套 DAO 封装在后续增加体脂率、血压等记录类型时也可以沿用同一个模式。

4. 添加体重记录页面完整实现

4.1 表单界面与表单状态管理

添加记录的页面,我用 Form + 多个表单项的经典组合。为什么用 Form 而不是手动逐字段校验?因为 Form 配合 TextFormField 的 validator 机制,可以在点击保存时统一触发所有字段校验,失败项自动标红并显示错误文案,交互体验比逐字段弹 Toast 自然得多。

页面从上到下依次是:体重输入框、日期选择器、时间选择器、备注输入框、保存按钮。体重输入框用 TextInputType.numberWithOptions(decimal: true),保证弹出数字键盘。日期和时间分开选择,因为用户存在补录场景——昨天忘了称,今天想补上昨天的体重,这在健康记录类 App 里非常常见。

class _AddWeightPageState extends State<AddWeightPage> { final _formKey = GlobalKey<FormState>(); final _weightController = TextEditingController(); final _noteController = TextEditingController(); DateTime _selectedDate = DateTime.now(); TimeOfDay _selectedTime = TimeOfDay.now(); @override void dispose() { _weightController.dispose(); _noteController.dispose(); super.dispose(); } Future<void> _pickDate() async { final date = await showDatePicker( context: context, initialDate: _selectedDate, firstDate: DateTime.now().subtract(const Duration(days: 365)), lastDate: DateTime.now(), ); if (date != null) { setState(() => _selectedDate = date); } } }

注意日期选择器的范围设计:firstDate 允许往前一年,lastDate 限定为今天。未来的体重记录没有实际意义,反而会把趋势折线图的时间轴打乱。这个约束放在选择器层面,比保存时再校验更早拦截无效输入。

4.2 三层校验规则挡住脏数据

体重输入框的校验分三层:第一层判空,不填直接提示"请输入体重";第二层用 double.tryParse 做类型转换,转失败说明输入的不是合法数字,提示"请输入有效数字";第三层做范围判断,体重小于 30 或大于 300 直接拦截。这个范围参考了常见家用体重秤的量程,同时能拦住一连串 9 那种明显误输入。

validator: (value) { if (value == null || value.isEmpty) return '请输入体重'; final w = double.tryParse(value); if (w == null) return '请输入有效数字'; if (w < 30 || w > 300) return '体重应在30-300kg之间'; return null; },

很多新手只做判空,不做类型和范围校验,结果就是脏数据进了库,后面做统计时出现"平均体重 800 公斤"这种离谱结果。数据校验永远是数据质量的前置防线,这层没守住,后面所有依赖数据的页面都会跟着遭殃。备注输入框选择性校验:不填就存 null,填了就 trim 掉首尾空格再存,避免出现纯空格备注。

4.3 保存流程与数据组装细节

保存按钮的回调里做了三件事:先调用 _formKey.currentState!.validate() 触发全部表单校验;校验通过后把体重字符串转 double、把日期和时间拼成完整 DateTime;最后组装 WeightRecord 对象,调用 DAO 插入持久化。插入成功之后不要傻站在原地,带着结果返回上一级,让列表页刷新。

void _saveRecord() async { if (!_formKey.currentState!.validate()) return; final weight = double.parse(_weightController.text); final recordedAt = DateTime( _selectedDate.year, _selectedDate.month, _selectedDate.day, _selectedTime.hour, _selectedTime.minute, ); final record = WeightRecord( weight: weight, recordedAt: recordedAt, note: _noteController.text.trim().isEmpty ? null : _noteController.text.trim(), ); final id = await DatabaseHelper.instance.insertWeightRecord(record); if (mounted) { Navigator.pop(context, id > 0); } }

这里有一个值得单独拎出来的坑:Navigator.pop 之前必须判断 mounted。因为 _saveRecord 是异步方法,await 数据库插入期间用户可能已经按了返回键,页面已经出栈后再调用 Navigator.pop 会直接抛异常。加上 mounted 判断是 Flutter 异步编程的基本素养,这行代码看着不起眼,关键时刻能救命。

4.4 返回联动:让列表页立刻刷新

体重列表页展示所有历史记录,添加页保存返回后列表要刷新。最简单的做法是用 await Navigator.push 等待添加页返回,返回值为 true 时重新查库。这个方案代码量最少,不引入额外状态管理包,适合中小项目。

Future<void> _openAddPage() async { final added = await Navigator.push<bool>( context, MaterialPageRoute(builder: (_) => const AddWeightPage()), ); if (added == true) { _loadRecords(); } }

如果后续页面增多、数据联动变复杂,建议把状态管理升级成 Provider + ChangeNotifier,把记录集合放进全局 RecordsModel,添加页写完直接调 model.add(record),列表页监听模型自动刷新。两种方案各有取舍,在体重记录模块这个阶段,await 返回值的方案已经足够敏捷,而且逻辑一眼能看明白,不需要为了"架构感"强行引入复杂状态管理。

5. 记录展示、图表趋势与全生命周期管理

5.1 历史记录列表的实现要点

列表页用 RefreshIndicator + ListView.builder 的组合。ListView.builder 不管记录总量多大,都只构建当前屏幕可见的 item,性能有保障,不会因为记录积累到几百上千条就卡顿。每个列表项左侧显示日期时间,右侧突出显示体重值,备注如果有就展示在小字区域。

数据加载我一开始用了 FutureBuilder,后来发现它在"下拉刷新重新加载"场景下代码不够顺手,干脆改成手动调用 _loadRecords(),读取数据库后 setState 更新本地列表变量。下拉刷新逻辑只需在 onRefresh 里再调一次 _loadRecords(),代码更直白。这里的经验是:不要为了用某个组件而用某个组件,FutureBuilder 在一次性加载场景很合适,但涉及刷新、增删联动时手动管理状态反而更清晰。

5.2 用折线图呈现体重趋势

健康记录类 App 只看数字列表是看不出趋势的。我在列表页顶部加了一块最近 30 天的体重折线图,用 fl_chart 库实现。fl_chart 是纯 Dart 库,没有原生依赖,在 OpenHarmony 上可以直接运行,绘制流畅度和平台表现一致,不需要额外适配。

画折线图的逻辑不复杂:把记录按日期排序,x 轴按天推进,y 轴是体重值。这里有一个数据层面的细节:用户如果同一天记录了两次体重,图表上会同时出现两个点,折线会显得很乱。我的处理是在查询阶段就取"当日最后一次记录"作为当日代表值,查询层面聚合好,图表渲染只关心展示,职责边界清晰,代码也好维护。

5.3 编辑与删除:补齐记录管理闭环

做添加功能时顺手把编辑和删除的接口留好,后续迭代会省很多事。列表项右侧放编辑和删除按钮。删除时弹确认对话框防止误触;编辑直接复用添加页面,传入已有记录数据,页面初始化时把体重、时间、备注填充进表单,标题改成"编辑体重记录"。

添加页和编辑页共用同一个页面类,靠构造参数是否为空区分新增或编辑。这个"一页两用"模式在 Flutter 里很常见,用可选构造函数参数就能实现,不需要拆两个页面。更新数据时 SQL 语句用 UPDATE weight_records SET weight = ?, recorded_at = ?, note = ? WHERE id = ?,注意绝对不能漏掉 WHERE 条件,否则整张表都会被改写。这个错误我早年踩过,现在写 UPDATE 都会下意识检查 WHERE 子句。

6. 常见问题与排查技巧实录

6.1 Flutter for OpenHarmony 编译与运行问题速查

Flutter for OpenHarmony 的生态相对小众,网上能查到的资料不多,遇到的问题多数得从报错信息里逆向排查。整理一张速查表,开发中遇到类似报错可以直接对照找思路。

异常现象可能原因解决思路
flutter create 没有 ohos 平台选项本地 Flutter SDK 是官方版换 OpenHarmony 分支 SDK 并重启终端
hvigor 同步失败DevEco Studio 与 SDK 版本不匹配按兼容矩阵对齐版本
HAP 装不上真机未配置签名DevEco Studio 中生成调试签名
编译报错 Could not resolve依赖仓库缓存问题清理 ohos 目录缓存后重新同步
App 运行闪退无日志Flutter 引擎与系统版本不兼容确认设备系统在支持范围内

还有一个排查小技巧:OpenHarmony 真机调试时,日志视图默认只显示系统级日志,Flutter 侧的 Dart 异常容易被淹没。遇到闪退先看 DevEco Studio 的 Log 面板里有没有 Flutter 相关关键字,再结合 flutter run 输出排查。如果两边都没有明确报错,优先怀疑 Flutter 分支版本和设备系统版本不匹配,这种问题往往没有任何有效日志。

6.2 数据库升级与数据安全

体重记录模块上线之后,数据库结构调整几乎是必然的。比如后面想加"体脂率"字段,就需要把数据库版本从 1 升到 2。sqflite 提供了 onUpgrade 回调,在版本号变化时触发。你在这个回调里执行 ALTER TABLE 语句,而不是去改 onCreate 里的建表语句——老用户的数据库已经创建过了,onCreate 不会重新执行。

openDatabase( path, version: 2, onCreate: (db, version) async { /* 首次创建的表结构 */ }, onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion < 2) { await db.execute( 'ALTER TABLE weight_records ADD COLUMN body_fat REAL', ); } }, );

这是一条非常实用的经验:生产环境永远不要执行 DROP TABLE。数据丢了找不回来,对健康类 App 来说,一次数据丢失就足以让用户流失。宁可多写几行 ALTER TABLE 迁移代码,也要保住用户积累的记录。数据库升级逻辑还要考虑"从 1 直接升到 3"这类跨越场景,所以上面的判断条件用 oldVersion < 2 而不是 oldVersion == 1,保证任何升级路径都能正确执行对应迁移。

6.3 列表性能与体验优化心得

最后聊几个性能优化点。第一,列表项构建要轻量化,不要在 build 方法里做数据库查询或重量级日期解析逻辑,这些放到数据加载阶段完成。第二,列表项显示相对时间(比如"3天前")时,建议预先把展示文案算好,存进一个轻量化的展示模型里,而不是在 build 里对每个 item 重新计算,否则每次刷新都会白费 CPU。第三,图表区域只在数据变化时重绘,避免 setState 触发列表刷新的同时把图表也重建一遍。

实测下来,按上述方式处理后,累计上千条体重记录的 App 在主流 OpenHarmony 设备上依然能保持列表滑动流畅、图表渲染不掉帧。这套优化思路不限用于体重记录,任何"列表 + 图表 + 本地存储"的组合都可以复用。核心原则就一句话:能提前算好的数据就别留到渲染的时候算,能局部刷新就别整页重建。

回到 Flutter for OpenHarmony 这个组合本身,我做完这个模块最大的体会是:坑大多集中在工程和工具链层面,真正写业务代码时,你积累的 Flutter 经验几乎可以完全平移过来。所以如果你已经会 Flutter,想往 OpenHarmony 方向扩一步,完全不用担心从头学一套 UI 框架,最大的成本反而是花半天时间把环境配好。

最后再分享一个小技巧:健康记录类 App 的时间字段,建议从一开始就按 UTC 存储,展示的时候再转本地时区。别问我是怎么知道要这么干的——当你看到用户跨时区出差后,体重记录的时间轴突然乱成一团的时候,就会回来感谢这个建议了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询