做猫咪管家App的时候,最让我头疼的模块不是宠物相册,不是喂养记录,反而是那个看起来没什么技术含量的“疫苗记录”。当时我用的技术栈是Flutter for OpenHarmony,也就是说,我要在一套还没完全成熟的开源鸿蒙生态里,把一套跨平台框架跑起来,同时还要实现一个跟时间、提醒、数据存储强相关的业务功能。真做下来才发现,疫苗记录这个功能涉及的坑,远比想象中多:数据模型怎么设计才不容易返工、本地存储用哪套方案在OpenHarmony上不会翻车、下次接种日期怎么算才靠谱、通知提醒怎么在国产平台上落地,每个点都能单独写一篇文章。
这篇就围绕“Flutter for OpenHarmony 猫咪管家App实战 - 疫苗记录实现”这个主题,把我在实际开发中的选型思路、代码实现、踩坑过程,以及最后整理出来的排查清单,一次性讲清楚。
1. 项目背景与整体技术选型
1.1 为什么选择Flutter for OpenHarmony
先交代一下背景。猫咪管家这个App面向的是宠物主,核心场景是帮助铲屎官管理猫咪的日常健康数据,其中疫苗记录是刚需中的刚需:猫咪出生后要打猫三联、狂犬疫苗,之后还要每年加强,有些猫还要打驱虫针。每个主人都会面临“我到底什么时候带猫去打针”的困惑,这就需要一个能记录接种历史、计算下次接种时间、及时提醒主人的工具。
当时摆在我面前的技术路线其实不少:原生ArkTS开发、Web套壳、Flutter跨平台、React Native等。最终选了Flutter for OpenHarmony,核心原因有三个。
第一是代码复用率。猫咪管家App并不只跑在OpenHarmony设备上,还要兼顾日常使用的Android和iOS设备。如果全用原生写,等于要把同一套业务逻辑在三套平台上重复实现,维护成本直线上升。Flutter的跨端能力正好覆盖这个需求,一套Dart代码,多端运行,业务逻辑完全不需要重写。
第二是UI一致性的需求。猫咪管家这类C端应用对界面细节的追求是很高要求的,动画要顺滑、卡片要有质感、交互要跟手。Flutter自带的渲染引擎能确保同一套UI代码在不同平台表现一致,这一点对宠物主这类非技术用户特别重要。
第三是社区生态。OpenHarmony虽然发展快,但原生生态相比Android和iOS还是有不小差距的。通过Flutter for OpenHarmony这套适配方案,可以复用Flutter生态里大量成熟的包和工具链,减少自己从零造轮子的工作量。
当然,选择Flutter for OpenHarmony也有代价。最直接的一个问题是:很多Flutter插件依赖Android/iOS平台的原生实现,在OpenHarmony上没有现成的对应版本。这意味着选插件时要格外谨慎,还要做好自己封装平台通道的心理准备。这部分后面会展开讲。
1.2 疫苗记录功能的核心需求拆解
有了技术路线,接下来第一件事就是把“疫苗记录”这个模糊的概念拆成可执行的开发任务。我梳理了一下,这个功能至少要覆盖以下场景:
- 记录猫咪的基础信息,至少包括名字和出生日期,因为疫苗首免时间和猫咪年龄强相关。
- 记录每一次接种的疫苗类型。常见的有猫三联(通常包含猫瘟、猫鼻支、猫杯状病毒)、狂犬疫苗,还有一些驱虫类药物注射,不同类型的免疫方案完全不同。
- 记录接种日期、接种地点、疫苗批号、下次应接种日期,以及可选备注。
- 对即将到期或已过期的疫苗给出直观状态提示,比如“即将到期”“已过期”“正常”,让用户一眼就能判断当前是否该带猫去补针。
- 提供历史记录查看能力,用户能回溯猫咪完整的免疫历程。
这些需求拆解完之后,我心里对数据模型、页面结构、存储方案的基本形态就有数了。有一点我特别想提醒:开发这类记录型功能时,切忌一开始就把字段设计复杂。比如疫苗批号、生产厂家这类信息,很多用户根本填不出来。第一版先支持最核心的字段,把链路跑通,后续再根据实际使用反馈逐步增加扩展字段,这个节奏比一次性做全要稳得多。
1.3 技术选型里的取舍
在具体实现前,还有一个关键决策要做:状态管理和本地存储用什么。
状态管理我最终用了Provider。原因很朴素:项目复杂度不算高,疫苗记录涉及跨页面共享的数据量不大,用Provider这种轻量方案足够,而且它的调试体验比某些重型方案要友好得多。如果你的项目里还包含多宠物管理、权限体系、云同步,那可以考虑Riverpod等更灵活的方案,但猫咪管家App的第一版没必要。
存储方案这里要重点说。Flutter开发中,大家最习惯的做法是引入sqflite或者shared_preferences这类插件。但在OpenHarmony环境下,这些插件的可用性是有风险的,因为它们的底层实现依赖Android/iOS的系统API,OpenHarmony的适配工作并不一定覆盖到位。实测下来,部分插件的OpenHarmony版本要么没发布,要么行为不一致。
我最后的选型是:本地文件存储 + JSON序列化,也就是自己维护一个数据文件,把疫苗记录列表写入文件,启动时读出来反序列化。实现成本低、依赖少、完全可控。这里面的细节,我在下一章详细讲。
2. 疫苗记录的数据模型与存储方案
2.1 先把疫苗记录这个领域概念理清楚
写代码之前,先用业务语言把“疫苗记录”是什么说清楚。一条疫苗记录本质上是某个宠物在某一天、某个地点接种了某种疫苗的事实,再加上“下次应该什么时候再来”的这种业务推导结果。
猫咪的疫苗方案虽然因品牌和兽医方案而异,但大致规律如下:幼猫一般在8周龄左右开始接种猫三联疫苗,间隔3到4周再接种一次,构成初免基础;狂犬疫苗通常建议在满3月龄后接种;之后每年或每三年需要一次加强接种。不同品牌疫苗的效力周期不同,有的维护一年、有的维护三年,所以“下次接种日期”不可能靠简单的固定间隔算出来。
这也是为什么疫苗记录的数据模型里,我特意设计了一个字段叫nextDueDate,而不是在具体场景里用“接种日期加一年”这种硬编码逻辑去算。因为不同疫苗的保护周期可能完全不一样,哪天不同品牌给出了不同的推荐间隔,这个字段能直接应付过来。
2.2 数据模型代码长什么样
核心数据模型我用Dart定义,大致如下:
class VaccineRecord { final String id; final String petId; final String petName; final String vaccineName; final DateTime vaccinatedAt; final DateTime? nextDueAt; final String clinicName; final String batchNo; final String note; final bool remindEnabled; VaccineRecord({ required this.id, required this.petId, required this.petName, required this.vaccineName, required this.vaccinatedAt, this.nextDueAt, this.clinicName = '', this.batchNo = '', this.note = '', this.remindEnabled = true, }); Map<String, dynamic> toJson() { return { 'id': id, 'petId': petId, 'petName': petName, 'vaccineName': vaccineName, 'vaccinatedAt': vaccinatedAt.toIso8601String(), 'nextDueAt': nextDueAt?.toIso8601String(), 'clinicName': clinicName, 'batchNo': batchNo, 'note': note, 'remindEnabled': remindEnabled, }; } factory VaccineRecord.fromJson(Map<String, dynamic> json) { return VaccineRecord( id: json['id'] as String, petId: json['petId'] as String, petName: json['petName'] as String, vaccineName: json['vaccineName'] as String, vaccinatedAt: DateTime.parse(json['vaccinatedAt'] as String), nextDueAt: json['nextDueAt'] == null ? null : DateTime.parse(json['nextDueAt'] as String), clinicName: json['clinicName'] as String? ?? '', batchNo: json['batchNo'] as String? ?? '', note: json['note'] as String? ?? '', remindEnabled: json['remindEnabled'] as bool? ?? true, ); } }有几个字段我解释一下。petId和petName分开,是为了后续支持多宠物管理时不至于把关联关系写死。vaccinatedAt是接种日期,nextDueAt是可空的下次应接日期,因为有的针次(比如某些检查或非免疫类注射)是无需加强的。remindEnabled让用户可以自主关闭某个疫苗的提醒,比如已经不想再打的疫苗,没必要天天被提醒。
写Json序列化时有个细节容易翻车:DateTime在Dart里的默认序列化结果是UTC时间格式,如果你直接调用record.vaccinatedAt.toString(),很可能会把本地时间转成带Z尾巴的UTC时间,等反序列化回来时间就凭空差了8小时。我在第一版就踩过这个坑,后面统一用toIso8601String()和DateTime.parse()来配对处理,问题就解决了。
2.3 本地存储怎么选:JSON文件起家的务实做法
前面提到,我在OpenHarmony上绕开了shared_preferences和sqflite,直接用文件存储。具体做法很简单:应用启动初始化时,确保一个业务数据目录存在,然后往里面写一个vaccine_records.json文件。
这里涉及到一个实际困难:在OpenHarmony的Flutter适配环境里,path_provider插件不一定可用,所以你没办法通过标准API拿到应用的私有目录。我的处理方式是用dart:io里的Directory.systemTemp作为基础目录,再拼接自己的业务目录名。虽然把数据放在临时目录里不算是长久之计,但好在猫咪管家App的疫苗记录数据量不大,第一版先跑通逻辑没问题。如果你在正式产品上做,建议等待官方适配包的能力扩展到完整路径支持后,再替换为系统应用目录。
读写的核心逻辑我封装成了一个Repository:
class VaccineRecordRepository { static const String _dirName = 'cat_manager_data'; static const String _fileName = 'vaccine_records.json'; Future<File> _getFile() async { final baseDir = Directory.systemTemp; final dir = Directory('${baseDir.path}/$_dirName'); if (!dir.existsSync()) { dir.createSync(recursive: true); } return File('${dir.path}/$_fileName'); } Future<List<VaccineRecord>> loadAll() async { final file = await _getFile(); if (!file.existsSync()) { return []; } final jsonStr = await file.readAsString(); if (jsonStr.isEmpty) { return []; } final list = jsonDecode(jsonStr) as List<dynamic>; return list .map((e) => VaccineRecord.fromJson(e as Map<String, dynamic>)) .toList(); } Future<void> saveAll(List<VaccineRecord> records) async { final file = await _getFile(); final jsonStr = jsonEncode( records.map((e) => e.toJson()).toList(), ); await file.writeAsString(jsonStr); } }为什么没有用数据库?因为疫苗记录这个数据形态非常固定,查询维度就一个列表,没有复杂的关联查询和事务需求,JSON文件完全够用,而且省掉了数据库初始化、迁移、适配等一大堆问题。如果后续要做云同步、多端消息合并,再升级换成数据库也不迟。我认为中小型记录类功能第一版用文件存储是最省心的选择,因为你可以把精力聚焦在业务逻辑本身,而不是底层存储的折腾上。
2.4 日期计算与下次接种日期的判断
数据有了,接下来要回答一个业务问题:怎么判断某条记录是不是即将到期。
我在代码里定义了四个状态:
enum VaccinateStatus { normal, dueSoon, overdue, unknown, }normal代表未到期且时间充裕,dueSoon代表距离到期日不足30天,overdue代表已超过到期日,unknown代表没有设置下一次接种日期。判断逻辑如下:
const dueSoonThreshold = Duration(days: 30); VaccinateStatus getStatus(VaccineRecord record) { if (record.nextDueAt == null) { return VaccinateStatus.unknown; } final dueDate = record.nextDueAt!; final today = DateTime.now(); if (dueDate.isBefore(today)) { return VaccinateStatus.overdue; } if (dueDate.difference(today) <= dueSoonThreshold) { return VaccinateStatus.dueSoon; } return VaccinateStatus.normal; }这里有一个很直观的教训:跨天的状态变化只有用户打开App时才会被重新计算,所以我看过挺多App在后台通知里提醒,但App内没有做状态刷新,最后用户打开应用发现“明明通知说今天该打针了,页面上还显示正常”。解决方式很简单,每次进入疫苗列表页时都重新执行一次状态计算,不要在内存里做状态缓存。
日期处理我还加了一个细节:如果用户编辑了下次接种日期,我会以最新编辑的为准,重新计算到期状态,这样就不会出现“上次打了针,但系统还按旧日期提醒”的尴尬。业务上这就是一次性判断,不依赖历史推导。
3. 疫苗记录功能的界面实现
3.1 疫苗列表页:让状态一目了然
列表页是整个模块的门面,我的设计理念是信息分层。每张卡片分三个区域:疫苗名和时间主信息区、状态标识区、日期和备注辅助信息区。
主信息区显示疫苗名称和接种次数徽标。比如同样是猫三联,可能打了第一针和第二针,我会在卡片标题旁边加一个小的徽标组件,显示是第几针。这样用户快速扫一眼就能知道猫咪目前的免疫进度。
状态标识区用了圆点加文字的组合,绿色圆点代表正常,橙色代表即将到期,红色代表已过期,灰色代表无后续接种计划。这个视觉语言要和全局保持一致,避免用户在列表页看到一种颜色,进到详情页又变成另一种含义。
日期辅助信息区则展示两个日期:接种日期和下次应接种日期。如果已过期,还会显示“已过期XX天”的文案,这种具体的天数越直观越有用,比单纯给个红色的“已过期”要有效得多。
还有一个实用性很强的交互:下拉刷新。虽然本地存储不需要网络,但刷新动作会重新读取文件并重算状态,我把它当成一个“强制刷新状态”的手势,让用户养成“不确定就拉一下”的习惯。
3.2 添加疫苗记录的流程设计
添加记录看起来简单,实则是个容易让用户厌烦的高频率操作。用户带猫打完针,最怕的就是填一堆表单。所以我在添加入口这里做了两个优化。
第一,表单默认值自动带出。如果用户是从某只宠物的详情页进入添加页,宠物名、宠物ID就直接带出来,不需要重复选择。接种日期默认是今天,因为绝大多数用户就是打完针当场记录的。如果不小心要补录之前的记录,日期控件一点就可以改。
第二,关键字段分组呈现。基础信息(疫苗类型、接种日期)放在第一屏,扩展信息(疫苗批号、医院、备注、下次接种日期提醒开关)放在展开区。默认情况下用户只用填疫苗类型和确认日期,所有其他字段都有合理默认值或可空,所以录一条记录最快的操作只需要2次点击加一次确认。
疫苗类型这里我提供了一个内置的常用列表:
const vaccineTypes = [ '猫三联(首针)', '猫三联(第二针)', '猫三联(第三针)', '狂犬疫苗', '其他免疫类疫苗', ];同时保留自定义输入,因为不同兽医对不同猫咪的接种方案不完全一致,硬标准选项反而会造成用户困惑。
下次接种日期的计算在表单里也做了半自动处理:当用户选择“狂犬疫苗”时,默认把下次接种日期设置为一年后;选择猫三联时默认设置为接种后21天。之所以加这个约定,是因为幼猫初免的针次间隔通常是21到28天,我选中了中间值21天作为默认,用户可以根据兽医建议自行调整。这个半自动逻辑节省了大部分用户手动选日期的时间。
3.3 记录详情与删除恢复
卡片点击后进入详情页。详情页的职责不是重复展示列表信息,而是补齐长文本和上下文。
在详情页我会展示完整的接种历史流,把与当前记录相关的上下文串起来让用户看到,比如这条记录属于“幼猫初免系列”还是“年度加强”。这个信息是用同一宠物的记录列表推导出来的:如果该宠物在30天内有多条记录,且这些都是同一类基础免疫,就把它们归为同一组。
删除操作我特意加了二次确认,而且删除后保留一个“撤销”入口。为什么做撤销?因为用户删除操作很容易发生在误触的场景。撤销实现很直接:删除时先暂存一份数据,显示一个SnackBar,带“撤销”按钮,点击后重新插入原数据。
顺带说一句,开发记录型功能时,一定不要只做删除而忽略恢复。因为数据一旦丢失,对用户而言就是不可逆的信息损失。哪怕最终只做到“删除后弹出一个撤销提示”,也比没有强得多。
4. OpenHarmony平台适配与问题排查
4.1 插件依赖在OpenHarmony上的现实
最开始提到过,Flutter for OpenHarmony最大的变量,是插件生态的不完整。我这里把实际遇到的情况展开说细点。
path_provider在OpenHarmony上没有保证可用的版本。我试过直接在依赖里指定最新版本,编译能过,但运行时拿到的是空路径或者路径中的目录根本不存在。这时如果再加一层自己的业务目录拼接,数据读写就会静默失败。排查过程是痛苦的,因为没有任何异常抛出。
shared_preferences也有类似的兼容问题:某些适配版本能写入,但读取时拿不到之前写入的内容。这个现象比路径问题还坑,因为它不是崩溃,也不是报错,就是数据“丢了”。后来我查了源码实现才发现,它可能把数据写到非持久化区域,应用重启后系统做了清理,自然一切归零。
以我个人的经验,在OpenHarmony上做Flutter开发,最好一开始就收敛插件依赖:凡是涉及文件读写、路径获取、系统通知的,先确认有没有OpenHarmony的原生适配实现;没有的,优先考虑用dart:io或平台通道自给自足。能少依赖一个插件,就少一个未来版本的兼容风险点。
4.2 通知提醒怎么落地
疫苗记录这种功能,提醒能力几乎是灵魂。但Flutter里的flutter_local_notifications插件,在OpenHarmony上同样没有现成的完整实现。我当时想的不是硬等插件作者适配,而是直接走平台通道,自己封装一条最短路径。
具体思路是:在Flutter侧,如果检测到当前设备是OpenHarmony平台,就通过MethodChannel调用原生侧的一个通知方法;在OpenHarmony原生侧,通过系统通知能力创建一条本地通知。由于OpenHarmony的通知需要应用有通知权限,并且部分版本需要用户在系统设置里手动打开通知开关,所以我在App里加了一个权限引导页。
这个平台通道的实现很简洁:
static const _channel = MethodChannel('cat_manager/notification'); Future<void> scheduleVaccineNotification({ required String title, required String body, required DateTime notifyTime, }) async { try { await _channel.invokeMethod('scheduleNotification', { 'title': title, 'body': body, 'timestamp': notifyTime.millisecondsSinceEpoch, }); } catch (e) { debugPrint('notification schedule failed: $e'); } }这段代码还有一个隐藏逻辑:当前设备如果不支持这一通道,捕获异常后只输出日志,不影响主流程。也就是说,即使提醒能力在某些设备上不可用,疫苗记录的查看和管理功能也依然是完整的。这种“能力降级设计”在跨端适配中非常实用。
不过我还是要提前打个预防针:本地通知的定时触发行为,在不同系统版本上差异较大,尤其应用被用户强杀后,有些系统的本地通知调度会被系统清除。第一版我选择了“进入App时检查到期记录并立即提醒”,而不是依赖后台定时调度。这个方案牺牲了一点实时性,但换来的是稳定性和低复杂度。
4.3 我整理的高频问题速查表
开发过程中反复踩到的问题,我整理成了一个速查表,写在这里,后续如果你们也在Flutter for OpenHarmony上做类似功能,可以直接对照排查。
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
path_provider返回空路径 | 插件未针对OpenHarmony适配 | 改用dart:io的Directory.systemTemp,或自建业务目录 |
| 写入的文件重启后消失 | 数据写在临时目录被系统清理 | 等待官方路径API适配,或用系统私有目录并定期检查存在性 |
| 页面显示“已过期”但通知没发 | 通知权限未开启或调度不被允许 | 增加权限引导页面,并在健康检查时直接弹通知 |
| 时间差8小时 | DateTime.toIso8601String()的UTC转换问题 | 统一使用toIso8601String序列化搭配DateTime.parse反序列化 |
| 插件编译通过但运行时无响应 | 平台通道异常被静默捕获 | 增加统一异常捕获,将错误上报到调试日志多做本地验证 |
| 列表刷新后状态不更新 | 状态计算结果被缓存 | 每次进入列表页强制重新计算到期状态 |
这六个问题是这段时间开发里出现频率最高的。前三个尤其有代表性,它们都是OpenHarmony适配不完整导致的原生能力缺口;而后三个属于通用业务逻辑,在任何Flutter项目里都可能遇到。
5. 经验复盘与后续扩展
5.1 开发节奏与调试的一些心得
复盘这次的开发过程,最值得说的其实不是某个具体的API用对了,而是整个开发的节奏和控制点。
疫苗记录这种功能,领域本身不复杂,但它横跨“数据建模、UI交互、本地存储、平台能力、时间计算”五个层面,任何一个层面出问题,都会让整体体验卡壳。所以我在开发时坚持了一条原则:先跑通纵向链路,再迭代横向细节。
什么叫纵向链路?就是从数据存储到列表展示到添加流程到状态判断到通知触发,先用最简单的方式把一条记录从头走到尾。第一版我甚至连UI都是最朴素的列表,没有状态色、没有徽标、没有撤销删除,但核心数据流是通的。确认链路没问题后,再逐个补齐细节。
这样做的好处是可以尽早验证关键技术风险。如果一开始就花大力气做漂亮UI,最后发现存储方案在OpenHarmony上跑不通、或者日期计算逻辑有问题,返工成本会非常高。
调试层面有一点对OpenHarmony设备特别重要:日志过滤。OpenHarmony的日志输出量非常大,Flutter侧的print会和系统大量日志混在一起。我用了一个简单办法,自定义一个带统一前缀的日志函数,比如[CAT_MANAGER],然后在终端里通过关键字过滤。这个习惯帮我节省了大量排查时间。
5.2 后续可以扩展的方向
猫咪管家App的疫苗记录模块虽然已经跑通,但离一个完整产品还有明显距离。我梳理了几个自然扩展方向。
第一个是疫苗知识库。现在的记录是纯数据录入,用户需要先自己知道猫该打什么疫苗、什么时候打。实际上很多宠物主并不清楚这些知识,如果内置一个疫苗知识库,根据宠物的年龄和已有记录自动提示“该打下一针了”,体验会提升不少。
第二个是多宠物支持。现在的数据结构里已经预留了petId,但没有做宠物维度的分组管理。后续可以加Swipe横向切换宠物,或者在列表页顶部做一个头像选择栏。这个改动在数据结构上几乎零成本,纯界面和交互侧的工作。
第三个是云端备份与多设备同步。疫苗记录一旦丢失,用户可能会损失几年内的重要健康信息。做云同步不一定是为了多端互通,更多是给数据上一个保险。方案上可以对接到各厂商的云能力,也可以先做一个导出JSON文件的功能,至少让用户能主动备份。
第四个是提醒的智能升级。现在只是简单的到期提醒,后续可以按宠物品种、年龄、疫苗品牌做更个性化的建议。比如对于幼猫,第一针和第二针间隔建议21天,超期未接种就给出更明显的提示。不过这部分涉及医疗建议,建议只做信息展示和提醒,不要给出强结论。
5.3 最后想说的一个细节
回到疫苗记录本身,这个功能给我最大的启发是:越简单的功能,越要自己亲手把数据模型和时间逻辑理干净。表面上看疫苗记录就是一个列表加一张表单,但这里面的字段设计、状态流转、时间边界、存储策略,每一步都在影响最终用户能不能长期信任这个App。
我个人的体会是,在Flutter for OpenHarmony上做这类业务功能,不要害怕技术债,真正怕的是在早期阶段把债压在了错误的地方。技术方案选错了还能后续替换,但如果数据模型设计里没有预留宠物ID、没有把下次接种日期设计成可空、没有考虑时间序列化的时区问题,后续扩展和修bug的成本是指数级上升的。
最后再分享一个小技巧:所有跟时间相关的字段,在Dart代码里坚持用DateTime类型,不要为了省事转成字符串存。字符串时间在展示时方便一点,但一旦涉及比较、计算、排序,你会发现还得全部再转一遍,纯属给自己找麻烦。直接传递DateTime对象到UI层,格式化只在最后一层展示时做,这个纪律能帮你避开一大堆隐蔽的时间Bug。这次的疫苗记录模块能在OpenHarmony上顺利跑通,很大程度上就依赖这些面对细节时的克制和坚持。