☰
Flutter跨端开发实战:用OpenHarmony打造家庭药箱与血糖记录App
2026/10/7 3:18:14 网站建设 项目流程

上个月在整理家里药箱时发现,三盒布洛芬已经过期大半年,一管软膏标签全被磨掉,甚至还有一瓶止咳糖浆压根儿看不出生产日期。当时我脑子里冒出来的想法其实挺朴素:与其每次翻箱倒柜靠人眼排查,不如直接写一个家庭药箱管理 App。恰好那段时间我在折腾 flutter_for_openharmony 这套工具链,Flutter 代码写起来快,OpenHarmony 生态又正缺这种本地化工具型应用,于是这个项目就顺理成章地开搞了:核心功能包含药品入库、过期预警、分类统计,以及完整的血糖记录模块。这篇文章就是这个项目从零到一、从“新建项目跑不起来”到真机稳定运行的完整复盘,适合有 Flutter 基础、想迁到 OpenHarmony 做工具型应用,或者对健康记录类产品感兴趣的朋友。

家庭药箱管理听起来是个小项目,但真做起来会发现,它同时踩中了跨端开发、原生插件适配、状态管理、数据库设计、图表可视化好几个深水区。尤其是血糖记录部分,涉及单位换算、时段分组、趋势图渲染和隐私合规,比单纯的 CRUD 复杂得多。我打算把整个项目的搭建过程、设计决策和踩坑日志拆开讲,其中不少经验是文档里查不到的。

1. 为什么把家庭药箱应用放在 Flutter + OpenHarmony 组合上

1.1 从一次真实药箱整理说起

先说需求。一个家庭药箱管理 App 到底要解决什么问题?我整理药箱时总结出的痛点是:不知道家里有什么药、不知道哪些药快过期、不知道某类药还剩多少。慢性病家庭还会多一个诉求——帮老人记录血糖、血压这类指标,观察趋势,方便复查时给医生看。

把这些需求拆开后,功能清单其实很清晰:药品的增删改查、有效期预警、余量提醒、分类统计,加上一套独立的血糖记录模块。技术上没有太高门槛,全是表单、列表、图表的组合,但这恰恰是最适合用 Flutter 做的场景。为什么不选择给家人人手装一个不同的原生 App?因为家里既有 Android 手机也有 OpenHarmony 设备,我不想为一个工具型应用维护两套代码,一套 Dart 通吃才是正解。

硬件层面也有考量。OpenHarmony 设备大多定位在平板、智慧屏、国产终端这类家庭场景,放一台在客厅茶几或餐边柜上,打开就是一个专用药箱终端。Flutter 的 OpenHarmony 适配分支能直接产出 HAP 安装包,意味着这套代码不只停留在模拟器里“自嗨”,而是能落到真实家庭成员每天都会打开的设备上。

1.2 OpenHarmony 上的 Flutter 分支现状

很多人以为 Flutter 只支持 Android、iOS、Web 和桌面,其实 OpenHarmony 也有官方社区维护的适配分支,主要是 OpenHarmony-SIG 组织下的 flutter_flutter 仓库。它做的事情是:把 Flutter 引擎跑在 OpenHarmony 的图形栈上,让 Dart 代码可以编译成 HAP 包。绝大多数 Flutter 基础控件、路由、动画、手势系统在分支上都能用,底层渲染和平台通道则做了独立的适配。

但这并不意味着你可以无脑地把 Android 工程的依赖拿过来跑。OpenHarmony 分支的版本更新速度落后于 Flutter 上游,官方 Flutter 已经发到 3.2x 甚至更高时,分支可能还停留在 3.19 或 3.22 这个区间。所以我的第一个经验是:锁版本,别追新。一旦某个 Flutter 包的新版本依赖了上游引擎的新 API,它在 OpenHarmony 分支上可能直接编译不过。

上游 Flutter 版本OpenHarmony 适配分支我的使用建议
3.16.x早期适配,基础可用不建议,插件生态太旧
3.19.x适配较成熟,社区资料多生产项目首选
3.22.x跟随上游,部分插件有断裂尝鲜可以,稳定项目慎用
3.24+适配跟进中,测试不充分等社区验证再说

我最终选了 3.19 对应的分支,原因非常实际:它能跑通当前项目需要的所有插件,稳定压倒一切。家庭药箱这种工具型应用,用户不会因为你是最新版本就多给你一次打开的机会,反而会因为三天两头闪退直接卸载。

1.3 为什么不直接用 ArkTS

