
在移动开发这个圈子里Flutter 的名字这几年几乎成了跨平台方案的代名词。你要是还没听说过它那确实是有点说不过去了。我自己的感受是Flutter 最吸引人的地方不在于“一套代码跑两端”这种口号而在于它在 UI 渲染层面那种接近原生的流畅度和一致性。特别是当华为鸿蒙系统在国产设备上的占有率越来越高之后如何让 Flutter 应用也能顺滑地跑在鸿蒙上就成了很多团队绕不开的话题。正好我用 Flutter 做了一个宠物驱虫记录器在这过程中踩了不少坑也积累了一些鸿蒙适配的第一手经验。今天这篇文章就把它当做一个完整的开发教程拆开讲从项目设计、核心功能实现到最终的鸿蒙平台适配一条线串起来希望能给正在做类似尝试的你一点参考。这个小应用本身不算复杂但胜在“小而全”。它聚焦的是一个非常具体的日常场景家里养了猫狗需要定期做体内外驱虫但谁都不会记得上一次是几月几号做的、用的是哪个牌子的药、下一次又该什么时候做。记在脑子里容易忘随手记在备忘录上又不好整理。宠物驱虫记录器的核心价值就是把这些零散的信息管理起来按时提醒你避免因为漏驱虫而让猫狗遭罪。这种定位清晰、功能边界明确的工具类应用特别适合用来学习 Flutter 的跨平台能力也特别适合验证鸿蒙适配的技术链路。这篇文章会比较长我会把整个开发流程拆成几大块包括项目的功能设计与数据模型、Flutter 端核心页面与状态管理的实现、鸿蒙端的适配思路与原生通信细节以及我在实际工作中遇到并解决的常见问题。不管你是刚开始学 Flutter 的新手还是已经在考虑把现有 Flutter 项目移植到鸿蒙上的开发者应该都能在里面找到可以直接拿去用的东西。1. 项目整体设计与技术选型在做任何一个项目之前最先要想清楚的其实不是怎么写代码而是这个项目到底要解决什么问题、页面怎么拆分、数据怎么流转。宠物驱虫记录器的需求非常明确我在第一版就把它拆成了几个核心模块宠物档案管理、驱虫记录管理、下次驱虫时间提醒以及一个简单的数据统计入口。每个模块的职责都很清晰相互之间靠数据模型连接整体架构不会因为版本迭代而变得臃肿。技术选型方面我最终确定的是 Flutter 3.16 以上版本搭配 GetX 做状态管理本地存储使用 sqflite 来完成结构化数据的持久化。之所以选 GetX 而不是 Provider 或者 Bloc纯粹是因为这个项目体量不大GetX 的轻量、少样板代码和内置依赖注入特性对小型应用非常友好。而且它的响应式状态管理机制在处理“新增一条记录后列表自动刷新”这类交互时写起来非常干净。sqflite 则是我在 Flutter 生态里用得最顺手的 SQLite 方案配合数据库可视化工具 db4sDB Browser for SQLite来排查数据问题效率会高出很多。对于驱虫提醒功能我用了 flutter_local_notifications 插件。这个插件在 Android 上的表现很稳定但到了鸿蒙环境中就需要做一些额外的适配工作了这一块后面我会单独讲。这里先说一下设计上的考量提醒时间并不是简单的“每三个月一次”这种固定间隔因为不同驱虫药的有效期不一样体外驱虫通常是一个月一次体内驱虫有的药是两个星期有的则是三个月。所以我在数据模型里特意设计了一个 nextDueDate 字段由用户录入驱虫记录时自主选择下次提醒日期应用只负责在到期前三天和到期当天各发一次通知不替用户做自动计算。这样既保证了用药的准确性又简化了业务逻辑。项目目录结构我也是有意识地按功能模块划分的而不是按页面划分。每个功能模块里面包含自己的页面、控制器、数据模型和数据服务这样后续新增功能时改动范围可以控制在一个模块内不会牵扯到其他地方。你如果自己从零开始写类似应用强烈建议一开始就花点时间把目录结构理清楚后面维护的成本会差很多。2. 核心功能拆解与关键实现2.1 宠物档案模块从数据模型到界面展示宠物档案是整个应用的数据基础没有档案就没有后续的驱虫记录。在这个模块里我建立了一个 Pet 数据表字段包括宠物名称、种类猫、狗、兔等、性别、生日、体重、照片路径和备注。其中体重这个字段我特意保留下来因为驱虫药的剂量通常是按体重计算的记下来可以在录入驱虫记录时作为参考避免买错药量。界面部分我用了 Card 加 ListView 的组合每一张卡片展示宠物的头像、名字和下一次驱虫的倒计时天数。这个倒计时不是单独去算的而是通过关联查询该宠物最新一条驱虫记录的 nextDueDate 计算出来的。如果当前日期已经超过了 nextDueDate说明已经过期卡片上会有一个明显的红色警示标志提醒主人尽快补种。这里我想特别说一个关于图片存储的坑。一开始我图省事直接把图片的绝对路径存进了数据库结果在 Android 上没问题但换到鸿蒙设备上就出现了读取不到的情况。后来我才意识到不同系统的沙盒机制对文件路径的隔离策略是不一样的绝对路径写死了就很容易失效。最终的方案是把图片拷贝到应用私有目录下再把相对的存储路径存入数据库读取时通过 path_provider 动态拼接。这个改动虽然不大但对于跨平台应用来说非常关键。2.2 驱虫记录模块与数据库设计驱虫记录模块是这个应用的核心业务所在。一张记录需要包含宠物 ID、驱虫药物名称、药物剂量、驱虫日期、下次驱虫日期、操作者也就是谁带宠物去做的驱虫以及备注。注意到这里没有一个称之为“周期”的字段因为不同药物的有效期差异太大强行加一个周期字段反而会给用户造成困扰直接让用户选下次日期是最直观的。数据表的设计我用了外键关联通过 petId 将记录与宠物档案联系起来。查询层面我封装了一个 PetRepository提供 getLatestRecordByPetId 这样的方法上游界面拿到的都是经过 Repository 处理后的数据模型而不用关心 SQL 是如何写的。这样一来后续如果想把本地 SQLite 换成 Drift 或者 Room 之类的数据库只需要重写 Repository 层即可界面完全不用动。关于数据库结构设计我放在这篇教程里重点讲一下建表和升级的策略。应用后续难免要加字段、加表如果直接在用户设备上执行 DROP TABLE数据就全丢了这是绝对不能接受的。sqflite 的 onUpgrade 回调里有一个 oldVersion 参数我每次升级数据库版本时都先用 ALTER TABLE 语句把新字段加上或者用 CREATE TABLE IF NOT EXISTS 建新表这样既保证了结构变化又保住了老用户的数据。这个习惯让我在后续迭代中省了很多事建议你从一开始就养成。2.3 状态管理与组件通信机制在小体量应用里如果你发现一个状态被两个页面共用通常就该考虑把它放进全局状态了。比如当前选中的宠物 ID宠物详情页需要用到新增驱虫记录的页面也需要用到。如果靠页面间的构造参数一层层传不仅代码丑而且很容易在路由返回时出现状态不同步的问题。我使用的是 GetX 的状态管理方案把 PetController 和 DewormingController 挂到顶层每个页面通过 Get.find 直接访问。这里有一个很多初学者容易踩的坑GetxController 的生命周期和页面是绑定在一起的如果你在页面 A 的 initState 里通过 Get.put 创建了一个 Controller然后 navigate 到页面 B页面 B 想通过 Get.find 再次找到它时很可能会得到一个 null。正确的做法是在应用启动时初始化全局 Controller或者用 Get.lazyPut 确保只创建一次实例。这个问题的本质是 Flutter 组件生命周期与状态管理器作用域之间的关系搞懂了这两者的边界很多莫名其妙的报错都能想通。关于组件通信我想多说一点。Flutter 内部的组件通信方式大致有回调函数、InheritedWidget、Stream、状态管理等几种具体怎么选取决于通信的层级和数据的流向。父子组件之间直接用回调就够了跨页面共享状态就该用 GetX 或者 Provider涉及异步事件的广播则用 Stream 更合适。没有必要在上层选择最复杂的技术灵活运用才是正确的思路。我这个项目里新增记录后列表自动刷新的功能就是靠 GetX 的 update 方法实现的页面监听 Controller 的订阅一旦数据变化UI 自动重建根本不需要手动刷新。3. 鸿蒙适配从理论到落地3.1 鸿蒙环境下 Flutter 的适配思路鸿蒙系统作为国产操作系统的代表近几年在生态建设上的动作相当大。对于 Flutter 开发者来说最关心的问题无非就两个Flutter 应用能不能在鸿蒙上跑原生插件能不能复用第一个问题的答案是肯定的目前 OpenHarmony 生态中已经具备了对 Flutter 引擎的基础支持。这套支持并非简单地套个壳而是从引擎层的适配到插件桥接都做了大量工作。第二个问题则复杂一些因为很多 Flutter 插件底层依赖的是 Android 的 API比如通知栏管理、系统分享等这些 API 在鸿蒙环境中并不能直接调用需要通过鸿蒙原生的 ArkTS 接口重新实现一套桥接逻辑。我在做宠物驱虫记录器的鸿蒙适配时采用了官方推荐的 ohos_flutter_plugin 方案。这个方案的本质思路是Flutter 端通过 PlatformChannel 发出一个方法调用鸿蒙原生侧用 ArkTS 实现对应的监听器处理完后把结果返回给 Dart 层。整体架构和 Android 平台的插件机制非常相似唯一的区别在于你需要写一套 ArkTS 代码来替代以前写过的 Kotlin 代码。为了让这个过程更清晰我把适配工作分成了三层。最底层是 Flutter 引擎它由 OpenHarmony 社区持续维护中间层是平台通道负责 Flutter 与鸿蒙原生之间的数据交换最上层是各个插件的鸿蒙适配版比如我项目里的 flutter_local_notifications_ohos。这三层中真正需要你动手改代码的其实只有最上层只要你选择的插件有对应的鸿蒙适配版本中间的问题都不大。3.2 EventChannel 与本地通知的鸿蒙端实现EventChannel 是我这次鸿蒙适配中用的最多的通信机制。它和 MethodChannel 有本质的区别MethodChannel 适合一次性的方法调用比如“获取当前电量”“打开摄像头”这种请求-响应模式EventChannel 则更适合持续的、事件驱动的数据流比如“监听网络状态变化”或者“接收来自原生端的通知消息”。在宠物驱虫记录器里我用 EventChannel 做了这样一件事鸿蒙原生侧监听系统的日期变化事件一旦日期跨越零点就把事件推送到 Flutter 端Flutter 端收到后重新检查所有宠物的驱虫日期状态更新界面上的倒计时。这段逻辑如果完全在 Flutter 端做也不是不行但前提是应用必须常驻后台否则 Dart 层根本收不到日期变化事件。而通过 EventChannel 把原生层的时间感知能力“代理”给 Flutter 层就能让应用在后台状态下依然保持数据更新。这种跨语言的事件流通信正是 EventChannel 存在的意义。关于本地通知部分我在鸿蒙上遇到的主要问题是通知权限的获取方式与 Android 完全不同。鸿蒙系统要求应用在发送通知前通过华为的推送服务或系统通知接口进行显式授权权限弹窗的触发时机也与 Android 有着微妙的差异。如果不做适配就直接调用 Android 的通知 API应用会直接崩溃或者静默失败。我最终的做法是在应用首次启动时通过 EventChannel 向鸿蒙原生侧发出一条“请求通知权限”的事件由原生侧负责弹窗授权。这样既保证了用户在首次打开应用时就被正确引导又不用在 Flutter 层判断系统类型去做逻辑分支。4. 实操过程与核心环节实现4.1 项目搭建与依赖配置如果你是从零开始第一步自然是创建 Flutter 项目。在命令行执行flutter create pet_deworming然后编辑 pubspec.yaml 文件加入项目需要的依赖包。我这里给出一个核心依赖清单供你参考dependencies: flutter: sdk: flutter get: ^4.6.6 sqflite: ^2.3.2 path: ^1.8.3 path_provider: ^2.1.1 intl: ^0.18.1 flutter_local_notifications: ^16.3.2 image_picker: ^1.0.5依赖版本不一定非要和我保持一致但建议尽量使用较新的稳定版本尤其是 flutter_local_notifications这个插件迭代速度很快旧版本的 API 改动幅度较大网上大多数教程的写法在最新版本里可能已经不再适用。4.2 数据库层的实现细节数据库这块我想用一个具体的例子来说明怎么写 Repository。PetRepository 的核心方法有两个一个是 insertPet用于新增宠物档案另一个是 getPetWithLatestDeworming用于获取宠物信息以及其最新的一条驱虫记录。FuturePetWithLatestRecord getPetWithLatestDeworming(int petId) async { final db await DatabaseHelper.instance.database; final petRows await db.query(pets, where: id ?, whereArgs: [petId]); if (petRows.isEmpty) return null; final recordRows await db.query( deworming_records, where: pet_id ?, whereArgs: [petId], orderBy: deworming_date DESC, limit: 1, ); return PetWithLatestRecord( pet: Pet.fromMap(petRows.first), latestRecord: recordRows.isEmpty ? null : DewormingRecord.fromMap(recordRows.first), ); }这个方法把三张表的关联查询封装得干干净净界面层传入一个 petId拿到的直接就是合并好的业务模型。如果你看过一些涉及多个外键的项目会发现很多人直接在主界面里拼 SQL 查询结果就是业务逻辑和界面耦合在一起。Repository 模式虽然看起来多绕了一层但长期维护性提升不是一点半点。我个人强烈建议在项目一开始就采用这种分层思想。4.3 跨页面状态传递与路由管理路由管理我用 GetX 的命名路由方式。每一条路由都会绑定一个独立的页面页面之间通过参数对象传递必要信息。比如从宠物列表点击某个宠物卡片进入详情页时我通过Get.toNamed(/petDetail, arguments: petId)把宠物 ID 传过去详情页在 onInit 中接收。这里有个小技巧尽量不要直接传整个宠物对象因为如果该对象在数据库中后续有更新你传过去的还是旧数据容易造成显示不一致只传 ID让详情页自己去数据库里拉最新的数据才是更稳妥的做法。页面跳转后另一个常见问题就是状态丢失。你在列表页通过 GetX 维护了一个当前选中的宠物 ID跳转到其他页面再返回时这个状态理论上应该还在因为 Controller 并没有被销毁。但如果你在跳转时用了Get.offAndToNamed或者Get.offNamed这种“销毁当前页面再进入新页面”的方式状态就会随之丢掉。这一点在你需要频繁切换页面并依赖状态时特别容易踩坑我自己最开始也在这里栽过跟头。原则很简单除非业务上确实不需要返回否则用Get.toNamed不要用off系列。4.4 通知提醒的完整实现通知部分除了权限适配还有定时触发逻辑。flutter_local_notifications 支持通过zonedSchedule方法在指定时间触发通知。要让它按“提前三天提醒”这样的时间点触发我需要在保存驱虫记录时就把提醒时间精确到当天上午九点然后传给 scheduled 接口。因为这是一个本地通知不需要网络请求所以即使用户设备在没有网络的环境下提醒依然能够正常弹出。不过这里要提醒你的是如果应用进程被用户从后台强制划掉很多国产 ROM 会连本地通知的定时任务也一并杀掉导致提醒失效。这个问题在 Android 平台上是靠引导用户开启“允许自启动”权限解决的。在鸿蒙系统上处理方式也有类似的地方需要在设置中允许应用后台活动。如果用户没有开启相关权限应用能做到的就是在首次进入应用时检查通知权限和后台运行权限不满足条件时给出明确的引导说明。这不是一个可以纯代码解决的问题需要清晰地向用户解释为什么要开这些权限。5. 常见问题与避坑实录5.1 Flutter 环境搭建与 Gradle 构建问题环境搭建是很多人卡住的第一道门槛。关于flutter doctor反复报错、SDK 路径不对这类问题网上资料其实已经很多了。我更想提一下的是你在终端里运行flutter build时经常见到的 Gradle 报错比如 java.lang.AssertionError 这类难以理解的异常。大多数情况下这类问题的根源是本地 Gradle 缓存不完整或者 Gradle 版本与 Android Gradle Plugin 版本不匹配。解决方式并不复杂第一检查项目的 gradle-wrapper.properties 指定的 Gradle 版本是否与本地安装版本一致第二清空 ~/.gradle/caches 目录后重试第三如果项目里涉及到多个 Flutter 插件尝试逐个排除依赖找出冲突的源头。另外有一个很有意思的报错You are applying Flutters main Gradle plugin imperatively。这个报错出现在新版 Flutter 对 Gradle 脚本格式做了收紧之后老项目里使用旧式 apply 语法就会触发。修复的方法非常直接按照新版本 Flutter 生成的示例项目把 android/settings.gradle 里的插件声明方式改成 plugins 块的形式。这类构建工具链的问题往往和业务代码无关但一旦出现就非常影响开发节奏建议你在升级 Flutter 版本之后第一时间用一个小项目跑一遍完整构建流程确认本地工具链没有问题后再动现有项目的代码。5.2 Navigator 页面切换后状态丢失的处理“Flutter navigator 切换页面后会丢失状态吗”这个问题硬要让回答那要看情况。在标准的 Navigator 栈模型中页面 A 被推到栈下层它的 State 对象并不会被销毁只是被暂停了 show 状态。但只要 A 被pushReplacement或者popUntil从栈中移除它的 State 就必然会销毁。如果你在 A 的 State 里保存了一些临时数据又没有同步到持久化层或全局状态数据就真的丢了。我在宠物档案编辑场景中是这样处理的编辑页持有一个本地草稿变量用户在保存前修改了宠物的名字和体重此时如果误触返回键代码会先弹一个确认对话框询问“有未保存的改动是否放弃”确认后才真正 pop。这种体验上的细节对一个记录类应用来说很重要因为用户很可能会在录入一半时被电话打断。你可以在PopScope组件里拦截返回行为实现上面说的这个交互流程避免用户的重要数据被无意丢弃。5.3 鸿蒙适配中容易忽略的权限问题鸿蒙系统在权限模型上和 Android 的高版本有很多相似的地方但细节差异相当大。首先是权限弹出时间的差异Android 是即用即申请鸿蒙则更倾向于在用户进入某个功能模块时统一弹出权限请求。其次是权限声明的位置不同鸿蒙需要在 module.json5 文件中声明权限而不是 AndroidManifest.xml。如果你将在 Android 上的习惯直接搬到鸿蒙最可能看到的现象就是应用启动后通知权限弹窗始终不出现但日志里也无任何报错排查问题非常困难。我在做鸿蒙端通知权限适配时特意把权限的请求时机和业务场景绑定在一起用户第一次点击“新增驱虫记录”按钮时才请求通知权限。这样的好处是用户此刻明确知道为什么要通知权限因为需要在驱虫日期临近时提醒他同意率会比冷启动时直接要求授权高很多。这个思路放到 Android 平台上同样适用算是跨平台开发中少数能从鸿蒙反向学习到的产品交互经验。5.4 驱动级问题从数据库工具到调试技巧在开发过程中有一类问题很难通过代码审查发现需要借助外部工具来排查。比如你往数据库里插入了一条记录字段值看起来都正常但查询结果却少了某几个字段。这时候我建议你用 db4s 这样的 SQLite 可视化工具直连调试设备的数据库文件逐条记录查看实际存储的值比对是否与代码逻辑预期一致。这类问题多数出在字段映射上比如 you 用了toMap方法写库但读取时fromMap却少写了一个字段的映射这种隐蔽的错误凭眼睛很难看出来只有直接把数据拿出来对比定位起来才快。Android Studio 自带的 Database Inspector 实际上也能在调试时实时查看数据库内容但它只在 Android 模拟器或开启 debug 的 Android 设备上有效。鸿蒙设备调试数据库时常需要先把数据库文件拉取到本地再用工具分析。文件的存储路径可以通过 path_provider 插件获取然后你用 adb pull 把它拷贝出来。掌握了这套流程排查数据相关的 bug 会轻松特别多。就我个人使用了这么多跨平台框架之后的感觉来说Flutter 鸿蒙的组合已经不再是“能不能用”的问题而是“怎么用得更好”的问题。宠物驱虫记录器这个小项目让我把 Flutter 的组件通信、状态管理、原生插件桥接等一整条链路都捋了一遍也让我确信一套代码快速覆盖 Android、iOS 和鸿蒙多个平台在工具类应用场景下是完全可行的。还有一个小技巧送给你写 Flutter 代码时把 UI 层和数据层彻底剥离开不要为了图省事在 Widget 里直接写数据库查询。这样页面看起来清爽调试时也能快速定位是 UI 的问题还是数据的问题。做任何跨平台项目耐心和时间都花在了解决边界问题上代码上的整洁会让这条路轻松不少。