Flutter for OpenHarmony实战:猫咪管家疫苗记录模块开发指南
做猫咪管家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的系统APIOpenHarmony的适配工作并不一定覆盖到位。实测下来部分插件的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, }); MapString, 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(MapString, 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的疫苗记录数据量不大第一版先跑通逻辑没问题。如果你在正式产品上做建议等待官方适配包的能力扩展到完整路径支持后再替换为系统应用目录。读写的核心逻辑我封装成了一个Repositoryclass VaccineRecordRepository { static const String _dirName cat_manager_data; static const String _fileName vaccine_records.json; FutureFile _getFile() async { final baseDir Directory.systemTemp; final dir Directory(${baseDir.path}/$_dirName); if (!dir.existsSync()) { dir.createSync(recursive: true); } return File(${dir.path}/$_fileName); } FutureListVaccineRecord loadAll() async { final file await _getFile(); if (!file.existsSync()) { return []; } final jsonStr await file.readAsString(); if (jsonStr.isEmpty) { return []; } final list jsonDecode(jsonStr) as Listdynamic; return list .map((e) VaccineRecord.fromJson(e as MapString, dynamic)) .toList(); } Futurevoid saveAll(ListVaccineRecord 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); Futurevoid 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上顺利跑通很大程度上就依赖这些面对细节时的克制和坚持。