聊 OpenHarmony 开发,绕不开那个经典问题:官方明明主推 ArkTS 和 ArkUI,为什么还要折腾 Flutter?我自己的答案分三层。

第一,从跨端角度。ArkTS 绑定的是 OpenHarmony 单端,写出来的代码没法直接给 iOS 或 Android 用;而 Flutter 一套代码可以同时覆盖 Android、iOS、OpenHarmony 甚至桌面端。我家里有 Android 手机也有 OpenHarmony 设备,这需求是真实存在的,不是理论探讨。

第二,从生态角度。Flutter 在图表、状态管理、数据库、UI 组件方面的第三方库非常丰富。fl_chart、provider、drift 这类成熟方案可以大幅缩短开发时间。ArkUI 本身不差,但个人开发者能直接调用的高质量轮子相对少,很多需求要自己从零搭。

第三,从开发效率角度。Dart 语言对前端转过来的开发者很友好,热重载的开发体验在调 UI 时节省了大量时间。医疗工具类应用界面不需要夸张的视觉效果,Flutter 自绘渲染的一致性反而成了优点:在 Android 上显示什么样,到 OpenHarmony 上基本还是什么样。

对比维度ArkTS + ArkUIFlutter(OpenHarmony 分支)
官方支持度最高,OpenHarmony 亲儿子社区维护,官方认可
跨端能力仅 OpenHarmonyAndroid/iOS/OpenHarmony/桌面
第三方生态ArkUI 生态,相对年轻Flutter 生态丰富成熟
动画与渲染声明式,性能不错自绘渲染,跨端一致性好
团队招聘难度相对小众Flutter 开发者的池子大得多

结论:如果你的目标只有 OpenHarmony 单端,那我劝你老老实实用 ArkTS,官方资料和工具链支持都最稳。但如果你像我一样需要覆盖多种设备,或者想保留跨端选项,Flutter 是更合理的投入。家庭药箱这种表单加图表的轻交互应用,Flutter 的“写一套处处编译”价值远大于它的那一点点性能损耗。

2. 环境搭建与“新建项目跑不起来”的完整排查链路

2.1 工具链与版本组合

OpenHarmony 上的 Flutter 开发环境和普通 Flutter 开发略有差异,你需要准备以下东西:

  • DevEco Studio(OpenHarmony 集成开发环境),里面包含 OpenHarmony SDK 和模拟器;
  • Flutter 的 OpenHarmony 适配分支,从 GitHub 拉取后切换到你计划使用的 release 分支;
  • ohpm 包管理器,它是 OpenHarmony 生态自己的包管理工具,和 pub、npm 职责类似;
  • hdc 命令行工具,作用相当于 Android 生态里的 adb,用来连接设备、安装 HAP、查看日志。

环境变量这块是最容易踩坑的地方。首先要把DEVECO_SDK_HOME指向 DevEco Studio 内置的 OpenHarmony SDK 路径,其次要确保PATH里排在前面的 Flutter 是你 clone 下来的 OpenHarmony 分支,而不是官方 Flutter。

我当时就载在这上面。电脑上原来装了官方 Flutter,又 clone 了 OHOS 分支,结果终端里flutter命令解析到的是官方版本,flutter create根本不认识--platforms ohos这个参数。后面我把 flutter_ohos/bin 显式加到 PATH 最前面,还是在多个终端窗口里反复出错。最后干脆不折腾全局环境变量了,直接在项目目录里用一个alias flutter=/path/to/flutter_ohos/bin/flutter,每次开终端先看一眼flutter --version确认当前是这个分支,这才算稳下来。

2.2 第一次运行:日志停在 dart_vm_initializer 的完整排查过程

新建项目成功、编译成功,但模拟器一启动就闪退,这类问题在热词榜上挂了很久——“flutter新建项目后跑不起来”是很多人的第一道坎。我复现时看到的日志是这样:

E/flutter (31173): [error:flutter/runtime/dart_vm_initializer.cc(41)] unhand...

如果你也停在这一行,先别急着搜报错信息,日志是被截断的。点开完整日志看后面跟的是什么,我那次完整的报错其实是:

Unhandled exception: MissingPluginException(No implementation found for method getAll on channel com.example.shared_preferences)

这才是关键。dart_vm_initializer.cc(41)只是 Flutter 引擎报“Dart 层有未捕获异常”的入口,真正的错误原因在后面的异常详情里。这次闪退和 OpenHarmony 的引擎适配无关,问题出在shared_preferences插件没有加载 OpenHarmony 平台的实现。

