我家里药箱的乱象应该是很多家庭的缩影:感冒药、降压药、创可贴、维生素全堆在一起,每次找药都要翻半天,月底偶尔还能翻出一两盒过期的。市面上确实有不少用药管理App,但大多要注册账号、绑定手机号、云端同步,对一台摆在家里、只服务一家人的OpenHarmony设备来说根本没必要。所以我干脆用Flutter写了一个家庭药箱管理App,目标平台就是OpenHarmony,所有数据都留在本地,打开即用,不用联网。
这个项目做下来最大的感触是:Flutter跨平台那套思路,在OpenHarmony上真的走得通。如果你有Flutter基础,又正好需要在OpenHarmony设备上快速落地一个业务清晰、体验扎实的工具类应用,这条路线非常值得试。下面把环境配置、核心功能拆解、设置页实现和真机调试过程完整记录下来,尤其是设置功能里那几个容易翻车的细节,希望能帮想试水的人少走弯路。
1. Flutter与OpenHarmony的缘分:为什么用这套组合做药箱App
1.1 OpenHarmony应用开发的另一条路
大部分人在OpenHarmony上做应用开发,第一个接触到的是一整套DevEco Studio加ArkTS加ArkUI的组合。这套东西确实成熟,声明式UI写起来也顺手,官方文档和示例都挺全。但我自己已经有两年多的Flutter经验,手头一堆现成的Dart代码和pub依赖,接到这个家庭药箱管理App需求时,第一个念头就是:能不能直接用Flutter跑在OpenHarmony上,把跨平台能力复用过来。
结论是可以。Flutter官方仓库里一直保留着对OpenHarmony的适配分支,社区迭代速度比想象中快,pub.dev上大量纯Dart包可以直接使用,UI层面仍然是Widget那一套,迁移成本远低于换一套语言和框架。选择这条路的逻辑其实不复杂:在ArkTS和Flutter之间做选择,本质上不是哪个技术更好,而是哪个更贴近你现有的团队积累和应用场景。ArkTS原生性能更直接,系统能力调用更省事,但如果你已经有Flutter版本的业务代码,或者团队主力技能就是Flutter,那用Flutter跑OpenHarmony,能省下至少一半的开发维护成本。
| 对比项 | ArkTS + ArkUI 原生开发 | Flutter on OpenHarmony |
|---|---|---|
| 学习成本 | 需要重新学习ArkTS语法和ArkUI组件体系 | 熟悉Flutter即可,几乎零门槛 |
| 代码复用 | 只能在OpenHarmony生态内复用 | 可复用现有Flutter业务代码和pub依赖 |
| UI一致性 | 各平台需要分别实现 | 同一套Widget在不同平台表现一致 |
| 系统能力调用 | 通过系统API直接调用,较直接 | 依赖插件适配,部分能力需要自行封装 |
| 性能表现 | 原生渲染,性能上限高 | 自绘引擎,复杂场景下需关注渲染开销 |
| 生态成熟度 | 文档、样例、SDK持续丰富中 | 插件生态仍在适配,部分包需要找替代方案 |
1.2 为什么恰恰是“家庭药箱”这个场景
选这个场景不是随手拍脑袋。一个药箱管理App要处理的核心问题无非几件事:药快过期了要知道、药吃完了要及时补、按医嘱按时吃。它天然就是本地优先的工具型应用,不涉及复杂服务端,不需要登录注册,数据量也小。这样我在验证Flutter on OpenHarmony的过程中,能把精力集中在界面交互、数据持久化、调度提醒这些真正体现跨平台能力的点上,而不是被业务复杂性拖住。
同时,药箱App的功能骨架刚好覆盖了移动应用最常见的几种形态:列表页、详情页、表单页、设置页,以及定时任务。尤其是设置页,几乎汇集了所有“全局状态管理”的典型问题:主题切换、开关状态、参数修改、跨页联动。把这些做明白了,一个App的架子基本就立住了。所以这篇文章会把设置功能的实现单独拿出来详细拆,它算是整个项目里最值得反复推敲的部分。
1.3 项目整体架构与目录结构
项目采用标准Flutter分层结构,UI层全部用Dart写,数据层通过插件访问本地SQLite,通知通过本地通知插件实现,设置项存储在shared_preferences。目录结构上,models放实体类,providers放状态管理,pages放页面,services放业务服务,utils放工具函数。ohos目录是OpenHarmony平台壳,包含module.json5、资源和原生构建配置。
技术选型如下表:
| 模块 | 选型 | 说明 |
|---|---|---|
| 界面框架 | Flutter | 使用Material 3组件库,构建跨端UI |
| 状态管理 | Provider | 轻量、易上手,适合中小型工具类App |
| 本地数据库 | sqflite_ohos | sqflite在OpenHarmony上的适配实现,API基本兼容 |
| 配置存储 | shared_preferences | 保存主题模式、提醒参数等轻量配置 |
| 本地通知 | flutter_local_notifications | 支持定时通知,用于用药与效期提醒 |
| 主题 | Material3 ColorScheme | 基于seedColor自动生成完整配色 |
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建:把Flutter的OpenHarmony工具链装好跑通
2.1 需要准备的物料清单
实际搭建时,需要的工具比普通Flutter环境多几样:OpenHarmony的SDK、flutter_flutter的适配分支、hvigor构建工具、hdc设备调试工具。如果你以后要在真机上测试,还需要一块OpenHarmony设备,我用的是RK3568开发板,买的时候已经刷好了官方系统镜像,省去了编译烧录的麻烦。关于设备树选择的问题后面会提一句,这里先不展开。
这里有个容易踩坑的地方:不要直接用官方Flutter下载页那个标准发行版,最好从OpenHarmony的flutter_flutter仓库克隆指定分支,版本对应关系在仓库说明里写得很清楚。我第一次直接拿标准版Flutter去构建,结果flutter build hap命令根本不存在,就是这个原因导致的。整个环境版本矩阵比较敏感,flutter、SDK、hvigor必须保持某个匹配组合,所以最稳妥的方式是照着仓库推荐版本来。
2.2 完整配置步骤
第一步,克隆flutter_flutter到本地,并把bin目录加入PATH环境变量。建议在.bashrc或.zshrc里追加:
bash复制export PATH="$PATH:/your/path/flutter/bin"
export DEVECO_SDK_HOME=/your/path/ohos-sdk
export OHOS_SDK_HOME=/your/path/ohos-sdk
DEVECO_SDK_HOME和OHOS_SDK_HOME这两个环境变量在不同版本的SDK里要求不完全一致,我建议两个都设上,指向同一个OpenHarmony SDK根目录,省得后面构建时提示找不到SDK。接下来运行flutter doctor,这时会在输出里看到OpenHarmony相关检查项,包括SDK路径、hvigor版本、Java环境等,逐项确认没有红叉,再进入下一步。
第四步是创建工程。这里有一个重要经验:不要手动在已有Flutter工程上硬塞ohos目录,直接使用flutter create --platforms ohos .为当前项目补全OpenHarmony平台文件,它会自动生成module.json5、build-profile.json5等文件,省去手工配置的麻烦。这个命令只有在适配分支上才可用,标准版Flutter会提示未知平台。
工程创建好之后,建议先把默认的计数器Demo构建一次,确认链路是通的,再开始写业务代码。构建命令是flutter build hap --debug,构建产物在build/ohos目录下,是一个带签名的hap文件。构建成功,说明整套环境基本没问题,可以放业务代码进来了。
2.3 镜像与依赖源配置
国内开发者配置Flutter环境,基本都绕不开pub镜像问题。pub.dev的包下载经常超时,通常需要配置PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL两个环境变量指向镜像。但这里要提醒的是,OpenHarmony的Flutter适配分支还多了一层深坑:构建hap时,原生依赖会通过ohpm仓库拉取,如果ohpm registry没有正确配置,构建过程会长时间卡在“Resolving dependencies”这一步。
ohpm的registry配置与普通Flutter镜像无关,需要在OpenHarmony sdk的tools目录下通过ohpm config命令单独设置。我第一次就是卡在这里,pub镜像配好了,Flutter侧依赖也正常拉取了,但hvigor解析原生依赖时卡了十几分钟都不动,最后才发现是ohpm仓库没配。所以这步一定不要遗漏。
3. 药箱核心数据与业务逻辑:先把“药”管理明白
3.1 药品实体与字段设计
实体设计上,我参考了市面上几款用药管理的做法,但做了一些精简。核心字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | INTEGER | 主键自增 |
| name | TEXT | 药品名称 |
| spec | TEXT | 规格,如“0.25g*24片” |
| stock | INTEGER | 库存数量,按最小包装计 |
| batchNo | TEXT | 批号,同一药品不同批次效期不同 |
| expiryDate | TEXT | 有效期,存“yyyy-MM-dd”字符串 |
| frequency | TEXT | 服用频次描述,如“每日1次,每次1片” |
| alertDaysBefore | INTEGER | 提前提醒天数,默认7 |
| remark | TEXT | 备注 |
| createdAt | INTEGER | 创建时间,毫秒时间戳 |
| updatedAt | INTEGER | 最近更新时间 |
其中最容易忽略的是“批号”。同一盒药可能有两批,有效期不同,如果只按名称去重,很容易把临期药漏掉。批号字段在添加药品时不是必填的,但一旦填了,首页列表就按“名称+批号”作为识别维度,更贴近真实家庭药箱的场景。
有效期字段我最终存的是“yyyy-MM-dd”格式的本地日期字符串,而不是时间戳。原因放到后面踩坑部分细讲,这里先说结论:在家庭药箱场景下,日期字符串是最直接、最不容易出错的方案,排序也方便,按字符串排序和按日期排序结果一致,不需要做时区换算。
3.2 数据库表结构与操作封装
数据库用了sqflite_ohos,它是sqflite在OpenHarmony上的适配实现,API基本和sqflite一致。建表SQL大致如下:
sql复制CREATE TABLE medicines (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
spec TEXT,
stock INTEGER DEFAULT 1,
batchNo TEXT,
expiryDate TEXT NOT NULL,
frequency TEXT,
alertDaysBefore INTEGER DEFAULT 7,
remark TEXT,
createdAt INTEGER,
updatedAt INTEGER
);
CREATE TABLE records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
medicineId INTEGER NOT NULL,
takenAt INTEGER NOT NULL,
amount INTEGER DEFAULT 1
);
DAO层封装成单独的文件,对外暴露insertMedicine、updateMedicine、deleteMedicine、queryAllMedicines、queryByExpiry等方法。封装的好处是页面不用关心SQL细节,后续换成drift这类代码生成器时,也只需要改DAO层即可。实际开发中如果表多了,建议用代码生成器来管理,但这个小项目用原生sqflite已经完全够用。
3.3 效期计算与排序展示
效期状态我用一个枚举表示:远离过期、即将过期、已过期、已用尽。计算逻辑就是把expiryDate字符串解析成DateTime,减去当天的零点日期,得到相差天数:
dart复制enum ExpiryStatus { fresh, soon, expired, outOfStock }
ExpiryStatus calcExpiryStatus(String expiryDate, int stock) {
if (stock <= 0) return ExpiryStatus.outOfStock;
final expiry = DateTime.parse(expiryDate);
final today = DateTime.now();
final todayZero = DateTime(today.year, today.month, today.day);
final diffDays = expiry.difference(todayZero).inDays;
if (diffDays < 0) return ExpiryStatus.expired;
if (diffDays <= 7) return ExpiryStatus.soon;
return ExpiryStatus.fresh;
}
列表页排序我用了一个组合策略:先按状态的紧急程度排,已过期最前、即将过期其次,再按有效期日期排,最后按创建时间排。也就是把药品从SQL查出来之后,在Dart里做一个多级Comparator排序。最初我想直接在SQL里用ORDER BY解决,但状态是动态算出来的,在SQL里要么写CASE WHEN,要么先在应用层算好状态再排,最后还是决定在Dart层做,逻辑更清晰,也方便调整排序规则。
3.4 服药记录与库存联动
服药记录表结构比较简单:药品ID、服药时间、数量。用户点“服药”按钮时,业务层开启一个事务,插入一条record,同时更新对应药品的stock字段。事务保证了两个操作要么都成功、要么都失败,不会出现“记录了服药但库存没扣”的脏数据。
库存扣减之后会重新检查数量:如果库存变成0,就标记药品状态为“用尽”,并触发一次本地通知,提示该补充药品了;如果没变0但已低于设置的阈值,也可以弹出提示,阈值放在设置页里让用户自己配。这一块把库存、状态、通知串起来,是项目里联动逻辑比较复杂的地方。首页列表的每个条目旁边都会显示当前的效期状态Tag,绿色、黄色、红色一眼看出哪些药着急处理。
4. 设置功能拆解:配置项、持久化与主题联动的完整实现
4.1 设置页到底该有哪些功能
家庭药箱App的设置页,我一共规划了四组功能。第一组是提醒设置,包含提醒总开关、默认提前几天提醒、每天提醒时间段三个控件;第二组是外观设置,包含主题模式选择和列表密度;第三组是数据管理,包含导出用药记录和清空所有数据;第四组是关于,包含版本号和开源许可入口。
每一组设置都对应一个具体的用户诉求:老人喜欢大字体和大图标,年轻人喜欢深色模式,有的人药多需要提前14天提醒,有的人提前3天就够了。设置页就是把参数开放出来,让每个家庭按自己的习惯去配置。设计页面时我刻意保持了简单层级,没有做二级页面,全部设置项放在一个ScrollView里,因为对家庭用户来说,少点跳转就少点学习成本。
4.2 配置模型、持久化层与状态暴露
设置项我用一个AppSettings类封装,字段包括themeMode、notificationEnabled、alertDays、notifyTime、density等。AppSettings的读写通过SettingRepository完成,底层就是shared_preferences。加载时机放在App启动时,main函数里先await SettingRepository.load(),拿到初值之后再runApp,这样能避免启动瞬间设置项未加载导致的界面闪烁。
状态管理选了Provider。SettingRepository负责数据读写,SettingsController继承ChangeNotifier,暴露themeMode、notificationEnabled等getter,并提供updateThemeMode、updateAlertDays这类方法。方法内部先更新内存状态,再异步持久化,最后notifyListeners通知所有监听者。UI层用context.watch<SettingsController>()监听变化即可。这样设置页的每个开关、选择器都只是Controller方法的一个调用点,改一处,全局响应。
4.3 主题切换:从按钮点击到全局换肤的完整链路
主题切换是设置页里最典型的功能。实现链路是:用户在设置页选择“深色模式”,调用SettingsController.updateThemeMode(ThemeMode.dark),内存状态更新,持久化写入shared_preferences,然后notifyListeners。MaterialApp外层通过context.watch获取到新的themeMode,触发整个Widget树重建,重新生成ThemeData并向下传递。
关键在ThemeData的生成方式。我建议用Material 3的ColorScheme,通过seedColor让系统自动派生出一套完整的配色,而不是手动指定几十个颜色。这样浅色、深色两套主题只需要分别给定一个seedColor,整体视觉就统一了:
dart复制static ThemeData buildTheme(Brightness brightness) {
final seed = const Color(0xFF4CAF50);
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: seed,
brightness: brightness,
),
);
}
一个常见误区是在页面里手动修改某个Widget颜色来实现“部分换肤”,比如“深色模式下标题想用蓝色就直接在Text里写死蓝色”。这种做法短期能用,但设置项一旦多起来,页面越改越乱。正确做法是始终从Theme.of(context)取颜色,业务代码只读取不写死颜色值,这样主题切换时所有颜色自动跟着变。
4.4 showLicensePage主题颜色不生效的根因与解法
这个坑值得单独说。做“关于”页面时,点击“开源许可”,弹出的页面背景始终是浅色,和已经设置的深色主题完全不搭。我先检查了MaterialApp的theme、darkTheme配置,确认没写错;又在调用showLicensePage之前打印了Theme.of(context),确认拿到的确实是深色主题。那问题就不是主题没生效,而是showLicensePage内部没有正确使用当前主题。
排查到源码发现,showLicensePage最终走的是ModalRoute加一个默认的Theme,弹出的页面组件并不会跟随外部主题变化而重建。这属于框架层面的行为,自己改不了框架,只能绕过去。我的解法是自己写了一个LicensePage,用showDialog包裹,在Dialog外面套一层Theme(data: Theme.of(context)),再把开源许可证内容用LicenseRegistry.licenses取出来渲染。这样弹窗样式和列表项都能正确跟随全局主题,颜色终于统一了。
这个问题在标准Android Flutter上不容易遇到,在OpenHarmony的适配版本上表现得比较明显,算是这套组合的特有怪癖。做设置类功能时,如果发现“某个弹窗不跟主题变”,优先检查它是不是通过默认路由或原生路由打开的,再决定是包一层Theme还是干脆自绘页面。
4.5 通知与设置的实时联动
提醒总开关从ON改成OFF时,如果只改配置、不处理已排程的通知,很容易出现“明明关了提醒,到点还弹通知”的问题。我在SettingsController里为notificationEnabled做了专门逻辑:改成false时,遍历所有已排程的本地通知ID并取消;改成true时,先查询所有未过期的药品,重新注册未来30天内的提醒。这个“关则全清、开则重建”的策略,比逐个判断要省心得多。
需要注意,App内的开关和系统通知权限是两回事。用户可能在系统设置里单独关掉通知权限,这时App内开关依然是开,但通知实际不会弹出。我在设置页加了一个权限状态检查,每次进入设置页时查询一次系统通知权限,如果系统权限和App内开关状态不一致,就提示用户去系统设置里打开通知权限。这一步不做的话,用户很容易误以为是功能坏了。
4.6 导出记录与清空数据的实操细节
导出用药记录时,我把所有record按时间排序,拼成CSV格式写到应用的文档目录,然后用文件分享面板让用户选择保存到系统文件管理器或通过其他工具发送。CSV第一行是表头,字段都做了中文化处理,方便家庭成员直接拿Excel或WPS打开,不需要额外转换。
清空数据是危险操作,我做了双重确认:第一次弹普通Dialog,第二次要求用户输入“确认”两个字才能执行。清空动作放在事务里执行,同时删除药品表、记录表,重置自增序列,清空配置里的提醒计划,再取消所有通知。这个操作做完之后,首页列表和统计页必须同步刷新,不能出现残留的过期提醒或脏数据。这套确认机制虽然多了一步,但对家里老人来说反而更安全,能明显降低误触概率。
5. 打包与上机验证:在OpenHarmony设备上跑起来
5.1 构建hap包的完整流程
日常开发用debug包足够,验证没问题后要发到设备长期使用,建议构建release包。执行flutter build hap --release,构建完成后会在build/ohos/release目录下生成签名后的hap文件。如果只是自己设备用,调试签名就够了;如果要在多台设备上装,最好在build-profile.json5里配置自己的正式签名证书。
OpenHarmony的签名体系由Profile文件、证书文件、密钥库三个要素组成,配置方式与Android的签名类似但又不完全一样。我第一次配置时卡在“安装时签名不一致”这个报错上,后来发现是调试签名和发布签名的指纹不匹配,把签名文件和Profile重新生成了一份才解决。建议从一开始就把调试和发布签名分开管理,后面再做正式分发时会省很多事。
5.2 用hdc连接设备并安装
OpenHarmony的调试工具是hdc,功能类似Android的adb。设备通过USB或网络连接到开发机后,先用hdc list targets确认设备在线,然后hdc install /your/path/xxx.hap安装,最后通过点击设备桌面图标或者使用aa命令启动应用。启动命令里的包名和入口Ability名称可以在module.json5里查到,不同模板可能不一样,直接复制模板示例里的值再修改即可。
如果你用的是RK3568之类的开发板,最常见的问题是“到底该选哪个系统镜像”。我的经验是:如果只是跑Flutter应用,买板子时直接让卖家刷好官方发布镜像即可,不要自己去编译系统、不要纠结设备树选择。设备树是系统移植阶段的事情,和应用开发者的关系不大;等你真需要裁剪系统时再回过头研究设备树,那时候已经不是App开发要解决的问题了。
5.3 真机验证清单
换到真机之后,很多模拟器上发现不了的问题会冒出来。我给自己整理了一份自测清单:冷启动后能否秒开,不白屏不卡顿;从浅色切到深色主题后,整个界面包括弹窗、状态栏、输入框光标颜色是否全部正常;设置提醒时间后杀掉进程,锁屏,到点能否正常弹出通知;杀掉App再打开,设置项和药品数据是否还在;长时间放后台再回前台,列表状态是否丢失。每一项都有对应排查方向,如果某一步失败,优先考虑是平台外壳问题还是业务代码问题。
6. 踩坑回顾:这套组合最容易翻车的地方
6.1 插件兼容性是第一道坎
整个项目做下来,越做越发现Flutter on OpenHarmony最大的成本不在语言和框架,而在插件生态。很多在Android上常用的库,在OpenHarmony上没有原生实现,构建时可能直接报missing plugin,也可能编译期没问题、运行期调用空实现,后者更难排查。
排查链路是这样的:先看pubspec.lock里这个插件的版本,然后去OpenHarmony仓库搜索有没有对应的适配包,有就把依赖源替换成同名API的适配版本;没有的话,看这个插件是否只是纯Dart代码,纯Dart的包通常可以直接用;实在不行,就自己写一个MethodChannel通道,在ohos侧实现原生逻辑调用。工作量虽然大一点,但OpenHarmony的Ability和UIAbility机制已经比较清晰,照着官方文档撸一遍是可行的。
6.2 版本切换后疯狂clean
OpenHarmony的Flutter工具链迭代速度很快,中途升级过一次SDK,然后发现hvigor版本对不上,构建直接失败。一开始我以为是代码问题,查了半天,最后在一个issue里看到有人说“升级SDK后要flutter clean,并删除ohos目录下的.hvigor和build缓存”。照做之后果然恢复正常。这里也提醒一下,工具链还在快速演进阶段,遇到诡异的构建问题,先怀疑缓存,再怀疑环境,最后才去怀疑业务代码。
6.3 日期存储的时区偏移
效期管理的核心就是日期计算,这里踩到的一个细节问题是时区。如果直接把过期时间拧成UTC时间戳存储,在设备时区设置不正确或者用户跨时区时,会出现“明明今天刚过期,界面显示还有一天”的情况。我在项目里统一改成本地日期字符串之后,这个问题彻底消失。结论是:像药箱这种纯本地、无多端同步的工具类应用,日期就应该用最简单的本地格式,没必要为了“规范”去承担UTC带来的复杂度。
6.4 键盘与列表滚动冲突
药箱App里有药品表单页,好几个输入框,真机上测试时发现,点击底部被键盘遮挡的输入框,列表弹不起来,输入框被完全盖住。排查后发现是Android路由的resizeToAvoidBottomInset和OpenHarmony窗口Insets机制配合不完全一致造成的。最终在页面外层加处理:键盘弹出时,手动滚动到对应输入框位置;输入框焦点变化时做一次ensureVisible。这类平台差异没有太多文档可查,全靠反复试,但跑通了之后表单体验会顺畅很多。
个人体会是这个组合并不适合所有项目,但非常适合家庭药箱这类本地优先、业务中等复杂度的工具型应用。做完整套之后,最大的感受是Flutter往OpenHarmony迁移这条路已经能走了,只是需要你花时间去处理插件适配和平台差异。如果你也想试试,建议先从一个像家庭药箱这样的小场景切入,不要一上来就做涉及大量原生调用的项目。后面我打算给这个App加上用药统计图表和家庭成员独立记录,到时候有新坑再来分享。
