家里有猫的朋友应该都经历过这个场景:三个月前信誓旦旦地给主子做完体内驱虫,三个月后死活想不起来上次到底是体内还是体外、用的哪款药,翻了十几分钟相册才勉强对上。宠物医生的建议是体外每月一次、体内每季度一次,这种频率全靠人脑记,时间一长必然漏。我一直想写个小工具专门管这件事,正好那阵子在折腾 Flutter for OpenHarmony 的跨端开发,就顺手把「猫咪管家」App 的驱虫记录模块完整做了一遍。这篇文章不是一个官方教程,就是一次能跑的实战复盘:业务建模、页面实现、数据存储,以及 OpenHarmony 真机上调试时踩过的几个坑。无论你是想给自家猫写个简单的记录工具,还是准备入坑 OpenHarmony 应用开发,里面的代码思路和排错路径都可以直接拿去用。
1. 先交代选型:OpenHarmony 上为什么用 Flutter 做这个模块
1.1 当前 OpenHarmony 应用开发的三种路线
OpenHarmony 原生应用官方主推的是 ArkTS 声明式开发,语法和 SwiftUI 有些神似,写起来并不难。但对我来说有个现实问题:团队已有的移动端代码基本都是 Flutter 写的,如果只为了一个 OpenHarmony 版本就把核心逻辑用 ArkTS 重写一遍,后面维护成本直接翻倍。所以我把视线放在跨端方案上。
当时摆在面前的大致是三条路线:第一是 ArkTS 原生,优点是官方支持力度最大、系统能力接入最全,缺点是我们团队不熟;第二是 Flutter 的 OpenHarmony 社区适配分支,优点是能复用现有 Dart 业务代码和大部分 UI,缺点是部分插件需要重新找适配版本;第三是 WebView 套壳之类的方式,前期快但交互和性能都拉胯,不太适合做工具类应用。对比完之后我选了第二条,因为驱虫记录这种功能本质上是表单、列表、本地存储的组合,Flutter 生态里的成熟组件能覆盖绝大部分需求。
1.2 环境准备与模块边界划分
这里说的环境准备不是单纯跑一个flutter create。要在 OpenHarmony 上跑 Flutter,得先拉社区维护的 flutter_ohos 分支,把它作为本地的 Flutter SDK 使用,然后配合 OpenHarmony SDK 编译出产物。初始化 OpenHarmony 原生壳工程后,再通过适配好的 Flutter 插件把渲染层挂上去。第一次跑通大概需要大半天,主要时间花在同步分支和配置签名上,真正写页面反而很快。
工程结构我按 feature 拆分,避免和 App 里其他模块混在一起:
miao_manager/ ├── lib/ │ ├── main.dart │ └── features/ │ ├── deworm/ │ │ ├── model/deworm_record.dart │ │ ├── page/add_record_page.dart │ │ ├── page/record_list_page.dart │ │ └── service/deworm_store.dart │ └── cat/ └── ohos/ ├── entry/src/main/ └── build-profile.json5这个模块边界很重要。驱虫记录将来大概率还要跟疫苗记录、洗澡记录合并展示,如果一开始就把页面和存储揉在一起,后面拆分会很痛苦。我宁可多建几个文件夹,也不要在main.dart里堆业务。
2. 驱虫记录的业务建模:不能只存一个日期了事
2.1 体内驱虫和体外驱虫的周期差异
很多人做这类功能时最容易犯的错误是:建一个表,存一个日期,再加一个备注,完事。但只要你真的喂过猫、认真问过宠物医生,就会知道这里面的规则比想象中细。
体外驱虫通常是滴剂,滴在猫脖子后面,主要防跳蚤、蜱虫,周期一般是 1 个月;体内驱虫是口服片剂,针对蛔虫、绦虫这类肠道寄生虫,周期通常是 3 个月。幼猫首次驱虫的时间也有讲究,一般建议满 6 周龄以后才开始。还有一条容易被忽略:如果猫近期要打疫苗,最好跟驱虫错开一周左右。这些业务规则看起来细碎,但它们直接决定了"下一次应该什么时候做"的计算逻辑。
| 驱虫类型 | 典型周期 | 常见形态 | 备注 |
|---|---|---|---|
| 体外驱虫 | 约 30 天 | 滴剂、喷剂 | 部分多猫家庭需要隔离观察 |
| 体内驱虫 | 约 90 天 | 口服片、膏剂 | 按体重分段给药很常见 |
| 幼猫首驱 | 满 6 周龄后 | 遵医嘱 | 不同药品起用周龄有差异 |
所以建模的第一步不是写页面,而是把"驱虫记录"拆成:什么时候做的、做的什么类型、用的什么药、下一次理论日期是什么、现在处于什么状态。这五个信息缺一个,后面做提醒和筛选都会缺胳膊少腿。
2.2 数据模型与到期日计算方法
Dart 模型我定义成下面这样,字段不多,但每个都有用处:
enum DewormType { internal, // 体内 external; // 体外 String get label => this == DewormType.internal ? '体内' : '体外'; } class DewormRecord { final String id; final String catName; final DewormType type; final String medicineName; final double weight; // 当时的体重,kg final DateTime usedDate; // 实际用药日期 final DateTime nextDueDate; // 理论上下次用药日期 final bool remindEnabled; final String note; final DateTime createdAt; DewormRecord({ required this.id, required this.catName, required this.type, required this.medicineName, required this.weight, required this.usedDate, required this.nextDueDate, this.remindEnabled = true, this.note = '', DateTime? createdAt, }) : createdAt = createdAt ?? DateTime.now(); }注意我把nextDueDate单独落库了,而不是在列表页每次现算。原因很简单:列表页和提醒逻辑会频繁读取这个日期,如果每次都从usedDate加上周期去推,万一以后出现"用户修改了用药日期"或者"某款药周期不同"的情况,所有展示逻辑都要跟着改。落库存一个冗余字段,换来的是查询和展示的简单,这是典型的空间换时间。
计算下个到期日的核心方法放在 model 层或独立的业务工具里,不要放在页面里:
DateTime computeNextDueDate({ required DewormType type, required DateTime usedDate, }) { final duration = type == DewormType.external ? Duration(days: 30) : Duration(days: 90); return usedDate.add(duration); }体外默认 30 天,体内默认 90 天。如果以后要支持自定义周期,把这个方法加一个customDays参数就行,页面调用方不用动。
2.3 记录状态的三种判定逻辑
列表上需要展示"还剩几天"或者"过期了几天",所以我在 model 上加了状态判定:
enum RecordStatus { normal, // 时间充裕 approaching, // 距离到期不足7天 overdue, // 已过期 } RecordStatus evaluateStatus(DateTime today) { final diff = nextDueDate.difference(today).inDays; if (diff < 0) return RecordStatus.overdue; if (diff <= 7) return RecordStatus.approaching; return RecordStatus.normal; }为什么是 7 天?因为体外驱虫通常 30 天一次,提前 7 天提醒意味着还剩差不多四分之一周期,用户有充足时间买药、安排时间;如果是体外驱虫还涉及到需要挑一个好天气(给药后最好别马上洗澡),提前 7 天比较合理。这个阈值我不建议做成写死的魔法数字,以后可以放到设置里让用户自己调。
3. 添加记录页:表单、日期选择与自动到期推算
3.1 页面结构与字段校验
添加记录页用的是 Flutter 自带的Form体系,结构上从上到下分别是:猫名输入框、驱虫类型选择、药品名输入、体重输入、用药日期、备注。其中类型选择我用了SegmentedButton,比 Dropdown 更直观,用户一眼就能看懂"体内还是体外"两选一。
class AddRecordPage extends StatefulWidget { const AddRecordPage({super.key}); // ... } class _AddRecordPageState extends State<AddRecordPage> { final _formKey = GlobalKey<FormState>(); final _catController = TextEditingController(); final _medicineController = TextEditingController(); final _weightController = TextEditingController(); DewormType _dewormType = DewormType.external; DateTime _usedDate = DateTime.now(); String _note = ''; Future<void> _save() async { if (!_formKey.currentState!.validate()) return; // 计算下一次日期 final nextDueDate = computeNextDueDate( type: _dewormType, usedDate: _usedDate, ); // 构造记录并交给 store } }校验规则我只设置了三条:猫名不能为空,药品名不能为空,用药日期不能晚于今天。这里有个容易被忽视的小细节:用药日期使用时间选择器之后,必须把时分秒清零,否则在后续比较日期时会出现"明明是 6 月 1 日选的,计算结果却是 6 月 0 日"这种谐音梗式的 bug。清零操作非常便宜:
final cleanDate = DateTime(widget.year, widget.month, widget.day);3.2 日期选择与"补录"场景
日期默认填今天。但如果用户是翻着聊天记录突然想起来"一个月前好像做过一次",就需要支持补录。补录的意思是:允许选择过去的日期,但要给出提示——下次到期日会根据这个过去的日期重新推算,而不是从今天开始算。
我是这样处理的:当用户选择的日期早于今天时,弹一个确认对话框,文案大概是"本次记录将作为 2024-05-12 的生效记录,下次到期日从该日期起算,是否继续?"。确认后才让保存按钮真正生效。这个细节看起来多了一步操作,但能避免用户把历史记录和未来计划搞混,体验上是加分项。
3.3 自动推算到期日并实时预览
我比较反感表单填完了还不知道结果是什么,所以在页面底部加了一块"下次预计:2024-08-12"的实时预览。实现方式很简单:监听类型切换和日期变化,每次变化都重新调用computeNextDueDate,用Text展示结果。
这块逻辑本身不难,难点在于"变更通知"要覆盖两个输入源:SegmentedButton的 onSelectionChanged,以及日期点击回调。我建议把它收敛成一个内部方法_refreshPreview(),两处回调都调用它,避免逻辑分散。
保存成功后,我会做三件事:把页面 pop 掉、刷新列表、弹一个 SnackBar 提示"已保存,下次预计 XX"。到了这一步,一个能录入、能推算的记录功能已经成型了。
4. 首页记录列表:剩余天数、分类筛选与滑动操作
4.1 列表项 UI 的信息层级
记录列表页是整个模块的入口,用户打开 App 第一眼看到的就是它。我的 UI 信息层级是:最显眼的位置放药品名称,副标题放猫名和类型标签,右侧一个大号的剩余天数数字,左下角放用药日期和理论到期日。
Card( child: ListTile( title: Text(record.medicineName), subtitle: Text('${record.catName} · ${record.type.label} · 上次 ${dateFormat}'), trailing: _StatusBadge(status: recordStatus, days: diffDays), ), )为什么要给右侧的剩余天数这么大的视觉权重?因为这个页面本质上要回答的问题是"我该什么时候给猫驱虫"。如果视觉上平均用力,用户还是要逐个点开详情才能找到答案,那就失去工具价值了。让剩余天数成为视觉焦点,整个列表才真正"可用"。
4.2 剩余天数计算的时区与零点坑
这个坑是我实测踩出来的。一开始我直接用DateTime.now()和record.nextDueDate做difference,然后取.inDays,结果发现明明明天才到期,列表却显示"已逾期 1 天"。查了半天才意识到,问题出在时间粒度上。
正确做法是先把两个时间都归一到"当天零点",再做差值:
int daysUntil(DateTime target) { final now = DateTime.now(); final today = DateTime(now.year, now.month, now.day); final dueDay = DateTime(target.year, target.month, target.day); return dueDay.difference(today).inDays; }把时分秒都置零之后,difference的结果才精确到天。这个坑在 Android 和 iOS 上也可能出现,但在 OpenHarmony 上更容易暴露,因为部分设备系统的默认时区处理让我一度怀疑是自己代码问题。遇到这种"差一天"的边界,先检查时间归一化,大概率能解决。
4.3 筛选、滑动删除与空态
列表页我加了两个筛选维度:左上角的类型 Tab(全部 / 体内 / 体外),右上角的猫名下拉筛选。多猫家庭里,每只猫的驱虫记录混在一起会非常乱,所以筛选不是锦上添花,是刚需。
删除操作用的Dismissible,但加了二次确认。原因很简单:误删一条历史记录,后面整个记录链就断了。我的实现是在confirmDismiss里弹一个 Dialog,确认后才真正移除。这比SnackBar撤销的模式更适合这种场景,因为记录的"副作用"是时间性的,撤销窗口过了就找不回来了。
空态我单独做了一个 Widget,文案是"还没有驱虫记录,点击右上角添加第一支"。在空态引导时顺手加了一个示例数据按钮,方便开发阶段快速预览页面效果,上线前可以去掉。
5. 数据持久化:OpenHarmony 沙盒下的本地存储实战
5.1 shared_preferences 在 OHOS 适配上的依赖配置
驱虫记录的数据量不会太大,几年下来顶多几十条到一百条,用完整数据库其实有点重。所以我第一版选了shared_preferences的 OpenHarmony 适配版本,既够用,又能少踩插件适配的坑。
这里要提醒一句:在 OpenHarmony 工程里使用 Flutter 插件,不能默认拿 pub.dev 上的原版。部分插件依赖 Android/iOS 的原生实现,在 OpenHarmony 上跑不起来,需要换成带 ohos 适配的 fork 版本,或者从适配仓库拉源码。以 shared_preferences 为例,配置依赖时要指定适配版来源,不是直接写shared_preferences: ^2.2.0就完事。
依赖配好之后,一定要做一次"写入、杀进程、重启、读回"的四步验证。这个验证能确认插件在沙盒环境下真的持久化了,而不是只存在内存里。
5.2 记录序列化与批量读写
shared_preferences 存的是键值对,所以我把 List 序列化成 JSON 字符串再存。封装了一个DewormStore,对外只暴露两个方法:
class DewormStore { static const _key = 'deworm_records_v1'; Future<List<DewormRecord>> loadAll() async { final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_key); if (raw == null || raw.isEmpty) return []; final list = jsonDecode(raw) as List<dynamic>; return list .map((e) => DewormRecord.fromJson(e as Map<String, dynamic>)) .toList(); } Future<void> saveAll(List<DewormRecord> records) async { final prefs = await SharedPreferences.getInstance(); final raw = jsonEncode(records.map((e) => e.toJson()).toList()); await prefs.setString(_key, raw); } }fromJson和toJson是手写的。虽然手写字段有点枯燥,但胜在完全可控,不用引入 json_serializable 那套代码生成流程。字段不多的时候,手写其实是更稳的方案。
读写过程中有个细节:写完之后立刻读出来比对一次。我在调试某个真机版本时,发现 setString 返回了成功,但杀进程后数据丢了,最后定位到是插件适配层的异步写入没有真正落盘。从那以后,我的所有持久化操作都强制要求"写入后读回校验"。
5.3 数据量变大之后的存储升级路线
shared_preferences 方案只适合数据量小、查询简单的场景。如果以后猫咪管家要扩展成完整的宠物健康档案,记录会包含疫苗、洗澡、体重、就诊等多种类型,届时必须切换到真正的数据库。
升级路线我会选择以 SQLite 为底层的适配方案,核心字段可以直接沿用现在的模型,建表语句和DewormRecord字段一一对应。迁移那天的关键是做好旧数据导入,所以我现在存储用 JSON,本身就是一种自描述的中间格式,迁移时解析成本很低。从现在开始设计好数据模型,以后换存储引擎不用改业务代码。
6. 真机调试中踩过的适配坑(hdc、字体、窗口尺寸)
6.1 从 adb 思维切换到 hdc 调试流程
用 OpenHarmony 真机调试 Flutter,最开始的别扭来自命令工具。Android 上习惯用 adb,这边对应的是 hdc。常用的几条命令区别不大,但有一个容易踩的坑:hdc 的日志输出不是adb logcat那条链路,调试 Flutter 页面异常时,用 flutter 自带的 console 不一定能看到完整的 native 报错。
我的实际流程是三步:
- 先用
hdc list targets确认设备已连接; - 通过 IDE 或命令行安装产物;
- 查看系统日志时用
hdc shell hilog过滤关键字,比如包名或我自己在代码里埋的 TAG。
Flutter 的debugPrint在 release 构建里默认不输出,所以排查真机问题时我习惯在关键路径上加一个PrintUtils.log,内部根据编译环境决定走 debugPrint 还是系统日志通道,保证线上也能定位问题。
6.2 字体、键盘与窗口尺寸三个适配细节
OpenHarmony 真机上,Flutter 渲染的中文偶尔会出现豆腐块,也就是字体缺失变成方框。这个问题的根源是部分设备没有内置全量中文字体。我最后是往工程里打了一个精简中文字体子集,然后在MaterialApp的theme.fontFamily里指定。如果你们的应用有自定义字体,最好也做子集化,否则包体直接膨胀几十兆。
第二个坑是键盘。OpenHarmony 版本里resizeToAvoidBottomInset在某些设备上表现不一致,表单页面弹键盘会把提交按钮顶出可视区。我的解决方式比较土:整个表单外包一层SafeArea+SingleChildScrollView,然后监听键盘高度用AnimatedPadding给底部留出空间。这个办法虽然简单,但实测最可靠。
第三个坑是窗口尺寸。OpenHarmony 应用默认窗口并不总是铺满整个屏幕,尤其是一些开发板和平板形态的设备上,Flutter 画布可能只占屏幕一部分。所有 UI 布局都要基于MediaQuery动态计算,不能用写死的宽高。列表和卡片我统一用ListView.builder和Card,配合MediaQuery取宽,基本不会出大问题。
6.3 插件选型速查与规避策略
我把这个项目里用到的插件和结论整理了一下,以后做 OpenHarmony 跨端项目可以直接参考:
| 场景 | 插件类型 | OpenHarmony 适配情况 | 我的结论 |
|---|---|---|---|
| 本地键值存储 | shared_preferences | 有适配版,需换源 | 数据量小可用,读写后必须验证 |
| 应用目录路径 | path_provider | 有适配版,需换源 | 获取沙盒目录用,写入前注意目录存在性 |
| 日期处理 | intl / 纯 Dart | 纯 Dart 包,直接用 | 无风险,放心用 |
| 本地通知 | 通知类插件 | 适配不完善 | 暂不做系统级通知,先做应用内提醒 |
| 状态管理 | provider | 纯 Dart 包,直接用 | 无风险,放心用 |
规避策略说白了就一句话:凡是依赖系统原生能力的插件,在 OpenHarmony 上都要先验证有没有适配版本,没有适配就退而求其次用纯 Dart 方案或者应用内自实现。我在这个项目里放弃系统级本地通知,改成应用内提醒+红点方案,就是这句话的产物。虽然走了点弯路,但最终稳定性是可控的。
7. 一点实际操作后的体会
做完驱虫记录模块后,我最大的感受是:跨端开发的核心壁垒不在 Widget 怎么写,而在"业务规则怎么沉淀"。驱虫周期、到期日推算、状态判定这些都是纯 Dart 逻辑,与 OpenHarmony、Android、iOS 都没有关系。我在项目里把计算逻辑全部放到model层的普通方法里,三个端直接复用同一份代码,以后如果有一天要把这个 App 迁回 Android,业务层一行不用改。
最后再分享一个小技巧:我通常在evaluateStatus这类纯函数里顺手写几个断言测试,比如"明天到期应该显示 approaching"、"昨天到期应该显示 overdue",验证完就删掉或丢进测试目录。这套习惯帮我拦下了很多次日期边界问题,比在 UI 层反复点屏幕高效得多。驱虫记录只是猫咪管家的第一块拼图,后面接疫苗记录、体重趋势、多猫档案切换时,这个模块的代码结构还能继续复用,不用推倒重来。