这套排查链路我总结成了标准化流程:

  1. 先看完整异常,而不是第一行。跳转到终端里日志的最后 30 行,找 MissingPluginException 或者 Channel 名;
  2. 根据 Channel 名定位对应的 Flutter 插件;
  3. 去插件仓库看有没有ohos/目录,没有就是没适配;
  4. 全局搜索代码里调用这个插件的地方,注释掉或用替代方案;
  5. 重新编译,确认闪退消失。

后续我学到的通用经验是:OpenHarmony 上的 Flutter 插件和 Android 插件不是自动兼容的。Flutter 官方插件默认实现的是 Android/iOS 平台通道,OpenHarmony 分支需要独立的原生实现才不会被 MissingPluginException 炸掉。解决办法有三个:换一个纯 Dart 实现的包、找社区维护的xxx_ohos适配包、自己写 MethodChannel 实现。

2.3 从 flutter aar 到 HAP:别把 Android 的构建习惯带过来

热搜词里有一条特别有代表性:you are applying flutter's main gradle plugin imperatively using the apply s。这条报错看着像 OpenHarmony 相关,实际上它是 Android 工程里用 Gradle 的 apply 脚本方式集成 Flutter 模块时常见的问题。为什么会在 OpenHarmony 项目里搜到这条报错?因为不少人在 OpenHarmony 里也想照搬 Android 混合工程的“把 Flutter 当模块塞进去”的思路。

Android 生态里,Flutter 最终会打包成 AAR,你需要在主工程的 Gradle 脚本里 apply Flutter 的插件脚本。OpenHarmony 完全不认识这套。它的构建系统基于 hvigor,应用产物是 HAP,独立模块则叫 HAR。OpenHarmony 分支的 Flutter 工具链已经提供了自己的构建插件,会在编译时把 Flutter 引擎和三端共享的 Dart 产物一起打进 HAP。

环节Android 生态OpenHarmony 生态
应用包格式APK / AARHAP / HAR
构建系统Gradlehvigor
设备连接adbhdc
Flutter 模块集成apply gradle 插件hvigor 插件
连接设备adb install apkhdc install hap

我当时因为目录结构问题误开了一个 Android 子工程,构建时直接就撞上了这条报错。删掉那个子工程,回到纯 OpenHarmony 的工程结构后,问题自然消失。所以碰到这条报错,先检查自己是不是把 Android 的混合工程结构误搬进了 OpenHarmony 项目。

2.4 判断一个 Flutter 包能不能在 OpenHarmony 上用的清单

入坑多了之后,我总结了一套判断 Flutter 包是否可用的快速检查法,在选型时能省很多时间:

  • 看仓库里有没有ohos/目录。有原生 ohos 实现的包,才会自动注册平台通道;
  • 看 README 或 pub.dev 的描述里有没有 OpenHarmony 支持的字眼;
  • 优先选纯 Dart 包。provider、fl_chart、intl、path、uuid 这些都是纯 Dart 实现,不依赖平台原生代码,OpenHarmony 上直接能用;
  • 原生类插件要检查它是否被注册到生成插件列表 GeneratedPluginRegistrant 里,没注册=没适配;
  • 不要盲目升级到最新版。OHOS 分支的引擎落后上游一两个大版本时,新插件很可能不兼容。

下表是我项目最终采用的依赖清单,全部跑通,值得直接抄:

包名用途平台依赖OpenHarmony 表现
provider状态管理纯 Dart完全正常
intl日期格式化纯 Dart完全正常
fl_chart血糖趋势图纯 Dart完全正常
sqflite_common_ffiSQLite 数据库FFI,无原生插件完全正常
uuid生成唯一 ID纯 Dart完全正常
shared_preferences本地偏好存储原生需要找 ohos 适配包

存储这块我要多说一句:OpenHarmony 的 Flutter 生态里,sqflite官方包的原生实现并不稳定,我试过几次都有兼容问题。最后选sqflite_common_ffi是因为它走 FFI 调用 SQLite,不依赖 Android/iOS 的插件注册机制,跨端一致性很好。桌面端、OpenHarmony、Android 一套代码全通,这对工具型应用来说是性价比极高的选择。

3. 药箱管理的核心:药品模型、数据库与过期预警

3.1 药品表设计:每个字段背后都有理由

药品的字段设计直接决定了后续所有功能的实现难度。我最终的表结构如下:

