想在鸿蒙设备上交付一个 Flutter 应用,第一反应基本都是“到底能不能跑”。去年我接到一个内部需求,要把一套基于 Flutter 的健康管理 Demo 移植到鸿蒙平板上,最开始以为只是换套打包脚本的事,真正动手才发现里面的坑比想象中多得多。但踩完之后回头看,这套跨平台方案的价值也确实立住了:一套 Dart 代码,安卓、iOS、鸿蒙三端同步维护,日常开发效率远高于各端重写。
这篇文章就以我实际做完的一个“每日饮水 APP”为例,从方案选型、环境搭建、代码结构、鸿蒙专项适配到上线前的问题排查,完整梳理一遍 Flutter 跨平台鸿蒙开发流程。内容适合两类读者:一类是想评估 Flutter 上鸿蒙可行性的技术负责人,另一类是已经在写 Dart、正打算把现有应用迁到鸿蒙的开发同学。我会把每一个关键环节的取舍原因和真实操作细节都讲清楚,不是泛泛而谈的流程介绍。
1. 整体设计与方案选型
1.1 为什么选 Flutter 而不是 ArkTS 单独开发
先说结论:如果团队里已经有成熟的 Flutter 代码资产,那鸿蒙适配一定优先选择 Flutter 跨平台方案;如果是从零开始且只做鸿蒙单端,ArkTS 原生开发自然更合适。我接手这个饮水 App 时,项目已经跑在安卓和 iOS 上,用户量不大但功能完整,团队没有精力再维护第三套 UI 代码。用 Flutter 做鸿蒙适配,意味着 UI、业务逻辑、数据模型几乎全部复用。
目前 Flutter 鸿蒙支持走得比较靠前的主要是 openharmony-sig 维护的 flutter_flutter 分支和配套的 flutter_packages 仓库。这套方案从 2023 年开始逐步成型,到 2024 年年中已经能支撑比较复杂的生产级应用。它做的事情可以理解为:把 Flutter 引擎的底层能力对接上 OpenHarmony 的图形栈和事件分发,上层 Dart 代码完全无感。也就是说,你在 Flutter 里写的 Widget、动画、路由、状态管理,在鸿蒙设备上运行时不会感知到宿主系统是谁。
另外要考虑的隐性成本是人心和技能栈。一个团队如果全员都会 Dart,移植鸿蒙的学习曲线很短;如果突然要求大家去学 ArkTS 的声明式 UI 和状态管理,那不只是写代码的问题,还有设计规范、组件库、调试工具链全面切换的代价。跨平台方案把鸿蒙变成了“多目标平台里的另一个 target”,对现有研发流程的侵入是最小的。
1.2 饮水 App 的功能边界与架构落点
这个 App 的功能不算复杂,但五脏俱全。核心模块包括:用户画像与饮水量目标计算、饮水记录与快捷添杯、定时提醒和补水电量、日周月统计报表、数据本地持久化。这样的功能范围用来验证鸿蒙适配很合适——它既有 UI 交互,又有定时任务、系统通知、数据库读写,基本覆盖了 Flutter 插件平台通道的常见场景。
架构上我选了三层结构:最底层是数据层,负责 DrinkRecord 的增删改查和本地存储;中间是业务层,处理目标计算、完成率判定、提醒时间策略;上层是 UI 层,承载首页进度环、记录列表、统计图表和设置页面。状态管理用的还是项目老底子 Provider,没有引入更重的方案。原因是我需要控制风险,鸿蒙适配本身已经有足够多的不确定性,状态管理选型越简单越好,等跑通了再升级也不迟。
这里有个很关键的选型提醒:在适配阶段尽量冻结业务需求,不要一边迁移一边加功能。你把问题域限定住了,排查问题时才容易定位到底是 Flutter 引擎的问题、插件兼容的问题,还是你业务代码的问题。要把迁移过程当成一次“技术债偿还”,而不是一次“重写”。
1.3 数据持久化方案的取舍
饮水记录的特点是写入频繁、单条数据小、查询需要按时间聚合。最开始我打算用 sqflite,因为安卓和 iOS 上它是最成熟的关系型数据库方案。但实测在鸿蒙上,sqflite 需要通过 ffi 调用 SQLite 原生库,openharmony 虽然提供了 sqlite 的 ndk 接口,但插件层的封装成熟度还不够,容易在打开数据库时出现符号找不到的问题。
后来我换了思路,直接用 shared_preferences 保存一份 JSON 数组,再加上内存缓存。对单用户普通频率的饮水记录来说,每天顶多几十条,全量 JSON 序列化反序列化性能几乎没有压力。查询统计时一次性加载到内存做聚合,开发效率高,也避开了原生数据库插件适配的坑。这个方案在功能验证阶段足够用,如果后续数据量大再平滑切换到 drift 这类支持自定义数据库连接的方案也不迟。
真正踩到的一个隐藏问题是:shared_preferences 在鸿蒙上存储路径和 key 的组织方式与安卓不同,应用卸载重装后可能残留缓存数据。所以我在启动时加了一层简单的数据校验,解析失败就重建默认数据,保证不会因为脏数据导致启动崩溃。
2. 鸿蒙环境搭建与项目迁移实录
2.1 开发环境的关键匹配关系
先讲环境版本这件事,这是最容易翻车的环节。Flutter 鸿蒙适配不是把官方 Flutter SDK 拿来直接加参数就能跑,你需要使用 openharmony-sig 维护的 flutter_flutter 分支,并且它的版本节奏落后于官方主线。我用的组合是:Flutter 3.22.x(ohos 分支)、OpenHarmony SDK 5.0.0、DevEco Studio 5.0 配套的 command line tools。
这里有几个必须注意的匹配点。第一,Dart SDK 版本是随 Flutter 分支带下来的,不能用官方独立安装的 Dart 覆盖。第二,OpenHarmony SDK 的 API version 要跟项目里的 compatibleSdkVersion 对应,否则编译期会报一些莫名其妙的 API 不存在错误。第三,鸿蒙构建工具链 hvigor 的版本由 DevEco Studio 管理,如果你在命令行单独跑构建,要确保 ohpm 和 hvigor 都加入了 PATH。
环境搭好后,验证是否正常最直接的方式是跑一遍 flutter doctor。正常的话应该能看到一条类似“Flutter (Channel ohos)”的信息,并且 harmony 工具链被正确识别。这里我不建议直接开一个全新项目试,而是在老项目分支上先建一个最小页面跑通再说,这样能区分问题是出在你的业务代码还是环境本身。
2.2 老项目迁移的具体操作步骤
迁移 Flutter 跨平台鸿蒙开发老项目,不会像想象中那么复杂,但有一堆细节要求。我的建议是把迁移拆成五个步骤依次执行。
第一步,替换 Flutter SDK 分支。在项目根目录的 pubspec.yaml 里,加入 dependency_overrides,把 flutter、flutter_localizations、flutter_test 等核心包都指向 openharmony-sig 发布的 flutter_packages 仓库对应版本。这一步是因为 Flutter 框架自身的插件注册表在鸿蒙上有独立实现,官方插件无法直接识别鸿蒙。
第二步,创建鸿蒙宿主工程。用 DevEco Studio 新建一个空的 HarmonyOS 应用,包名、版本号尽量复用原项目的安卓配置。需要注意的是,鸿蒙的 bundleName 格式与安卓 applicationId 不完全一致,但规则相近,建议都改成 com.example.drinkwater 这种反域名结构。
第三步,把 Flutter 模块接入鸿蒙宿主工程。具体操作是在鸿蒙工程的 entry module 的 oh-package.json5 里添加 flutter_ohos 的依赖,并在 MainAbility 的 onWindowStageCreate 中调用 Flutter 的启动入口。这一步相当于把 FlutterViewController 的概念移植到 ArkTS 侧。
第四步,构建产物配置。在项目的 build-profile.json5 里设置签名配置。调试阶段可以用自动签名,把设备连接上 DevEco Studio 自动生成 profile。正式发布则需要手动配置 hap 证书。
第五步,启动调试。鸿蒙设备连接调试使用的是 hdc 命令,类似安卓的 adb,但常用命令有差异。通过 hdc list targets 确认设备在线,之后 Flutter attach 或 flutter run -d <device_id> 都能正常工作。
做完这五步,你应该能在鸿蒙设备上看到 Flutter 默认的计数 Demo 页面。这一关过了,跨平台迁移最危险的部分就算闯过去了。
2.3 构建脚本与持续集成改造
开发机跑通只是开始,真正要长期维护的是 CI 流程。之前的安卓和 iOS 构建都跑在 Jenkins 上,鸿蒙构建需要在构建机上也安装 OpenHarmony SDK 和 hvigor。我试过几台 Ubuntu 构建机,有一个明显教训:OpenHarmony 的 command line tools 对 JDK 版本很敏感,17 以下大概率构建失败,建议统一用 JDK 17 的 x64 版本。
另外,鸿蒙构建产物是 .app 或 .hap 包,输出路径和安卓的 APK 位置不同。在现有流水线里可以单独加一个 stage,拉一份 flutter_ohos 分支代码,执行 hvigorw assembleHap 来产出 hap 包。这里要提醒的是,hvigor 的增量编译在 CI 上容易出状态残留问题,如果改了原生代码没有生效,clean 之后再构建通常能解决。
3. 核心功能开发与代码实现细节
3.1 数据模型与目标计算逻辑
饮水目标的推荐公式是体重乘以系数,这是健康类 App 比较通用的基础逻辑。我实现的 DailyGoal 计算方法是:目标量 = 体重(kg)乘以 35 毫升,这是指成年人基础饮水需求,再根据当前运动状态乘一个修正系数。这个公式用在 App 里主要是给用户一个默认值,允许手动修改。
代码上我建立了一个 DrinkRecord 数据类,包含 id、recordTimeMillis、amountMl、drinkType 四个字段。drinkType 用枚举表示清水、茶饮、咖啡、其他,后续统计可以直接按类型过滤。这里有一个设计细节我想多说一句:时间字段一定要用毫秒时间戳存储,不要用格式化字符串。因为统计报表需要按天、周、月分组,时间戳可以轻松转换为本地日的起始边界,字符串格式则会遇到时区、跨年问题的各种麻烦。
目标计算和记录更新的逻辑放在同一个 ChangeNotifier 里,每次新增记录都会重新计算今日已完成量和完成百分比。这个百分比归一化到 0 到 1 之间,直接驱动首页的进度环。
3.2 饮水记录与 UI 交互实现
饮水记录页面是用户每天面对最多的界面,交互设计上要尽量减少点击次数。我采用了大按钮加自定义容量的组合:底部三个常用容量按钮(250ml、330ml、500ml),点击即记录;顶部有一个滑动条可以调整自定义容量,解决用户使用的是特定杯型时的记录需求。
这里有一个踩坑经历。最开始我在按压按钮后直接弹出 SnackBar 提示“已记录”,但鸿蒙设备上 SnackBar 的默认位置和高度跟安卓不同,在部分全面屏手势模式下会被手势条遮挡。解决方式是改用自定义 Overlay 提示,不依赖 Material 组件的默认定位逻辑。这再次验证了一件事:跨平台框架能保证逻辑一致,但 UI 细节必须逐端验证。
记录列表用 ListView.builder 渲染当天全部记录,每条记录左侧显示时间,右侧显示饮水量。这个页面的性能压力很小,没有做分页加载,但按天滑动查看历史记录时,需要把日期切换和列表数据绑定处理好。我把当前查看日期提升到了页面 State,根据日期变化重新从内存数据源中获取当天记录,而不是在列表滚动事件里去判定日期变化,这样逻辑简单也更好调试。
3.3 定时提醒与本地通知的鸿蒙适配
提醒功能是这个 App 最能体现平台差异的地方。在安卓上,我使用 flutter_local_notifications 插件,通过 AlarmManager 实现精确的定时通知。在鸿蒙上,这个插件的实现路径完全不同——鸿蒙的通知服务走的是通知管理子系统,需要应用先申请通知权限,再通过后台任务或闹钟接口触发。
我在适配时遇到的第一道坎是权限。鸿蒙的权限模型比安卓更严格,通知权限必须在应用启动时通过一定的触发机制去请求,不能在后台静默注册。我实现了一个引导对话框,首次进入提醒设置页时主动拉起权限请求,用户同意后才允许设置提醒,这样既符合系统要求,也避免用户莫名其妙被拦截。
第二道坎是后台执行限制。如果用户设置的是“每小时提醒一次”这种周期性任务,你不可能依赖 Flutter 侧的 Timer 在后台运行,因为进程随时可能被挂起。我目前采用的方式是在应用存活期间用 Timer 触发通知,同时保存下一次提醒的本地闹钟计划。如果应用被杀死,提醒就不会触发,这个局限性在原生鸿蒙应用里也存在,需要接入系统的长时任务或后台代理才能完整解决。我在产品说明里明确标注了这一限制,避免用户误解。
3.4 统计报表与自定义绘制
统计页需要展示近 7 天饮水量的柱状图和完成率环形图。图表库我选了 fl_chart,它在三端都有良好的兼容性,纯 Dart 绘制,不依赖原生图表能力。柱状图用 BarChart,每日数据从记录集合中按日期聚合得出,这里同样是从内存或持久化数据中一次性载入。
环形进度和柱状图之外,我还用 CustomPaint 画了一个“今日饮水分布”的 24 小时时间轴,把每次饮水的时刻用圆点标出来。这个视觉元素是纯 Flutter 代码完成的,适配鸿蒙时没有任何额外工作量。这让我再次确认了一个选型判断:UI 层尽量用 Flutter 自带能力实现,少引入第三方插件的平台通道,跨端稳定性会高很多。
统计页还有一个相对冷门但实用的功能:导出本周饮水数据为 CSV 文本。这个功能简单到不依赖任何插件,只需要在内存中拼接字符串,然后用 share 插件唤起系统分享面板。但在鸿蒙上 share 插件也存在兼容问题,目前我暂时用的是复制到剪贴板的方式,提示用户自行粘贴到备忘录,功能和体验稍差一点,但逻辑完全可控。
4. 鸿蒙化专项适配与差异化处理
4.1 插件生态的评估与替换策略
老项目跑在安卓上时,往往随手就用了很多插件。真正迁移到鸿蒙后,你会发现自己依赖的插件很多都没提供鸿蒙实现。我的做法是给所有依赖插件画一个矩阵:Flutter 官方核心插件、(openharmony-sig 已适配的插件)、纯 Dart 实现的插件、完全没有鸿蒙支持的插件。
完全没支持的插件有两种处理路径。第一种是查找功能替代包,比如某些二维码扫描插件在鸿蒙上无法使用,可以换用集成 zxing 的纯 Dart 实现。第二种是走 MethodChannel 自己写一个原生鸿蒙实现,把原生代码写在 ArkTS 的 entry module 里,Flutter 侧通过统一的通道调用。后一种方案工作量略大,但一劳永逸,适合那些你无法替换的核心能力。
我这次遇到最典型的案例是包管理工具 permission_handler。它在安卓上是标准插件,鸿蒙上没有对应实现。由于饮水 App 涉及通知权限,我干脆写了一个封装层:Flutter 侧定义一个 SystemPermissionService 抽象接口,在安卓端调用 permission_handler,在鸿蒙端调用原生 ArkTS 的权限接口。这样上层业务代码完全不用关心系统差异,替换成本被服务在一个薄层里。这也是整个迁移过程中最值得投入的地方——把平台差异隔离在一层,而不是散落在业务代码里。
4.2 启动流程与生命周期差异
Flutter 应用跑到鸿蒙上后,生命周期事件与安卓有微妙差异。在安卓上,FlutterActivity 的 onPause 和 onResume 会直接映射到 widget 的 AppLifecycleState;鸿蒙的 MainAbility 也有类似的前后台切换,但 onWindowStageHide 的触发时机比安卓的 onStop 更晚一些。
这带来的实际问题是:用户从后台切回 App 时,部分页面的状态恢复可能慢一拍。我在饮水首页做了自适应刷新,通过 WidgetsBindingObserver 监听到 resumed 状态时重新计算当天的饮水数据,避免因为长时间后台导致界面显示过期数据。
另一个生命周期问题是 App 退出时的数据保存。Flutter 的状态不保证在进程被杀前一定会执行 dispose 或保存逻辑,我选择在每次新增记录时同步写入本地存储,而不是依赖退出时的集中保存。这种“即时写入”策略牺牲了极小性能,换来的是数据不丢失,对用户日常记录饮水的场景非常重要。
4.3 桌面卡片、图标与启动屏
鸿蒙用户很看重桌面卡片能力,但 Flutter 应用无法直接用 Dart 代码绘制桌面卡片,这是当前阶段的一个硬边界。桌面卡片的本质是一个 ArkTS 编写的 FormExtensionAbility,它和 Flutter 的 UI 树完全是两套体系。如果产品上非要不可,只能接受在 ArkTS 侧重写一个卡片组件,通过与 Flutter 共享本地数据文件或通过 App 内更新数据的方式实现。
图标和启动屏相对简单。鸿蒙应用图标放置在 AppScope/resources/base/media 目录下,格式支持 png 或 svg。启动屏在 Flutter 侧的 windowBackground 属性控制,可以在原生工程的 resources 里配置一张静态图片。我的建议是启动屏配色尽量与 App 主色调一致,不要设置太长时间的延迟,用户在鸿蒙设备上对启动速度的敏感度高于安卓。
这部分的总结是:鸿蒙化不只是跑通代码,还要补齐系统级体验的差异化能力。Flutter 能帮你解决 80% 的 UI 和业务逻辑,但剩余 20% 的系统集成工作(通知、卡片、权限、后台任务)仍然需要原生或混合方案去覆盖。
4.4 设备调试与日志分析
鸿蒙设备的调试不叫 adb,而是 hdc。命令风格接近 adb,但细节差异让人头疼。例如查看设备日志需要使用 hdc hilog,而不是 logcat;安装应用用 hdc install,但 hap 包的安装参数比 apk 更多。
我在联调阶段最常用的三条命令是:hdc list targets 查看设备、hdc hilog 抓取 Flutter 侧的输出、hdc file send 推送测试数据文件到应用沙盒。Flutter 的 debugPrint 在鸿蒙上会输出到 hilog,并且默认日志级别过滤可能会把它藏起来,需要加上 -e flutter 之类的过滤条件才能完整看到。这里有一个实用经验:你在 Flutter 里的日志如果大量丢失,先检查是否有 log level 限制,再检查 serial 端口连接是否稳定。
另外,开发期强烈建议用 DevEco Studio 的 Device File Browser 查看应用沙盒文件,尤其是当你需要验证 shared_preferences 写入是否成功时,直接找到偏好文件看内容比在代码里打日志高效得多。
5. 常见问题与排查技巧实录
5.1 编译期错误的典型场景
迁移过程中最消耗时间的不是写代码,而是处理编译错误。我整理了三个最高频的场景。
第一种:依赖包版本冲突。报错信息形如 “Because xxx requires Flutter from another source”。这是因为 dependency_overrides 里的 flutter SDK 版本和 pub 仓库上的其他依赖预期版本不一致。解决方案是统一 freeze 版本,把所有传递依赖也用 dependency_overrides 指向鸿蒙适配分支。
第二种:hvigor 编译时找不到 OpenHarmony SDK 路径。这种情况常见于脱离了 DevEco Studio 单独跑命令行构建的场景。要检查 local.properties 里的 sdk.dir 是否指向了正确的 OpenHarmony SDK 目录,而不是误指向了 Android SDK。
第三种:C++ 编译报错。Flutter 鸿蒙引擎层包含 C++ 代码,在 Ubuntu 构建机上常见的是缺少基础编译工具链,比如 clang 版本过旧或缺少 cmake。装上较新的 clang 和 cmake 基本能解决问题。
5.2 运行期崩溃与诡异行为
运行期遇到的问题比编译期更难复现和定位。我遇到过一个印象深刻的 case:鸿蒙设备上连续快速点击“添加饮水记录”按钮时,偶发崩溃,但在安卓上怎么点都没事。
最终定位到是 Provider 的 notifyListeners 触发了页面重建,而重建过程与原生事件循环的时序冲突。解决办法是使用 Stream 替代部分高频状态更新,或者在事件处理里加一个轻量的节流防抖。这个经验也侧面说明:不要想当然认为 Flutter 代码在安卓上稳定就必然在其他平台稳定,每个平台的 UI 事件粒度不同,偶发问题始终存在。
另一个常见问题:中文输入法在 TextField 内输入时,光标位置跳变或候选词遮挡。这个属于引擎层已知差异,openharmony-sig 持续在修。规避方法是减少沉浸式输入场景,需要输入的地方尽量用独立页面,不要让键盘遮挡主要操作区。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 构建时提示依赖版本冲突 | 传递依赖引入官方 Flutter SDK | 统一 dependency_overrides 指向鸿蒙适配仓库 |
| hvigor 构建失败,找不到 SDK | local.properties 的 sdk.dir 配错 | 修改为 OpenHarmony SDK 正确路径 |
| 设备连接不上 | hdc 服务未启动或 USB 调试未开 | hdc kill-server 后重启,检查开发者选项 |
| 通知权限弹窗不出现 | 鸿蒙通知权限需手动触发申请 | 在用户操作事件回调中启动权限请求流程 |
| 应用后台后提醒失效 | 周期任务无法在后台长期运行 | 明确产品预期,使用系统级任务能力扩展 |
| 部分图片资源加载失败 | 资源目录命名不一致 | 校验鸿蒙资源目录的 media 规范 |
| logcat 日志看不到 | 鸿蒙日志系统是 hilog 不是 logcat | 使用 hdc hilog 抓取日志并适当过滤 |
| 键盘弹出遮挡输入框 | 窗口调整模式兼容问题 | 把输入场景放到独立页面,避免复杂沉浸布局 |
5.4 梳理一下我对鸿蒙适配的整体感受
适配鸿蒙这件事,有点像早年做安卓碎片化适配合集,最麻烦的永远不是框架本身,而是生态工具链的成熟度。Flutter 鸿蒙分支已经解决了“能不能跑”的问题,但离“无缝好用”还有距离,插件适配和调试体验都需要时间沉淀。
如果计划启动类似项目,我给三点朴实建议。第一,给迁移留出至少一周的排错时间,不要排期排得太乐观。第二,插件依赖越少越好,纯 Dart 实现的包优先选。第三,把原生差异都收口到一个 service 层,别让业务代码到处嵌套平台判断。按照这样做,即便后续鸿蒙 SDK 升级带来 breaking change,你也能用最小成本跟上去。
做这个饮水 App 的完整过程中,我最满意的一个决策就是坚持把数据层和平台差异彻底隔离。所以我最后想分享的小技巧是:在你项目里建立一个 platform_bridge 目录,专门放所有涉及系统能力调用的接口和实现,尽量用接口定义业务侧能力需求,再用各端具体实现去适配。这样后续无论面对鸿蒙还是未来可能出现的其他平台,你都不需要动业务代码。这一次适配的经验,对任何一个跨平台产品来说都值得沉淀成一套方法论。