最近团队接了个活,要做一款单位换算工具,需求很明确:覆盖Android、iOS,还要兼容鸿蒙。做了这么多年客户端,我太熟悉这种“三端都要”的玩法了——以前意味着三个项目组、三套代码、三份测试,最后往往是Android和iOS各做各的,新系统来了再另起炉灶。这次我不想再走老路,决定用Flutter把这活儿一锅端了。
这里我得坦白讲,最初我对Flutter跑鸿蒙这件事是有疑虑的。毕竟鸿蒙生态曾经走过一段“兼容Android”的过渡期,很多团队直接拿APK装上去完事,但单位换算大师这种要配合鸿蒙原生服务、最终走鸿蒙工具链上架的应用,迟早得走hap这条路。直到我深入研究并把OpenHarmony分支的Flutter SDK真正跑起来之后,才确认这条路是通的,而且体验比想象中顺畅。
这篇内容写给谁看?想用一套Flutter代码同时产出Android、iOS、鸿蒙三端应用的人;或者团队里有Flutter鸿蒙适配任务、但不确定技术路线能否落地的小伙伴。我会把选型思路、环境搭建、代码设计、打包发行的完整流程都写出来,所有过程都是实际跑过的,不是对着文档空想。
1. 选型复盘:为什么是Flutter而不是ArkUI或uni-app
1.1 三套原生的成本算不过账
先看最朴素的方案:Android一套Kotlin,iOS一套Swift,鸿蒙一套ArkTS。理论上这是“最正统”的路线,平台能力跟得最紧,性能和体验也最可控。可一旦把一个工具型应用拆开算账,事情就完全不一样了。
单位换算大师的核心页面大概包括:换算主界面、单位分类选择、历史记录、收藏列表、设置页。粗看不多,但每个页面都要处理状态管理、主题切换、深浅色模式、动画动效、本地数据持久化,三套写下来就是三份完全独立的代码库。更麻烦的是后续迭代——加一个新单位分类,三个端要同步改;改一个交互细节,三个端要同步评审。对一个小团队来说,三倍的人力意味着项目周期至少翻两倍,这在产品侧根本等不起。
所以当时的第一判断就是:必须有跨平台方案。跨平台的目的不是省掉“写UI”的人力,而是省掉“同一套业务逻辑在不同语言里反复翻译”的人力,以及后续迭代时“一个功能改三遍”的隐性成本。选择Flutter的另一个潜意识因素是团队里有人写过Dart,语法上手成本低,这是选型时容易被低估的点。
1.2 各跨平台方案在鸿蒙上的适配现状对比
选型的时候我把自己熟悉的跨平台方案过了一遍,逐一去查它们在鸿蒙生态的适配情况。
- React Native:社区适配方案存在,但版本跟进速度偏慢,部分原生模块需要自己封装桥接,踩坑资料也比较少,遇到问题容易卡死。
- uni-app:国内生态做得不错,自己也发布了鸿蒙适配版本,适合以国内业务为主的H5类应用;但如果团队已经习惯了Flutter的渲染模型和Widget体系,切换过去需要不少心理成本。
- Flutter:最早对第三方生态开放度最高的跨平台方案之一,OpenHarmony分支的Flutter SDK已经能跑通主线工程,并且网络、存储、状态管理等常用基础库都有对应的适配成果。
我做了一个简单的对比表,方便还在犹豫的人快速判断:
| 方案 | Android表现 | iOS表现 | 鸿蒙适配现状 | 工具链成熟度 | 个人结论 |
|---|---|---|---|---|---|
| 原生三套 | 优 | 优 | 优 | 优 | 成本过高 |
| Flutter | 优 | 优 | 较成熟,能出hap | 中高 | 推荐 |
| React Native | 良 | 良 | 社区方案,版本滞后 | 中 | 观望 |
| uni-app | 良 | 良 | 有官方适配,但深度一般 | 中高 | 可备选 |
这里特别想说明一点:Flutter之所以能在鸿蒙上跑起来,核心在于它的渲染层是自己用Skia(后来逐步引入Impeller)自绘的,不依赖各平台的原生UI组件。也就是说,只要把引擎的嵌入层适配到鸿蒙的Surface上,Dart层的业务代码几乎不用动。这套架构设计当初是为了保证跨端UI一致性,现在反而成了它拥抱新平台的最大资本。
1.3 单位换算这个场景为什么特别适合Flutter
单从业务来看,单位换算大师是一个非常“标准”的Flutter应用:
- UI完全自绘可控,不需要嵌入复杂的原生地图、视频、3D画布等重型组件;
- 核心逻辑是纯Dart的换算计算,没有平台差异;
- 本地存储只需要保存历史记录和用户偏好,不依赖平台级数据库;
- 即使后续要加图库选图、应用内支付这些原生能力,也有MethodChannel可以打通。
这几点凑在一起,意味着Flutter的“跨平台红利”在这个项目里能吃到最大。说白了,越是不依赖平台特性的应用,越适合用Flutter做。反过来,如果你要做的是一个需要重度调用底层硬件能力的应用,那Flutter的优势会被削弱,需要投入额外精力做原生插件的适配。
2. 环境准备:搭出能出鸿蒙hap的Flutter工程
2.1 需要的工具清单
这一步是整个过程中最容易被低估的。很多人卡在“环境跑不起来”上,其实不是技术难度,而是版本对应关系没搞对。我这边最终稳定的工具版本组合如下:
- DevEco Studio:用于鸿蒙工程的导出、签名、打包,日常写代码时不一定要打开;
- Flutter SDK(OpenHarmony分支):从官方仓库拉取的适配版本,不是原版flutter/flutter;
- ohpm:鸿蒙的包管理器,类似npm,安装原生依赖时要用;
- Node.js:部分自动化脚本依赖;
- Java JDK:DevEco Studio运行依赖。
这里有个很关键的认知:OpenHarmony分支的Flutter SDK和官方主线的SDK并不是简单的小版本差,而是带有专门的鸿蒙平台嵌入层。所以环境变量里配置的flutter路径一定要指向这个分支,否则你执行flutter create的时候根本看不到鸿蒙的工程选项。我当时先装了原版Flutter,又装了鸿蒙分支,PATH变量指错了顺序,结果折腾了快一下午才反应过来。
2.2 创建支持鸿蒙的Flutter项目
工具链就位后,创建项目的步骤大致是这样:
- 用Flutter SDK创建标准的Flutter工程;
- 在工程目录下执行与鸿蒙平台相关的初始化命令,生成entry模块、hvigor配置、oh-package.json等鸿蒙工程结构;
- 用DevEco Studio打开生成的鸿蒙工程目录,等待hvigor自动同步依赖;
- 执行构建,首次构建时间会偏长,因为引擎要编译成鸿蒙的so库。
命令行层面大概长这样(具体命令以你拉取的SDK分支说明为准):
flutter create --org com.example unit_converter cd unit_converter flutter build hap --debug第一次跑通的时候还挺感慨:一个原本为Android/iOS设计的Flutter工程,竟然真的能输出hap包。这说明鸿蒙适配分支的完成度已经很高了。
2.3 最容易翻车的环境坑
下面这几个环境坑,我基本都踩过一遍,列出来帮你省时间:
- JDK版本对不上DevEco Studio的要求,同步阶段会报各种奇怪错误。解决办法是打开DevEco Studio的设置页,确认它内置的JDK版本,然后用同样的版本配置JAVA_HOME。
- Flutter分支版本和OpenHarmony SDK版本错位。建议优先使用适配分支release说明里锁定的组合,不要各取最新,否则编译时可能会遇到API签名不一致。我的习惯是先把适配分支的README完整看一遍,把推荐的版本组合记录下来再动手。
- ohpm仓库默认源可能不稳定,可以配置镜像源。这一步属于国内开发环境的常规操作,核心是让依赖拉取更顺畅。
- 首次构建需要下载大量依赖和引擎产物,如果网络条件不好,很容易超时失败。我的做法是先手动执行一次全量构建,让它把所有依赖缓存下来,之后的小改动就不需要反复下载了。
3. 单位换算大师的数据模型与换算引擎设计
3.1 先理清功能需求
动手写代码前,我习惯先把功能边界划清楚。单位换算大师第一版需要支持:
- 至少6大类单位:长度、重量、温度、面积、体积、时间;
- 每大类下有常用子单位,比如长度包含米、千米、厘米、毫米、英尺、英寸、英里等;
- 换算方向要支持“任意单位到任意单位”,不能只做“第一行到第二行”;
- 需要保留最近20条换算记录;
- 用户可以收藏常用换算组合;
- 支持深色模式,自动跟随系统。
这里面最有意思的是换算引擎的设计。一开始我想得很简单——每个单位存一个对基准单位的系数,换算时先转基准单位,再转目标单位,一步到位。后来一写才发现温度这种单位根本不乐意配合,华氏度和摄氏度之间是偏移加比例的关系,用一个固定系数根本搞不定。
3.2 数据模型设计
我的最终设计是这样的:
enum UnitCategory { length, weight, temperature, area, volume, time } class Unit { final String id; final String name; final UnitCategory category; final double Function(double)? toBase; final double Function(double)? fromBase; final double? linearFactor; }对绝大多数线性单位(长度、重量、面积、体积),一个linearFactor就够了。比如:
- 米:linearFactor = 1,因为基准单位就是米;
- 千米:linearFactor = 1000;
- 厘米:linearFactor = 0.01。
换算逻辑就是从源unit先执行toBase,拿到基准值,再执行目标unit的fromBase,得到目标值。线性单位下这俩函数都可以由linearFactor自动生成,不需要为每个单位手写。
温度这种非线性单位,则直接给出两个函数:
final celsius = Unit( id: 'celsius', name: '摄氏度', category: UnitCategory.temperature, toBase: (c) => c, fromBase: (b) => b, ); final fahrenheit = Unit( id: 'fahrenheit', name: '华氏度', category: UnitCategory.temperature, toBase: (f) => (f - 32) * 5 / 9, fromBase: (c) => c * 9 / 5 + 32, );这样设计的好处是:线性单位可以用统一工厂方法快速注册,非线性单位只需额外多写两个函数,整个注册表非常清爽。后续要加一个新的单位,比如“海里”或“开尔文”,只需要在数据表里追加一行定义,UI无需任何改动。
3.3 换算引擎核心实现
换算服务本身是纯Dart类,不依赖任何Flutter组件,这样就能放进单元测试里直接验证:
class UnitConverter { double convert(double value, Unit from, Unit to) { if (from.category != to.category) { throw ArgumentError('单位类型不一致,无法换算'); } if (from.linearFactor != null && to.linearFactor != null) { // 线性单位走基准系数 return value * from.linearFactor! / to.linearFactor!; } // 非线性单位走自定义函数 final baseValue = from.toBase!(value); return to.fromBase!(baseValue); } }这段代码看起来简单,但真正决定换算结果正确性的,是每个单位注册时参数对不对。我实践下来的建议是:每个分类单独写一组单元测试,把所有相邻关系都测一遍。别看这些测试占时间,没有它们,后续更新单位表时改错一个系数,用户到处报问题,排查起来反而更痛苦。比如重量分类里“斤”和“公斤”的系数,稍不留神就错了一个量级。
3.4 历史记录与收藏的持久化
历史记录和收藏量都不大,我直接用的shared_preferences,把数据序列化成JSON数组存到本地。单条记录就是一组源单位、目标单位、源值、目标值、时间戳。之所以不引入完整数据库,是因为这个量级的读写用数据库反而是一种负担,维护起来也重。
一个很容易踩的点是shared_preferences的写入不是即时的,快速连续操作时如果不加await,偶发会丢记录。我一开始没在意,连续换算时发现历史列表偶尔少了一条,排查了很久才定位到是异步竞争问题,改成await顺序执行后就稳定了。
4. 一套UI跑三端:页面结构与鸿蒙适配细节
4.1 页面整体划分
界面设计上,我没有刻意做复杂布局,遵循了Flutter惯用的结构:
- 底部导航三个Tab:换算、历史、设置;
- 换算页顶部是单位分类横向滚动条,下面是一个标准换算卡片;
- 历史页用ListView展示最近记录,长按可收藏;
- 设置页放深色模式开关、字体缩放、关于信息。
这套结构在Android、iOS、鸿蒙上跑出来的观感几乎一致,这正是Flutter自绘引擎的价值。但“几乎一致”不等于“完全一致”,平台差异主要藏在细节里。如果是Content-Type导向的页面布局,Flutter天然优势很大,但遇到平台专有交互习惯,还是得针对性做适配。
4.2 鸿蒙安全区和状态栏适配
这里必须单独讲一讲。鸿蒙系统的安全区策略和Android、iOS都有区别,尤其是在挖孔屏、胶囊键和底部导航条的处理上,Flutter默认的SafeArea通常只能覆盖最基本的padding,有些场景需要手动处理。
我常用的做法是监听系统状态栏高度变化,然后动态调整顶部留白:
final statusBarPadding = MediaQuery.of(context).padding.top; final bottomPadding = MediaQuery.of(context).padding.bottom;在单位换算大师里,换算卡片和底部导航之间需要保持足够间距,否则在部分鸿蒙设备上会出现“导航条遮挡内容”的问题。这个细节如果不上真机,光靠模拟器几乎发现不了,等上线后用户反馈就晚了。所以后来我的测试计划里专门加了一条:所有关键页面必须在真机上过一遍安全区检查。
4.3 深色模式与字体缩放
深色模式在Flutter里用ThemeData的brightness字段切换,逻辑本身很简单。但单位换算场景有个特殊性:大量展示数字和单位符号,颜色对比度直接关系到可读性。我最终确定的配色方案是:正文用主色加15的明度偏差,弱化文字用主色加40的明度偏差,背景色在深色下不用纯黑,而用偏蓝灰的深色,这样长时间看屏幕不会刺眼。
字体缩放也是一个容易被忽略的点。很多工具类应用在系统字体调大后布局直接崩,整行文字从卡片里挤出来。我当时的做法是给关键数字区设置最小字号,同时用Flexible和FittedBox控制文本溢出行为,保证用户在鸿蒙系统设置里把字体调到最大时,界面仍然可用。这个适配用真机来回测了好几次,才找到比较舒服的边界值。
4.4 同一套Widget在不同主题下的行为差异
最后提醒一句:Flutter里用Material控件时,部分自带语义在三端会有微妙差异,比如对话框、下拉菜单在Android和鸿蒙上的弹出样式几乎一致,但iOS上会用Cupertino风格。单位换算大师暂时用不到这些控件,但如果你后续扩展功能,要提前想好这套UI风格的分歧点。我的选择是统一采用Material Design语义,不强求在某一个平台上完全原生,这也是Flutter最舒服的开发姿势。
5. 与鸿蒙原生能力交互:MethodChannel实战
5.1 理解Flutter与鸿蒙的通信机制
Flutter与鸿蒙原生之间的通信,底层依然沿用Flutter的PlatformChannel机制。原理不复杂:Dart侧通过MethodChannel发一个方法调用,鸿蒙原生侧在自己的Ability或Service里注册对应的MethodCallHandler,收到调用后执行原生代码,再通过Result把数据回传给Dart侧。
对开发过Android和iOS插件的人来说,这套逻辑非常熟悉,唯一要学的是鸿蒙侧的注册位置和参数类型映射。鸿蒙侧常用的数据类型基本都能直接映射到Dart类型:字符串、数字、布尔、List、Map,以及它们的嵌套组合。需要注意浮点类型在跨语言传递时尽量统一用double,避免部分场景被转成int后精度丢失。我做压测时就发现过温度结果被截断的问题,最后查出来是原生侧返回了float,Dart侧解析时精度丢了。
5.2 调用鸿蒙图库选图
单位换算大师本身不需要选图,但我在做技术预研时专门跑通过这个场景,因为很多工具类应用后续都可能加“自定义背景”或“头像上传”功能。在鸿蒙侧调用图库,思路和Android类似:
- 在鸿蒙工程里调用PhotoViewPicker或对应API拉起图库;
- 拿到选中图片的URI后,通过MethodChannel的result返回给Flutter侧;
- Flutter侧再把URI转成ImageProvider显示。
这里有个坑:鸿蒙返回的URI不一定能直接被Flutter的Image.network加载。我当时的处理是让鸿蒙原生侧先把图片拷贝到应用缓存目录,再把文件路径传给Dart侧,用Image.file加载。这个方案绕开了URI权限和格式兼容问题,实测最稳妥。如果后续你的业务有类似需求,建议直接采用这个“中转缓存”思路,省得跟URI权限纠缠。
5.3 拉起鸿蒙IAP支付
应用内支付是另一个高频原生能力。Flutter侧不需要关心鸿蒙支付的完整逻辑,只需要封装一个MethodChannel调用,让原生侧处理支付流程。大致结构:
const paymentChannel = MethodChannel('com.example.unit_converter/iap'); Future<bool> purchase(String productId) async { final result = await paymentChannel.invokeMethod<bool>('purchase', {'productId': productId}); return result ?? false; }鸿蒙原生侧拿到productId后,调用支付SDK的拉起购买流程,再把结果通过Result回传给Dart侧。实际开发中需要注意的是:支付回调分为同步返回和异步回调两种,MethodChannel的result只能回一次,像支付这种异步操作,一定要在最终的异步回调里调用Result,不能在方法入口立刻返回一个“发起成功”的中间状态。
另外提醒一下,不管是图库还是支付,原生侧的异常处理一定要完整。跨语言调用出现异常时,Dart侧的Future会一直挂起,如果没做超时处理,用户可能看到界面“卡死”。我的习惯是Dart侧对每个MethodChannel调用都包一层timeout,并在catch里回退到默认状态。这样即使原生侧异常,用户也能看到友好提示,而不是一场静默的卡顿。
5.4 HAR、HSP模块化复用
在鸿蒙工程里,代码可以按模块拆成HAR(静态共享包)或HSP(动态共享包)。对我们这种Flutter工程来说,大多数业务代码都在Dart层,鸿蒙侧拆HAR/HSP的意义更多在于多应用复用原生桥接能力。比如同一个支付、图库的MethodChannel实现,如果未来有第二款Flutter应用也要上鸿蒙,直接把这个HAR包引用过去就能复用一套桥接代码,不用每个应用单独写一遍。
6. 打包上架:hap、hsp、har到底有什么区别
6.1 三种产出物定位对比
刚开始接触鸿蒙打包的人,很容易被hap、hsp、har绕晕。我整理了一张表:
| 类型 | 全称 | 用途 | 是否可独立安装 | 典型场景 |
|---|---|---|---|---|
| hap | HarmonyOS Ability Package | 应用安装包 | 是 | 交付给应用市场或直接安装 |
| hsp | HarmonyOS Shared Package | 动态共享包 | 否 | 运行时按需加载的公共模块 |
| har | HarmonyOS Archive | 静态共享包 | 否 | 编译期静态依赖的公共代码 |
对单位换算大师垂直场景来说,绝大多数情况下你只需要关注hap。hsp和har更多出现在大型应用中,用来拆分子业务或公共能力,减少主包体积。如果你的Flutter工程里没有额外拆鸿蒙原生模块,直接构建hap就够了。我见过有的新手在打包时非要手动做hsp,结果配了一堆依赖关系,反而把工程搞复杂了,其实完全没必要。
6.2 用Flutter工程产出hap的流程
在Flutter工程中构建hap,核心命令就是执行Flutter的构建指令,最终在鸿蒙工程目录下生成可安装的hap文件。实际操作中我习惯分两步:
- 先用Flutter命令构建Dart部分的产物;
- 再用DevEco Studio里的build按钮或命令行工具完成鸿蒙侧的资源打包、签名、生成hap。
这个流程比Android的assembleDebug要略重一些,但整个链路是通的。第一次打包时建议用debug包在模拟器或真机上验证一遍安装和启动,没问题再切release。如果release包启动闪退,优先查ProGuard混淆规则,Flutter引擎和鸿蒙桥接类往往需要额外的keep规则。
6.3 签名与上架前的检查
鸿蒙应用上架需要签名,签名分为调试签名和发布签名,发布签名需要对应的证书文件和密钥库,这些都是通过DevEco Studio的证书管理能力生成的。上架前还要注意:应用图标要按鸿蒙规范提供多种尺寸,应用名称要与包内声明一致,隐私说明要填写完整。
我在上架前检查时最常遇到的三个问题:
- 包名不匹配,导致签名校验失败;
- 未正确声明隐私权限,审核被打回;
- 版本号格式不符合应用市场要求,上传失败。
这些都属于一次性工作,配置一次第二版就顺畅了。建议在项目早期就按规范把包名和签名配好,别等到要上架才回头改,否则会牵出一堆连锁问题。尤其签名这块,密码和密钥库文件一定要备份好,团队里至少两个人知道位置,否则人员变动时很可能就得重新走一遍证书申请流程。
7. 实测阶段踩过的坑
7.1 第三方插件兼容性陷阱
Flutter生态里第三方插件多,但很多插件并没有同步适配鸿蒙分支。我实测时使用的一些热门库在鸿蒙上表现不稳定,报错信息千奇百怪,一开始以为是代码问题,排查半天才发现是插件原生层压根没实现鸿蒙的方法。有的插件报错甚至直接指向引擎内部,不把Dart侧调用栈翻到底层根本看不出来。
应对策略有三个层次:
- 优先选择纯Dart库,这类库完全跨平台,没有任何适配烦恼;
- 需要原生能力的库,先查它的鸿蒙适配issue,看维护者是否活跃;
- 实在没有替代方案,就自己写一个MethodChannel封装包,把原生能力桥接到自己的鸿蒙HAR里。
对于单位换算大师这类应用,前期把依赖控制得越轻,后期在三端的适配成本就越低。我最后实际用到的第三方库不超过十个,而且全是纯Dart或明确支持鸿蒙的,这给整个项目省了大量排查时间。
7.2 热重载偶尔断连
在鸿蒙真机上调试Flutter时,热重载(hot reload)偶尔会断连,改动Dart代码后界面不刷新。这种情况通常发生在:鸿蒙侧工程文件被DevEco Studio同步后,Flutter附加进程的连接句柄失效。一开始我还以为是代码写错了,反复保存重跑,后来发现和代码没关系。
我的临时应对办法是:
- 切回真机调试时,先主动杀掉App进程再重新拉起;
- 热重载失效时改用热重启(hot restart),大多数场景都能恢复;
- 如果还不行,断开USB重连,或者同时重启Flutter attach进程。
说到底,热重载本来就是开发期便利工具,在鸿蒙这种相对较新的开发环境上不稳定,属正常现象。关键是别因为这个浪费时间,我心里有了预期后,反而能更自然地切换调试方式,效率没受到太大影响。
7.3 性能优化与包体积控制
单位换算大师本身不重,但Flutter应用的包体积在鸿蒙上仍然值得关注。我最终做的一版release包,体积控制在一个比较健康的范围,主要用了三招:
- 开启Tree Shake Icon,只打包用到的Icon字体;
- 压缩图片资源,能矢量化的绝不用位图;
- 代码里避免无意义的全局状态对象,减少Dart侧的运行内存开销。
启动性能方面,单位换算这个页面元素少,基本不需要额外优化。但如果你后续想把换算历史做成大列表,建议用ListView.builder而不是一次性构建所有条目,实测在鸿蒙低端设备上差别非常明显。帧率方面,Flutter在鸿蒙上的表现已经比较接近Android,只要不做频繁的全局setState,不会有掉帧问题。
最后说点个人体会。Flutter跑鸿蒙这条路,整体走得通,但绝不是“零成本”的。它帮你省下了三套业务代码的维护量,代价是你必须接受一个新平台生态还不那么成熟的现实,特别是在第三方插件和调试工具链上,需要有一定的动手排查能力。
如果你和我一样,团队小、版本迭代快、又不想放过鸿蒙这个渠道,那么用Flutter做类似工具类应用,我认为性价比很高。我做完单位换算大师之后,已经把整套工程结构沉淀成了模板,下一款应用启动时,环境搭建和基础框架的环节能省掉不少时间。希望这篇记录也能帮你少踩几个坑。