CREATE TABLE medicines ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, category TEXT NOT NULL DEFAULT 'default', stock INTEGER NOT NULL DEFAULT 0, expiry_date TEXT NOT NULL, dosage TEXT DEFAULT '', manufacturer TEXT DEFAULT '', remind_before_days INTEGER DEFAULT 7, created_at TEXT DEFAULT CURRENT_TIMESTAMP );

几个字段的取舍理由值得聊一聊。

expiry_date我用了 TEXT 类型存 ISO8601 格式的日期字符串,而不是存时间戳。原因很实际:SQLite 里WHERE expiry_date < '2025-06-01'这种字符串比较天然按字典序生效,不需要额外的日期函数,排序也自然按日期排。而且调试时直接打开数据库看到2025-06-01比看到一长串秒数直观得多。

dosage用纯文本而不是结构化字段。药品规格太杂了,“一日两次,一次一片”、“每次 5ml,一天三次”、“顿服”,硬要结构化反而会把自己写死。文本录入虽然不“智能”,但对用户最友好,也最灵活。

remind_before_days控制“提前几天预警”。不同药的重要程度不同,慢性病常用药可以提前 14 天提醒补货,短效感冒药提前 3 天提醒就行。这个字段给了用户灵活性,也避免所有药都按同一套规则预警导致提醒疲劳。

对应的 Dart 模型:

class Medicine { final int? id; final String name; final String category; final int stock; final DateTime expiryDate; final String dosage; final String manufacturer; final int remindBeforeDays; Medicine({ this.id, required this.name, this.category = 'default', this.stock = 0, required this.expiryDate, this.dosage = '', this.manufacturer = '', this.remindBeforeDays = 7, }); }

仓储层我把它拆成了MedicineRepository,所有 SQL 操作都收敛在这个类里,页面层不直接碰 SQL。这样做的好处是后面要换数据库引擎或者接后端同步时,只需要替换仓储实现,页面代码可以完全不动。

3.2 过期预警:这张“色卡”要放在数据库之外

过期状态我没有存库,而是放在代码里计算。原因很简单:状态依赖“今天”这个动态日期,存下来很快会过期。比如你昨天看某盒药还有 8 天过期,存了个“warning”状态,今天再看其实已经进入 7 天紧急区间了。存库的话必须每天跑定时任务去更新,纯属自找麻烦。

我的计算逻辑如下:

enum ExpireStatus { expired, urgent, warning, normal } ExpireStatus getExpireStatus(Medicine m) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final expireDay = DateTime(m.expiryDate.year, m.expiryDate.month, m.expiryDate.day); final diff = expireDay.difference(today).inDays; if (diff <= 0) return ExpireStatus.expired; if (diff <= 7) return ExpireStatus.urgent; if (diff <= m.remindBeforeDays) return ExpireStatus.warning; return ExpireStatus.normal; }

颜色规则:已过期红色、7 天内紧急橙色、预警区间黄色、正常绿色。在列表项左边加一条 3 像素宽的状态色条,用户扫一眼就知道哪些药需要处理,不用点开详情。

首页我做了三张统计卡:药品总数、即将过期数量、已过期数量。这些数据不是独立查表的,而是MedicineModel对外暴露的计算属性,内部遍历列表即可。列表本身由状态管理维护,任何增删改操作后重新加载,统计卡自动更新。

3.3 入库表单:快是第一位,校验不能省

家庭药箱的录入场景通常是这样的:买了一盒新药,站在药箱前随手要记一下。这时候如果表单要填 8 个字段、每个都要手动输入,用户大概率直接放弃。所以我把交互压缩成最短路径:

  • 药品名称必填,默认聚焦;
  • 过期日期默认填当前日期加 180 天,用户一般只需要在快过期时改一次;
  • 分类用下拉选择加自定义输入,常用分类预设“感冒、止痛、肠胃、外用、慢性病”;
  • 数量用步进器,默认 1。

校验逻辑我用 Flutter 自带的 Form + TextFormField validator,不引额外依赖:

TextFormField( decoration: const InputDecoration(labelText: '药品名称'), validator: (v) => (v == null || v.trim().isEmpty) ? '药品名称不能为空' : null, )

保存时先做表单校验,通过后调用仓储层写入数据库,再调context.read<MedicineModel>().refresh()刷新全局药品列表,最后Navigator.pop回到列表页。这个小闭环是整个药箱管理跑得顺不顺畅的关键——从打开 App 到录入一盒新药,理想状态是十秒以内完成。

4. 血糖记录功能的完整实现

4.1 需求拆解:血糖记录要记录什么

