ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter for OpenHarmony实战:体重记录功能完整开发指南

Flutter for OpenHarmony实战:体重记录功能完整开发指南 最近在做一个基于 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 WidgetForm 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 --platformsohos .它会自动补生成 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, }); MapString, dynamic toMap() { return { id: id, weight: weight, recorded_at: recordedAt.millisecondsSinceEpoch, note: note, }; } factory WeightRecord.fromMap(MapString, 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; FutureDatabase get database async { if (_database ! null) return _database!; _database await _initDatabase(); return _database!; } FutureDatabase _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), ); }, ); } Futureint insertWeightRecord(WeightRecord record) async { final db await database; return db.insert(weight_records, record.toMap()); } }DAO 层还提供了按时间倒序查询、按 id 更新、按 id 删除等方法。所有方法返回 FutureUI 层 await 拿到结果后再刷新界面保证数据写入完成前不跳转。这套 DAO 封装在后续增加体脂率、血压等记录类型时也可以沿用同一个模式。4. 添加体重记录页面完整实现4.1 表单界面与表单状态管理添加记录的页面我用 Form 多个表单项的经典组合。为什么用 Form 而不是手动逐字段校验因为 Form 配合 TextFormField 的 validator 机制可以在点击保存时统一触发所有字段校验失败项自动标红并显示错误文案交互体验比逐字段弹 Toast 自然得多。页面从上到下依次是体重输入框、日期选择器、时间选择器、备注输入框、保存按钮。体重输入框用 TextInputType.numberWithOptions(decimal: true)保证弹出数字键盘。日期和时间分开选择因为用户存在补录场景——昨天忘了称今天想补上昨天的体重这在健康记录类 App 里非常常见。class _AddWeightPageState extends StateAddWeightPage { final _formKey GlobalKeyFormState(); final _weightController TextEditingController(); final _noteController TextEditingController(); DateTime _selectedDate DateTime.now(); TimeOfDay _selectedTime TimeOfDay.now(); override void dispose() { _weightController.dispose(); _noteController.dispose(); super.dispose(); } Futurevoid _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 时重新查库。这个方案代码量最少不引入额外状态管理包适合中小项目。Futurevoid _openAddPage() async { final added await Navigator.pushbool( 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 存储展示的时候再转本地时区。别问我是怎么知道要这么干的——当你看到用户跨时区出差后体重记录的时间轴突然乱成一团的时候就会回来感谢这个建议了。
返回列表