资讯动态

Flutter+OpenHarmony实战:宠物App驱虫记录模块设计与实现

发布时间:2026/10/18 12:25:17 来源:尧图企业网站定制
做宠物类 App 的人应该都有这种体会功能列表里密密麻麻的需求一堆但真正使用频率最高、最容易被用户夸“好用”的反而是那些看着不起眼的小模块。最近我在折腾“猫咪管家”App用 Flutter 跑 OpenHarmony 平台第一个重点打磨的功能就是驱虫记录。驱虫这件事养猫的人隔三差五就得操心体外驱虫一个月一次、体内驱虫三个月一次时间一久谁都会忘。把这块落实到位用户才会觉得你这款 App 靠谱。这篇就把驱虫记录的完整实现过程拆开来讲数据怎么存、周期怎么算、界面怎么做、在 OpenHarmony 上踩了哪些坑。内容不算高深但每一步都是我实际跑通过的方案适合正在用 Flutter 做 OpenHarmony 应用、或者做宠物工具类 App 的朋友参考。1. 项目背景与功能定位1.1 为什么用 Flutter 做 OpenHarmony 猫咪管家OpenHarmony 生态的 App 开发现在有几条路可以走ArkTS 的 ArkUI、C 的 NAQI、再就是跨平台方案。选 Flutter 的原因很直接团队里已经有一套 Flutter 的代码资产猫咪管家后续肯定还要出 Android 版用 Flutter 一次编写、两边跑比维护两套 UI 成本低太多。OpenHarmony 这边对 Flutter 的适配一直在跟进到了现在这个阶段基础渲染、Dart 运行时、异步 IO 都已经能稳定跑起来日常的页面布局、状态管理、网络请求基本可以无缝迁移。但要注意Flutter 对 OpenHarmony 的支持并不是说你拿官方标准 Flutter SDK 就能直接编出 HAP 安装包。你需要换上社区维护的、专门针对 OpenHarmony 做了适配的 Flutter SDK再配合 OpenHarmony SDK 一起配置环境。配置好之后构建产物就是.hap包可以直接装到 OpenHarmony 设备或模拟器上跑。这个过程说起来轻巧真正搭环境的时候会碰到不少细节后面第 5 节我会把踩过的坑单独拿出来讲。1.2 驱虫记录模块到底要解决什么问题先明确需求边界。猫咪管家里的驱虫记录用户核心诉求其实只有三条记录每次驱虫用了什么药、在什么时间做的系统自动算出来下一次该什么时候驱虫到时间了能给个提醒别让用户自己拿小本子记日子功能看起来简单但有个隐藏难点驱虫分体内和体外。体外驱虫一般一月一次体内驱虫一般三月一次还有一部分药是内外同驱周期也不太一样。所以数据模型里不能写死“一个月”或“三个月”这样的固定字段得让用户在添加记录的时候自己选周期再由系统按周期自动推算。同时模块还要有“逾期”和“临期”的状态判断让用户打开列表一眼就能看出哪只猫该驱虫了。2. 数据模型与存储设计2.1 字段设计哪些信息必须记下来驱虫记录的核心字段我最后定下来这么几个字段类型说明pet_idINTEGER记录挂在哪只猫名下typeTEXT体内 / 体外medicineTEXT用的什么药dateINTEGER驱虫日期存毫秒时间戳cycle_monthsINTEGER周期单位是月noteTEXT备注可空有一个设计决定值得说道说道为什么一定要存“周期”而不是直接存“下次驱虫时间”因为用户改历史记录时或者想给下次驱虫换一种药周期随时会变。任何时候用“驱虫日期 周期”都能重新算出“下次时间”数据库里就不需要冗余字段也不会出现“改了日期、忘了改下次时间”的数据不一致问题。这是数据建模里一个比较典型的取舍能推导出来的数据尽量不要落库。存储方案我选的是 SQLite通过社区适配 OpenHarmony 的 sqflite 插件来访问。为什么不选 Hive、Isar 这类 NoSQL驱虫记录有明确的筛选、排序、聚合需求比如按宠物查、按时间倒序、统计有没有逾期SQL 写起来非常自然而且 SQLite 在 OpenHarmony 上的适配情况比 Isar 成熟得多踩坑成本低。2.2 建表与数据访问层封装建表 SQL 里有一个细节日期字段我存的是 INTEGER 毫秒时间戳不是 TEXT 字符串。虽然 T_EXT 字段直接写成2025-06-12也能看但时间戳在排序、范围查询、时区处理上都要稳得多界面展示的时候再格式化就行。import package:sqflite/sqflite.dart; class AppDatabase { static const _dbName cat_butler.db; static const _dbVersion 1; static Database? _db; static FutureDatabase get instance async { _db ?? await _open(); return _db!; } static FutureDatabase _open() async { final dir await getDatabasesPath(); final path $dir/$_dbName; return openDatabase( path, version: _dbVersion, onCreate: (db, version) async { await db.execute( CREATE TABLE deworm_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, pet_id INTEGER NOT NULL, type TEXT NOT NULL, medicine TEXT NOT NULL, date INTEGER NOT NULL, cycle_months INTEGER NOT NULL, note TEXT ) ); }, ); } }实体类我直接放了一个toMap和fromMap的映射。重点在于类型转换逻辑只出现在这一处界面层和业务层拿到手的都是DateTime对象不用到处做字符串和日期的来回倒腾后面改字段也不会牵一发动全身。class DewormRecord { final int? id; final int petId; final String type; final String medicine; final int cycleMonths; final DateTime date; final String? note; DewormRecord({ this.id, required this.petId, required this.type, required this.medicine, required this.cycleMonths, required this.date, this.note, }); MapString, dynamic toMap() { id: id, pet_id: petId, type: type, medicine: medicine, date: date.millisecondsSinceEpoch, cycle_months: cycleMonths, note: note, }; factory DewormRecord.fromMap(MapString, dynamic map) DewormRecord( id: map[id] as int?, petId: map[pet_id] as int, type: map[type] as String, medicine: map[medicine] as String, date: DateTime.fromMillisecondsSinceEpoch(map[date] as int), cycleMonths: map[cycle_months] as int, note: map[note] as String?, ); }仓库层我把增删改查封装成一个 Repository。写这个类的习惯是所有方法都返回Future上层只管await底层实现以后哪怕要从 SQLite 换成别的存储调用方也完全不用动。class DewormRepository { FutureListDewormRecord getRecords(int petId) async { final db await AppDatabase.instance; final rows await db.query( deworm_records, where: pet_id ?, whereArgs: [petId], orderBy: date DESC, ); return rows.map(DewormRecord.fromMap).toList(); } Futureint insertRecord(DewormRecord record) async { final db await AppDatabase.instance; return db.insert(deworm_records, record.toMap()); } Futureint updateRecord(DewormRecord record) async { final db await AppDatabase.instance; return db.update( deworm_records, record.toMap(), where: id ?, whereArgs: [record.id], ); } Futureint deleteRecord(int id) async { final db await AppDatabase.instance; return db.delete( deworm_records, where: id ?, whereArgs: [id], ); } }3. 核心业务逻辑驱虫周期计算3.1 周期规则是怎么定的驱虫频率是兽医领域的常识不是我们拍脑袋编的体外驱虫一月一次体内驱虫三月一次幼猫第一年可能还要更密一些。产品上不需要替用户做医疗判断提供几个常用档位就好。我这里用的是 1 个月、3 个月两档再加一个自定义天数的扩展位默认选中 1 个月。有一个关键点必须理解周期是“距上次驱虫日期的间隔”不是“按自然月的固定日期”。比如 1 月 31 日做了体内驱虫三个月后理论上应该是 4 月 31 日但 4 月没有 31 日这时候要回退到 4 月 30 日。如果直接让DateTime做加法Dart 会把日期自动后滚成 5 月 1 日这跟用户的认知预期差了整整一天。这个边界问题必须单独处理。3.2 日期计算Dart 的日期溢出陷阱Dart 的DateTime(year, month 3, day)会自动处理月份大于 12 的情况比如 11 月加 3 会正确跳到次年 2 月。但日溢出的处理是“进位”不是“截断”所以必须自己先算出目标月最后一天把溢出的日数截掉DateTime calculateNextDate(DateTime base, int months) { final targetMonth base.month months; final targetYear base.year (targetMonth - 1) ~/ 12; final monthInYear (targetMonth - 1) % 12 1; final lastDay DateTime(targetYear, monthInYear 1, 0).day; final safeDay base.day lastDay ? lastDay : base.day; return DateTime(targetYear, monthInYear, safeDay); }这里DateTime(targetYear, monthInYear 1, 0)是 Dart 里一个挺巧妙的写法月份传 0就会取上个月的最后一天。1 月 31 日加 3 个月走这个函数返回的是 4 月 30 日加 1 个月2 月年份如果不是闰年就返回 2 月 28 日。这个函数我单独抽出来放在core/utils/date_utils.dart里单元测试直接对着边界日期砸后面怎么改都不慌。提示凡是涉及宠物健康日期一定要把 1 月 31 日、闰年 2 月 29 日这类边界场景写进测试。别问我为什么强调问就是被坑过。3.3 状态判定临期、到期、逾期列表页要展示状态判断逻辑要按天来算不要按小时。先取“今天零点”和“下次驱虫零点”再算差值enum RecordStatus { overdue, dueSoon, normal } RecordStatus getRecordStatus(DewormRecord record) { final now DateTime.now(); final today DateTime(now.year, now.month, now.day); final next DateTime( record.nextDate.year, record.nextDate.month, record.nextDate.day, ); final diffDays next.difference(today).inDays; if (diffDays 0) return RecordStatus.overdue; if (diffDays 7) return RecordStatus.dueSoon; return RecordStatus.normal; }阈值我用的是 7 天7 天以内算“临期”过了就算“逾期”。逾期状态我还会在界面上补一个“逾期 X 天”的数字让用户有紧迫感。为什么状态判断单独拎出来而不是让 UI 自己算因为列表、卡片、统计页都要用这份逻辑写一处才能保证三处颜色一致、状态一致。4. 界面实现与状态管理4.1 记录列表与状态卡片的设计状态管理我用的 Provider ChangeNotifier。这个模块的状态粒度很小就一个记录列表加一个表单页ChangeNotifier 足够没必要上 Bloc 或者 Riverpod 那种重家伙。团队里新来的同学看这套代码也不需要任何额外学习成本。class DewormStore extends ChangeNotifier { final _repository DewormRepository(); final ListDewormRecord _records []; int _currentPetId 0; ListDewormRecord get records List.unmodifiable(_records); Futurevoid loadRecords(int petId) async { _currentPetId petId; _records ..clear() ..addAll(await _repository.getRecords(petId)); notifyListeners(); } Futurevoid addRecord(DewormRecord record) async { await _repository.insertRecord(record); await loadRecords(_currentPetId); } Futurevoid removeRecord(int id) async { await _repository.deleteRecord(id); await loadRecords(_currentPetId); } }列表用ListView.builder每张卡片展示药品名、类型标签、实际驱虫日期、下次日期和状态色块。逾期用红色、临期用橙色、正常用绿色状态色块放在卡片左侧视觉权重最高。用户在列表页扫一眼就能快速知道哪只猫的健康任务卡住了。卡片上我还加了一个“补记”入口。这个是真的观察用户习惯后加的功能很多养猫人不是按时记录而是忘了驱虫之后回来补一条。点了这个入口会把当前选中的记录复制成一条新记录只让用户改日期和药名省掉从头填表单的麻烦。MVP 阶段这种小交互比多塞几个功能更拉好感。4.2 新增/编辑表单与实时预览表单页包含这几个控件类型切换、药品名输入、周期下拉、日期选择器、备注输入。日期选择直接调showDatePicker周期用DropdownButtonFormField类型切换我用的 Flutter 的SegmentedButton三段式切换体内、体外、内外同驱。这个表单页有个体验细节我认为很关键选完日期和周期之后页面底部实时显示“下次驱虫建议某月某日”。提交之前用户就能看到计算结果立刻明白“我选 3 个月后是哪天”避免提交之后才发现日期算错了。实现上很简单calculateNextDate的结果包在ValueListenableBuilder里字段一变就重算。Text( 下次驱虫建议${formatDate(_nextDate)}, style: Theme.of(context).textTheme.titleMedium, )表单提交前的校验有三条药品名不能为空日期不能晚于今天周期必须选。校验通过后把DewormRecord交给 Store 的addRecord入库并刷新列表。这里我建议把校验逻辑放在独立的FormValidator里不要堆在 build 方法中不然页面一复杂就成了一团乱麻。关于日期格式化我多说一句。虽然 intl 包可以格式化但我在这个模块里写了一个简单的手动格式化函数只做yyyy-MM-dd格式四五行代码依赖越少越稳。这在普通 Android 上无所谓在 OpenHarmony 插件生态下少一个依赖就少一个潜在的坑。5. OpenHarmony 平台适配的坑与排查实录5.1 插件兼容性先看有没有 ohos 实现在 OpenHarmony 上跑 Flutter最大的坑不在 Dart 层而在插件层。sqflite 官方版默认没有 OpenHarmony 的实现必须用社区适配版本或者带 ohos 目录的 fork。判断一个插件能不能用速度最快的方法就是去 pub-cache 里翻一翻插件包看有没有ohos目录或者仓库里有没有 openharmony 分支。我整理了一个排查清单照着过一遍就能过滤掉八成不兼容的插件检查项说明是否有 ohos 目录或 openharmony 分支没有基本可以直接放弃是否依赖 dart:io 里的平台实现有特定路径依赖的插件要警惕是否用了 Android 独有的 API比如 Activity 相关的肯定不行社区维护频率长期没人更新的库慎选5.2 数据库路径与初始化时序sqflite 的getDatabasesPath()在 OpenHarmony 上返回的路径有时候并不在应用私有目录里或者说目录根本不存在。最稳的做法是换成 path_provider 取应用文档目录拼上数据库文件名再在打开前Directory.create确保父目录存在final dir await getApplicationDocumentsDirectory(); final path $dir/cat_butler.db;初始化时序也要注意WidgetsFlutterBinding.ensureInitialized()必须在访问数据库之前调用否则插件通道没有准备好数据库打开会直接抛异常。我之前是在main()里先开库再跑runApp结果数据库路径拿到了但通道没初始化报错半天查不出来。后来把顺序调成ensureInitialized()先行问题立刻解决。5.3 热重载不是万能的Flutter 的 hot reload 在 OpenHarmony 上基本可用Dart 层改动都能即时生效。但改了插件注册、原生侧逻辑之后热重载是不生效的必须全量重新编译生成新的 HAP。我在这上面吃过一个亏改了 sqflite 的初始化代码热重载之后路径还是旧的数据库怎么都打不开反复查了半小时才发现是没重编。所以我的经验是凡是动了 pubspec 依赖、原生侧代码、数据库 schema直接全量重编别省那点编译时间。5.4 数据库升级与数据迁移最后补一个很多人会忽略的点CREATE TABLE的版本号一定要预留好。App 才上线的时候_dbVersion 1后面如果加字段比如给驱虫记录加一个“用药剂量”需要在onUpgrade回调里写ALTER TABLE而不是让用户卸载重装。数据库迁移这种操作在开发阶段多写几行不亏等真上了生产再补就是事故了。onUpgrade: (db, oldVersion, newVersion) async { if (oldVersion 2) { await db.execute(ALTER TABLE deworm_records ADD COLUMN dosage TEXT); } }我个人实际操作中的体会是跨平台开发里这种“平台差异”其实才是最耗时间的部分。像日期溢出这种边界逻辑在 Android 上早被无数人踩平了到了 OpenHarmony 上因为底层行为一致反而能直接复用但插件兼容、路径差异这些就得靠实打实跑设备才能发现。所以千万别只依赖模拟器验证能上真机就上真机。驱虫记录这个模块做完之后下一步我准备接本地通知提醒到“临期日”给用户推一条提醒。OpenHarmony 的本地通知插件和 Android 的 notificaiton 插件实现有差异这条路还得再趟一遍。不过核心的数据模型、周期计算、状态判定这些逻辑已经稳定了往上层加功能只是时间问题。做宠物工具类 App先把这种“看着小、但用户天天用”的模块做扎实比追求大而全的功能列表重要得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