血糖记录是整个项目里最有嚼头的部分。市面上血糖记录 App 一大通病是把流程做得很重:要注册账号、绑定血糖仪、设置治疗方案、填写饮食日记。我的诉求很简单——家人测完血糖,打开 App,三秒内完成一条记录。

基于这个诉求,字段设计刻意做了减法:

class BloodSugarRecord { final int? id; final DateTime measuredAt; // 测量时间 final double value; // 血糖值,统一 mmol/L final String period; // 测量时段 final double? medicatedDose; // 用药剂量,可选 final String note; // 备注 }

measuredAt默认当前时间,也可以手动改,因为有人会补记早上忘测的空腹血糖。value是核心数值,界面输入时允许小数,比如 5.6、7.2。period是一个枚举值转成的字符串,分别是:空腹、餐前、餐后、睡前、随机。medicatedDose是可选字段,糖尿病人服药后记录剂量,方便后续观察用药和血糖的关联。note用来记“吃了两碗面”“运动了 30 分钟”这类上下文。

为什么要单独突出period这个字段?因为糖尿病管理里,医生最关注的往往不是单个数字,而是“空腹血糖范围”和“餐后血糖控制”这两个维度的对比。有了这个字段,我可以实现分组统计、分时段的平均值计算,也能在趋势图里用不同颜色区分空腹和餐后数据。

4.2 单位换算:mmol/L 与 mg/dL 的坑

国内血糖仪大多显示毫摩尔每升,也就是 mmol/L,但有些进口仪器的单位是毫克每分升,也就是 mg/dL。如果 App 只认一种单位,家里换台血糖仪或者导入外部 CSV 数据时就会出现数字完全对不上的情况。

这里有一个必须谨记的换算常数:1 mmol/L = 18.016 mg/dL。

const double kMmolToMgdl = 18.016; double toMgdl(double mmol) => mmol * kMmolToMgdl; double toMmol(double mgdl) => mgdl / kMmolToMgdl;

我见过不少简化方案直接用 18,反正小数点后差异不大。但实际项目里我踩过一次坑:外部导入的数据用的是 18,本地存储和展示用 18.016,两份数据混在一起后,7.8 和 7.75 这种细微偏差在折线图上形成了一条本不存在的“锯齿”。所以我的原则是:存储层统一用 mmol/L,换算系数固定 18.016,显示层再按用户偏好切换单位。天下数据对齐,才能谈趋势分析。

mmol/Lmg/dL(×18.016)常见含义
4.479.3偏低参考线
6.1109.9空腹血糖参考上限
7.8140.5餐后血糖参考上限
11.1200.0糖尿病诊断参考值

这套设计也体现在设置页:单位切换只是改了显示层的格式化函数,数据库里的数值分毫不动。后来我给图表加参考线时才体会到这个决策的价值——无论用户用什么单位,图表里的 6.1 和 7.8 参考线都能自动换算,不用临时抱佛脚。

4.3 记录列表、下拉刷新与分组展示

血糖列表页的主要交互是:按日期倒序查看记录,下拉刷新重新拉取最新数据,点击某条记录可以编辑或删除。列表逻辑用RefreshIndicator+ListView实现,OpenHarmony 分支上对 Material 组件支持良好,下拉刷新的回弹动画体验和 Android 上几乎一致。

RefreshIndicator( onRefresh: () => context.read<BloodSugarModel>().load(), child: ListView.separated( itemBuilder: (context, index) { final record = records[index]; return ListTile( title: Text(record.periodLabel), subtitle: Text(DateFormat('MM-dd HH:mm').format(record.measuredAt)), trailing: Text( '${record.value.toStringAsFixed(1)} mmol/L', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), ), ); }, ... ), )

列表按天分组比纯平铺更直观。我用 intl 的DateFormat('yyyy-MM-dd')把记录按日期聚合,同一天的记录合在一个二级标题下,再按时间倒序排。这样一眼就能看到“今天测了三次:空腹 5.8、早餐后 7.2、晚餐前 6.4”。

新增和编辑共用同一个表单页。编辑时用Navigator.push把记录 id 传过去,表单页加载时用context.read<BloodSugarModel>().findById(id)初始化。这一步虽然简单,但有个重要的生命周期细节:不要在initState里直接context.read后再异步加载数据而不判断状态,后面第 5 节我会专门展开讲。

4.4 用 fl_chart 画“血糖曲线”:近 7 天趋势图

记录血糖的最终目的是看趋势,而不是盯着单次数值。趋势图我选了 fl_chart 的 LineChart,这个包是纯 Dart 渲染,在 OpenHarmony 分支上没有任何兼容问题,是我测试下来最稳的图表库之一。

核心图表配置:

LineChart( LineChartData( minY: 0, maxY: 12, lineBarsData: [ LineChartBarData( spots: points, isCurved: true, color: const Color(0xFF3B82F6), barWidth: 2.5, dotData: const FlDotData(show: true), ), ], extraLinesData: ExtraLinesData( horizontalLines: [ HorizontalLine(y: 6.1, color: const Color(0xFFF59E0B), strokeWidth: 1), HorizontalLine(y: 7.8, color: const Color(0xFFEF4444), strokeWidth: 1), ], ), ), )

extraLinesData里的水平参考线是血糖图最实用的部分:空腹参考上限 6.1 用橙色,餐后参考上限 7.8 用红色,用户一眼就能看出自己的点在参考线之上还是之下,不需要懂任何医学知识。

实际开发时遇到一个细节:如果用户一天测 4 次,近 7 天就是 28 个点,全部画出来线会非常抖,肉眼很难捕捉趋势。我做了两种视图,用 Tab 切换:

  • 记录明细:近 7 天的全部原始记录点,点的大小按时段区分,空腹、餐后用不同颜色;
  • 日均趋势:把同一天的记录取平均值,画一条平滑的日均线,过滤日内波动,突出整体走向。

两个视图共用同一个数据源,只是对 points 做了不同的预处理。实现下来效果很理想,尤其日均趋势,能清晰看到某个人一周内血糖是先升后降还是持续偏高,给家人看也更容易理解。

5. 组件通信与 Provider 状态管理:这个项目里的实战用法

5.1 为什么药箱这种“小项目”也需要状态管理

很多人觉得家庭药箱 App 几十个页面顶天了,用 setState 加回调完全够,没必要上状态管理。我一开始也是这么做的,实际写到第三个页面就后悔了。

问题的根源是共享状态太多了。首页的统计卡片依赖药品列表,药品列表页的增删改要实时反映到首页统计,血糖页的最新一条记录要显示在首页的“最近血糖”卡片上,添加页保存后又必须触发列表页刷新。如果全部用 setState 加构造函数回调,数据流会从首页一路传到列表页、详情页、添加页,中间任何一个页面断了连接,刷新就失灵。

Provider 解决的是“数据源与页面解耦”的问题。核心思路是:让某个对象持有数据,并在数据变化时主动通知所有关心它的页面。页面不需要知道数据是哪来的、谁更新的,只需要订阅。这个思路对家庭药箱这种多处共享数据的小项目刚刚好,不过度设计,还足够灵活。

5.2 MultiProvider 的组装与仓库拆分

项目目录我分成四层,模型、仓储、状态、页面各司其职:

lib/ main.dart models/medicine.dart models/blood_sugar_record.dart repositories/medicine_repository.dart repositories/blood_sugar_repository.dart providers/medicine_model.dart providers/blood_sugar_model.dart pages/medicine_list_page.dart pages/medicine_edit_page.dart pages/blood_sugar_list_page.dart pages/blood_sugar_edit_page.dart pages/blood_sugar_chart_page.dart

main.dart里用 MultiProvider 一次性注册两个模型:

void main() { WidgetsFlutterBinding.ensureInitialized(); final medicineRepo = MedicineRepository(); final sugarRepo = BloodSugarRepository(); runApp( MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => MedicineModel(medicineRepo)..load(), ), ChangeNotifierProvider( create: (_) => BloodSugarModel(sugarRepo)..load(), ), ], child: const FamilyMedicineApp(), ), ); }

一个值得讨论的细节是仓储对象放哪里。我选择在 Provider 外部构造,再把实例传给 Model。对比在create回调内部直接MedicineModel(MedicineRepository()),外部注入的方式让 Model 的构造函数可以接受 mock 的仓储对象,后面写单元测试时完全不需要碰 Widget 层。代码多两行,但测试友好度高一个量级。

以BloodSugarModel为例:

class BloodSugarModel extends ChangeNotifier { BloodSugarModel(this._repository); final BloodSugarRepository _repository; List<BloodSugarRecord> _records = []; bool _loading = false; List<BloodSugarRecord> get records => List.unmodifiable(_records); bool get loading => _loading; Future<void> load() async { _loading = true; notifyListeners(); _records = await _repository.findAll(); _loading = false; notifyListeners(); } Future<void> add(BloodSugarRecord record) async { await _repository.insert(record); await load(); } Future<void> delete(int id) async { await _repository.delete(id); await load(); } }

load()里我调用了两次notifyListeners,一次在进入加载态,一次在数据就绪后。这样页面可以在加载中显示 spinner,而不是全程 white screen。虽然多了一次通知,但对用户体验的提升是实打实的。

5.3 Watch、Read 和跨页面刷新:别再写错的三个用法

Provider 的使用有三个高频 API,用得不对最容易出问题。

context.watch<T>()是订阅式读取。在build方法中调用它,Provider 的值一变化,当前 widget 就会重建。首页统计卡就是典型场景:

@override Widget build(BuildContext context) { final model = context.watch<MedicineModel>(); return Row( children: [ StatsCard(label: '药品总数', value: model.medicines.length), StatsCard(label: '即将过期', value: model.expiringCount), StatsCard(label: '已过期', value: model.expiredCount), ], ); }

context.read<T>()是只读不订阅。适合在事件回调里拿数据或触发方法,比如按钮点击后调add。

Consumer<T>是局部重建利器。它只重建自己的 child,适合列表项里需要频繁刷新的场景。

一个我在实际项目中踩过的坑:在 initState 里不能直接使用 context.watch。initState只执行一次,后面 Provider 的值再变也不会重新触发,watch 没有任何意义。正确做法是:在initState用context.read<T>()触发一次数据加载,在build里用context.watch<T>()订阅变化。

另一个坑是异步回调后的mounted判断。我在血糖记录保存成功后习惯直接Navigator.pop,某次在低内存 OpenHarmony 设备上测试时,保存过程稍慢,用户等不及手动返回了,结果异步回调执行时页面已经被销毁,直接抛异常。Fix 方式:

Future<void> _save() async { final model = context.read<BloodSugarModel>(); await model.add(record); if (!context.mounted) return; Navigator.pop(context); }

context.mounted是 Flutter 官方提供的安全判断。很多人觉得这是小概率事件,但健康记录类 App 恰恰最容易遇到——用户可能随时锁屏、切后台、退出页面,异步回调触发的概率远比想象中大。

5.4 组件通信的其他方案与选择建议

Provider 不是万能的,也不该万事都用它。我在这个项目里还用了另外两种通信方式:

第一种是回调 + route 返回值。比如添加药品页保存成功后,除了刷新全局状态,我还会让Navigator.pop(context, true)返回一个布尔值,列表页接收返回值后弹一个轻量的 SnackBar“药品已添加”。这种一次性动作不适合走 Provider,因为 SnackBar 只关心这一次保存的结果,不关心后续数据状态的变化。

第二种是 NotificationListener。我自定义了一个“分类快捷筛选”组件,点击分类按钮时向父级列表页冒泡一个事件,父级监听后切换列表筛选条件。这种从子到父的轻量事件用 Notification 比 Provider 更简洁,因为不需要一个全局对象来承载“当前选中了哪个分类”这种局部状态。

事件总线(event_bus)我也看过,但最后没引入。它在跨模块广播场景确实方便,但用多了会带来问题:全局事件满天飞,调试时根本不知道哪里触发了哪里。家庭药箱这种规模的项目,引入 event_bus 纯属增加认知负担。我的原则是:数据共享用 Provider,一次性动作用回调,局部事件用 Notification,能不引入的库就不引入。

6. 真机实测、渲染引擎与发布链路

6.1 Impeller 在 OpenHarmony 分支上的实测

热搜里 Flutter 相关的高频词有一条是 flutter impeller。Impeller 是 Flutter 新一代渲染引擎,核心目的是解决 Skia 的 shader 编译卡顿问题。但这个渲染引擎主要针对 Android、iOS 的渲染管线做了大量优化,OpenHarmony 分支的适配滞后于上游,支持程度很有限。

我在模拟器上强行开启 Impeller 后,血糖趋势图出现了可复现的问题:fl_chart 的坐标轴在横向拖动时偶发闪烁,图表区域时而白块闪现。关掉 Impeller、回到默认 Skia 渲染后,问题完全消失。

所以我的建议是:在 OpenHarmony 上跑项目,先别急着开 Impeller。如果你遇到页面卡顿,优先排查是不是某个高开销操作阻塞了 UI 线程,或者使用了过于复杂的动画叠加,而不是指望换渲染引擎一劳永逸。Skia 在 OpenHarmony 分支上的稳定性经过大量社区版本验证,没那么差。

6.2 摄像头扫描和 HDI:二期再考虑,先把核心跑通

家庭药箱管理 App 经常被问到一个问题:能不能扫药品条码自动识别药名?这个功能确实有价值,但它牵扯到的坑比想象多。

OpenHarmony 的 camera 能力接口和 Android 存在差异。Flutter 社区常用的 camera 插件在 OpenHarmony 上没有官方适配,真机调起摄像头的稳定性很不理想。要扫条码还得再叠一层条形码解析库,整个链路太长,一期硬做会拖垮主流程。

我的计划是二期走“系统相机拍照 + 本地 OCR/条码解析”路线,或者更朴素一点,先让用户在搜索框里输入药名。药箱管理 App 的核心价值是“记录和提醒”,不是“扫码识别”,把前者做扎实比塞一堆花哨功能重要得多。

至于外接血糖仪,那就更是硬件层的事了。OpenHarmony 的 HDI 接口负责驱动层抽象,普通应用开发者直接调用上层能力接口就行,不需要碰驱动代码。如果你团队做的是血糖仪配套 App,更值得投入精力的反而是三端数据模型的统一——iOS、Android、OpenHarmony 上的记录格式一致,后面做同步和迁移才不会有乱账。

6.3 上架前的 XTS 认证与隐私合规:健康数据是雷区

OpenHarmony 的应用市场认证会过一套 XTS 兼容性测试,它检查的不只是“App 能不能正常跑”,还包括权限声明是否合理、隐私合规是否到位。健康类应用在这个环节格外严格,因为血糖数据属于敏感个人信息。

我第一次提交测试时栽了一个小跟头:早期为了试验摄像头功能加过 camera 权限,后来功能砍掉了,权限声明却留在配置文件里。XTS 测试直接报“申请的权限与实际业务场景不符”,打回重改。这个教训我记到现在——权限最小化不是口号,是硬性门槛。

XTS 常见检查项我的处理方式
权限声明合理性只保留存储、通知等真正用到的权限
隐私政策缺失首次启动弹隐私说明,明确“数据全部本地存储,不上传”
包名、应用名规范按 DevEco 规范配置,避免特殊字符
版本签名使用 DevEco 的统一签名配置
敏感数据说明血糖、用药记录属健康数据,页面底部加免责声明

医疗健康类内容还有一条必须遵守的底线:App 里要明确提示用户“记录仅供参考,不构成诊疗建议”。我在设置页和血糖列表页底部都放了一行小字,既符合合规要求,也避免用户把软件记录当成医生诊断依据。

6.4 坑清单整理:我踩过的那些“网上查不到”

最后把这轮开发里比较有代表性的问题放进一个表,给后来人参考:

现象根本原因解决方式
flutter create不认 ohos 参数PATH 里的 Flutter 是官方版,不是 OHOS 分支用 alias 锁定分支,先flutter --version确认
启动闪退,日志停在 dart_vm_initializer某个插件没有 ohos 原生实现,抛 MissingPluginException看完整日志定位 channel,换适配包或纯 Dart 包
构建时报 apply flutter gradle plugin把 Android 混合工程结构误搬进 OpenHarmony删掉 Android 子工程,用 hvigor 构建 HAP
开启 Impeller 后图表闪烁OHOS 分支对 Impeller 支持不完整关闭 Impeller,用默认 Skia
异步保存后 Navigator.pop 崩溃页面已被销毁,回调还在执行if (!context.mounted) return;
XTS 报权限过多测试期间加的权限没删干净按“权限最小化”清理配置文件

这个项目跑稳之后,我的药箱终于从“纸箱里一堆过期待处理”变成了一目了然的列表:哪些药快过期、哪些药快用完、家人最近血糖趋势如何,打开 App 就能看到。个人最满意的是血糖记录的录入速度,家人两三天测一次,每次开 App 记录不超过五秒。

下一步我打算做两件事:一是把过期预警接入系统通知能力,让设备在药快过期时主动提醒,而不是等用户打开 App 才发现;二是把血糖数据做成 CSV 导出,月底可以直接发文件给医生参考。这些功能其实都围绕同一个思路——健康记录的终点是“被使用”,不是“被存储”。

如果你也在折腾 Flutter 和 OpenHarmony 的组合,我最想传达的一个经验是:这套跨端方案的实际成熟度远超多数人想象,但每一个坑都得亲手踩过才能真正摸清版本边界。社区资料虽然不像 Android 那么铺天盖地,但只要你掌握了“看 ohos 目录、锁版本、优先纯 Dart 包”这三板斧,大部分问题都能自己解决。祝你们跑通第一个 HAP。

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

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

立即咨询