做Flutter开发这些年,有一个体会越来越深:把一个你天天在用的三方库体系搬到另一个平台上,才是对“跨端”二字的极限测试。今天想聊的就是这个——我把pub.dev上非常常用的物理量与单位计算库quantity,完整移植到了鸿蒙系统的Flutter引擎上,让同一套Dart代码可以在鸿蒙设备上继续做长度、质量、温度、能量这些物理量的精准换算。整个过程涉及环境配置、源码依赖改造、编译踩坑和运行时验证,我都会摊开来讲。
这篇文章适合两类人:一类是正在把现有Flutter应用适配到鸿蒙、又恰好遇到业务里涉及单位换算和物理量计算的开发者;另一类是对纯Dart库如何在鸿蒙上存活感兴趣、想了解“移植一套三方库到底要动哪些地方”的人。我会把 quantity 的底层结构、依赖关系、鸿蒙侧的工程边界、以及我实际动手改造中的每一步都写清楚,尽量做到拿过去就能用。
1. quantity 库底层设计与移植难点
1.1 它凭什么成为物理量计算的常用脚手架
先说说 quantity 是干什么的。它是一个用 Dart 写的物理量计算与单位转换库,覆盖长度、质量、时间、温度、角度、能量、功率、压力、频率、速度等常见物理量,还支持货币汇率换算、复数量计算和格式化输出。在工程类App、物联网仪表盘、科学计算工具、健康监测和医疗类应用里,这类库几乎是刚需,因为业务代码里一旦出现“把用户输入的英里数转成公里”“把华氏温度转成摄氏温度”“把焦耳转成千瓦时”这些逻辑,手写换算不仅容易出错,而且每个单位还要维护一套系数表,非常繁。
使用体验很直接:
import 'package:quantity/quantity.dart'; final distance = Length(meters: 10.0); final miles = distance.to(Unit.miles); print(miles.value); // 输出 0.006213711922373339声明一个 Length 对象,调用 to 方法转到目标单位,数值就出来了。底层没有魔法,它的设计思路是“一个物理量一个类”:Length、Mass、Time、Temperature 这些类都继承自 Quantity 基类,每个单位被封装成 Unit 对象,单位之间的换算关系通过量纲定义和换算系数计算出来。这样做的好处是类型安全——你不能把 Length 直接赋值给 Mass,编译器就会拦住你,这在科学计算领域特别重要。
不过,这个库能跑得这么舒服,是建立在完整依赖链条上的。它依赖 intl 做数字和区域的格式化,依赖 hive 做货币汇率的本地缓存。这个依赖关系看着不起眼,搬到鸿蒙上就变成了第一个坎。
1.2 源码级拆解:part/part of 组织方式与依赖关系
quantity 的源码组织方式比较特殊,它大量使用了 Dart 的part / part of机制。简单解释一下这个机制:Dart 里一个 library 可以拆成多个文件,其中一个文件用library xxx;声明库名,再用part 'xxx.dart';引入其他文件,被引入的文件顶部写part of '主文件.dart';。这些文件共同组成同一个 library,互相之间可以直接访问私有成员。
quantity 这么做是为了把庞大的代码按职责拆开,比如量纲定义、单位定义、格式化、货币逻辑各占一个 part 文件。但这种组织方式给移植带来了一个很现实的问题:你要裁剪或替换其中任何部分,都必须把整套 part 文件一起处理好,单独删一个文件就会导出“part of ... is not included in any library”的编译错误,连带引用关系全部断裂。很多人在适配时报错,第一反应是“我代码写错了”,其实只是 part 文件被遗漏了。
依赖方面更麻烦。intl 是纯 Dart 包,鸿蒙适配相对省心,只要版本约束一致就能直接用。hive 就不一样了,它为了做到高性能,除了 Dart 层还包含本地原生实现,在 Android/iOS 上通过各平台的 FFI 或原生代码完成存储。鸿蒙上如果没有对应的原生适配,编译会直接报找不到底层实现。这颗雷不拆掉,quantity 的货币换算功能就会成为整个移植的爆破点。
1.3 精度、格式化和科学严谨的边界
标题里说“极致精准、科学严谨”,这不是写文案,而是移植任何科学计算类库时都必须较真的部分。quantity 底层的数值用 num/double 表达,double 本身对小数位有限制,做长距离单位的连续换算时,误差会累积。比如从英寸转到千米,再转回英寸,往返一次可能丢掉毫厘级别的精度。在正常的业务里这无所谓,但在工程测量、航空航海、医疗剂量计算这些场景,几个小数点后数字的偏差是能出大事的。
所以移植时要做的不是把源码搬过来就行,还要把格式化能力一起验证好。quantity 的格式化依赖 intl 的 NumberFormat,针对不同 locale 会输出不同的小数点符号和千分位分隔符。鸿蒙设备上如果 locale 数据没走对,格式化结果会莫名其妙地多出逗号或者用错小数点符号。这一点我在后面的实操里会专门说怎么排查。
2. 开工前想清楚的三套适配方案与环境配置
2.1 鸿蒙 Flutter 的工程边界与插件通道
在动手之前,先把鸿蒙 Flutter 的工程边界摸清楚。鸿蒙上跑 Flutter,用的不是标准 Flutter SDK,而是 OpenHarmony 分叉的 flutter_flutter 分支,工程结构里会多出一个ohos目录,这个目录就是鸿蒙原生侧,承载 ArkTS 与 Flutter 引擎的通信和生命周期管理。用flutter create生成工程时,如果支持鸿蒙平台,会看到--platforms=ohos这个选项,之后编译和运行都走 hvigor 构建链路。
原生的通信方式跟 Android/iOS 类似,有 MethodChannel、EventChannel,还有基础消息通道。MethodChannel 适合一次性的双向调用,比如调用一个原生的汇率接口;EventChannel 适合持续推送,比如监听传感器数据或系统时间变化。quantity 本身的库代码不太用这些通道,但作为适配工程师你要明白这些通道的接线方式,因为如果要给 currency 功能接一个实时汇率源,很可能需要原生侧通过 EventChannel 把数据流式推给 Dart 侧。
2.2 三套方案对比:全量 fork、overrides、特性裁剪
针对 hive 和 intl 这两个依赖,现实中的适配方案可以分成三类,我列个表对比一下:
| 方案 | 做法 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 全量 fork | 把 quantity 源码拉到本地仓库,直接改 pubspec 和源码 | 改动可控,完全定制 | 需要自己维护与上游的同步 | 对 currency 和 formatting 有深度定制需求 |
| dependency_overrides | 保留 quantity 原包,用 overrides 替换 hive 等传递依赖 | 不动 quantity 主源码,升级上游版本时省心 | 被替换的包必须具备同样的 API 形状,否则照样编译失败 | 只是想绕过 hive 原生缺失问题 |
| 特性裁剪 | 关闭 currency converter 相关代码路径,不初始化 hive | 改动最小,跑通核心物理量计算最快 | 货币换算功能缺失,要后续补 | 业务里不需要汇率换算的场景 |
我最终选择的是“全量 fork + overrides 兜底”的组合:先 fork 一份 quantity 到自己私有仓库,同时把 hive 这个依赖彻底换掉,用一个小型自实现缓存替代,而不是强行去编译 hive 的原生部分。原因也很简单——hive 在鸿蒙上没有官方预编译库,强行适配等于我再写一个原生模块,成本和风险完全不可控,但 quantity 里真正用到 hive 的地方只有货币汇率的缓存读写,这部分对纯 Dart 的物理量计算毫无影响。裁掉它,库的核心价值不受损。
2.3 前期环境配置清单
动手之前,环境必须先准备好。鸿蒙 Flutter 开发环境有几个关键组件,缺一不可:
- DevEco Studio(建议 5.0 以上版本),用于打开和编译 ohos 侧的工程。
- OpenHarmony SDK,对应你目标设备的 API 版本,编译时 hvigor 会校验版本匹配。
- OpenHarmony 分叉的 flutter_flutter SDK,把它配置到 PATH 里,所有 flutter 命令都用这个分支来跑。
- 一个真实设备或者模拟器,鸿蒙的本地调试最好用设备,模拟器的文件路径和传感器行为跟真机有差异。
配置完之后,用下面这条命令验证环境是否正常:
flutter doctor -v如果有报错,优先排查 SDK 路径和 licence 是否接受。我习惯再跑一条flutter run -d <device-id>空跑一个 hello world 工程,确认基础链路通了再开始移植。这条路看起来绕,但能帮你把“环境问题”和“代码问题”分开,后面出错时排查范围能缩小一半。
3. 实操步骤:把 quantity 改造成鸿蒙可用版本
3.1 引入依赖并把版本钉死
我采用本地 fork 的方式,先把 quantity 源码克隆到项目的third_party/quantity目录下,然后在主工程的 pubspec.yaml 里用 path 方式引入:
dependencies: quantity: path: third_party/quantity intl: ^0.19.0这里有个细节:用 path 而不是 git 依赖,是为了后面调试时可以直接改源码,不用每次 push 到远端再拉取。但坏处是,后续升级上游版本时要手动同步。所以我在 fork 仓库里保留了一个vendor注解,记录 fork 自哪个 tag,方便以后做 diff。
如果用 git 依赖,写法是这样的:
dependencies: quantity: git: url: https://your.git/quantity_fork.git ref: harmony两种方式都可以,我个人的建议是:如果你只想快速看效果,用 path;如果你想长期维护一个鸿蒙 fork 分支并让团队共用,用 git 私有仓库。
3.2 用 60 行代码替换 hive 缓存层
这是整个适配中最关键的一步。quantity 的货币汇率缓存逻辑会调用 hive 的接口,一般是这样:
import 'package:hive/hive.dart'; final box = await Hive.openBox('currency_cache'); await box.put('USD/EUR', 0.92); final rate = box.get('USD/EUR');它真正用到的 API 就那么几个:openBox、put、get、delete、close。到这里就简单了,我们完全可以写一个最小实现,让编译器和运行时都满意:
import 'dart:collection'; class MiniHiveBox { MiniHiveBox._(this._name); final String _name; final Map<String, dynamic> _store = HashMap<String, dynamic>(); static final Map<String, MiniHiveBox> _boxes = {}; static Future<MiniHiveBox> openBox(String name) async { return _boxes.putIfAbsent(name, () => MiniHiveBox._(name)); } Future<void> put(String key, dynamic value) async { _store[key] = value; } dynamic get(String key) => _store[key]; Future<void> delete(String key) async { _store.remove(key); } Future<void> close() async {} static Future<void> clear() async { _boxes.clear(); } }这套实现没有持久化,重启后汇率缓存会丢,但足以让 quantity 的代码路径完整跑通。如果你的业务确实需要缓存持久化,可以在这个类里加上文件序列化,用鸿蒙侧的文件目录把 map 写成 JSON,读取时再反序列化回来,实现逻辑也不复杂。
替换方式有两种:要么直接改 fork 源码里所有import 'package:hive/hive.dart'为import 'mini_hive_box.dart',要么用 dependency_overrides 把 hive 替换成自定义包。我实际操作时选择直接改源码,因为 number 函数引用点不多,改动量小,而且可以顺手检查是否有遗漏的 hive API 调用。
3.3 修复 part 文件缺失与 intl 版本冲突
替换完 hive,接下来就是编译期的硬仗。我遇到的第一类报错就是 part 文件问题:
Error: part of 'src/quantity.dart' is not included in any library Target of URI doesn't exist: 'package:quantity/src/unit.dart'这个报错几乎都是在修改源码结构时,导入了不完整 part 文件导致的。quantity 源码里,主文件用 part 引入多个子文件,只要有一个文件路径写错或忘了带上引用,整条链就断掉。我的排查办法是:打开主文件的part列表,逐个打开对应的 part 文件,确认每个文件第一行的part of指向的主库名一致。用一个 grep 就能快速检查:
grep -rn "part of" third_party/quantity/lib/src | head -20第二类报错是 intl 版本冲突。quantity 某个 tag 下声明的是intl: ^0.19.0,而你的主工程里其他依赖可能已经把 intl 拉到了 0.20 以上。Dart 的版本约束是双端都要满足,结果就是编译报版本求解失败。解决办法有三个:第一种,在 pubspec.yaml 里用 dependency_overrides 强制 intl 回退;第二种,升级 quantity fork 里的 intl 上限,跑一下静态检查确认 API 兼容;第三种,如果只是个别方法变了,可以在 fork 里做个薄包装层。
dependency_overrides 的写法:
dependency_overrides: intl: 0.19.0我个人更推荐第二种,因为直接把上游的下限抬高到一个能同时满足主工程和周边依赖的版本,长期看更干净。当然,升级之后要跑一遍单测,确认 NumberFormat 的行为没变化。
3.4 用单元测试验证移植后的计算精度
编译通过只是开始。把库搬到新平台,必须用测试兜底,否则一个换算系数抄错或者一个常量被误改,都会在用户端酿成数据事故。我建了一个专门的测试文件,覆盖最常见的几个物理量:
import 'package:flutter_test/flutter_test.dart'; import 'package:quantity/quantity.dart'; void main() { test('length: meters to miles', () { final meters = Length(meters: 10.0); final miles = meters.to(Unit.miles); expect(miles.value, closeTo(0.006213711922373339, 1e-12)); }); test('temperature: celsius to fahrenheit', () { final celsius = Temperature(celsius: 25.0); final fahrenheit = celsius.to(Unit.degreesFahrenheit); expect(fahrenheit.value, closeTo(77.0, 1e-9)); }); test('energy: joule to kilowatt-hour', () { final joules = Energy(joules: 3600000); final kwh = joules.to(Unit.kilowattHours); expect(kwh.value, closeTo(1.0, 1e-9)); }); test('mass: kg to lb', () { final kg = Mass(kilograms: 1.0); final lb = kg.to(Unit.pounds); expect(lb.value, closeTo(2.2046226218, 1e-9)); }); }这些测试里我特意用了closeTo而不是精确相等,因为浮点运算天然存在误差,你要验证的是“误差在可接受范围内”,而不是“完全等于某一个小数”。这也呼应了前面说的科学严谨——任何物理量库的移植,都要以数值误差作为验收指标,而不是肉眼看着差不多就放行。
4. 运行时踩坑记录与排查速查表
4.1 三个最容易炸的运行时错误
编译过了,测试也过了,但装到鸿蒙真机上跑仍然可能翻车。我总结三个最容易炸的点:
第一个是空安全崩溃。启动后如果走了货币换算逻辑,容易报“Null check operator used on a null value”。原因在于我替换了 hive 之后,原本由 hive 初始化的某个对象没有被赋值。这类错误在 Android 上不会出现,是因为 Android 上 hive 原装可用,缓存对象自然能初始化;鸿蒙上换成 MiniHiveBox 之后,某些全局的初始化顺序变了。解决办法是找到报错的堆栈,定位到那个对象,在调用之前显式初始化,或做一个兜底默认值。
第二个是 locale 数据缺失。鸿蒙系统某些版本的区域信息路径跟 Android 不完全一致,intl 在初始化时会拿不到正确的 locale 数据,导致NumberFormat输出异常。表现是单位转换结果对,但格式化后的字符串多出奇怪的分隔符。排查时先打印当前 locale,再用initializeDateFormatting手动初始化区域数据。
第三个是缓存不更新。因为我做的 MiniHiveBox 不持久化,App 重启后汇率缓存必然失效。如果外部逻辑假定“存过就一定能读到”,就会出现读到 null 的边界情况。解决方式是测试时主动清缓存,或者把缓存读写用 try/catch 包起来,失败时降级到默认汇率。
4.2 从一片红到跑通:我的排查路线
实际调试时有一个笨但有效的方法,就是按“编译 → 启动 → 功能调用”三段回溯。第一段只看编译报错,先解决 part 文件和依赖版本问题;第二段把 App 启动到首页,看有没有初始化崩溃;第三段才去点触发物理量计算的按钮,看具体功能能否出数。每段都写一条日志到控制台,比如:
debugPrint('[quantity] cache ready, entries=${box.length}');这条日志能告诉你在哪个环节断了。我最初移植时,第一段花了两小时,第二段半小时,第三段一小时,其中一半时间都耗在定位一个初始化顺序问题上。后来学乖了,所有全局对象都在main()里显式初始化,不让库自己猜。
4.3 鸿蒙特有边界:字符、路径、平台通道
除了上面的通用问题,鸿蒙还有几个特有边界要留意。文件路径就是其中之一,鸿蒙上应用沙箱目录跟 Android 的/data/data/不完全一样,如果你决定扩展 MiniHiveBox 做持久化,一定要用path_provider的鸿蒙适配版获取目录,不要硬编码路径。硬编码的路径在 Android 上能跑,在鸿蒙上大概率直接找不到目录。
字符集也要注意。Dart 的字符串在鸿蒙引擎上走的是 UTF-16,但 ArkTS 侧某些部件可能用 UTF-8 处理文本,跨通道传递带有特殊符号的单位符号时,偶尔会出现乱码。遇到这种情况,在通道层统一编码,不要依赖系统默认行为。
如果你要给 currency 功能接入实时数据,还要注意 EventChannel 的调用时机。Dart 侧receiveBroadcastStream在监听之前要确保原生侧 already 在发消息,否则会丢消息。我一般会在原生侧做幂等发射,Dart 侧做消息序号去重,避免 UI 上汇率跳动。
4.4 问题速查表
把这次踩过的坑汇总成一张表,方便以后照着查:
| 错误信息 | 原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| part of ... is not included in any library | part 文件引用断裂 | 检查所有 part 文件的part of指向 | 补齐缺失文件,统一库名 |
| Target of URI doesn't exist | 源码里 import 路径失效 | 检查包配置和文件路径 | 修改 import 路径或重新放置文件 |
| Version solving failed ... intl | 版本约束冲突 | 查看依赖树 | 用 dependency_overrides 或升级 fork 内约束 |
| Null check operator used on a null value | 初始化顺序或缓存缺失 | 打日志定位空对象 | main 函数显式初始化,兜底默认值 |
| NumberFormat 输出多余分隔符 | locale 数据缺失 | 打印 locale 和格式化结果 | initializeDateFormatting 手动初始化 |
| EventChannel 收不到消息 | 时序问题 | 检查发送端是否提前发射 | 原生侧幂等发射,Dart 侧序号去重 |
我始终认为,一张能直接照着做的排查表,比写一段成功感言有用得多。
5. 关于这次移植,我最后的几点体会
我个人在实际操作中的一个体会是:越是想“快速适配”,越容易在后期补课。资金和人力都紧张的情况下,人们很容易选择直接塞一个原版 quantity 进工程,等编译爆了再救火。但我建议先花半天把依赖图画清楚,哪怕只是手写一张小表,也能省下后面几天的调试时间。
还有一个值得分享的小技巧:保持 fork 分支和上游主干定期同步。quantity 上游更新频率不算高,但每次更新都会修一些边角 bug,比如某个单位符号拼写错误、某个换算系数更精确。同步方法很简单,在 fork 仓库里跑一次 merge,然后跑单测,只要我的四组基准测试全绿,我就放心合入。这个动作已经帮我避过两次上游 bug——不是被修复,而是及时发现上游改挂了我依赖的 API 签名。
最后说一句实在话:鸿蒙化适配对纯 Dart 库来说,真正的风险不在语法,而在生态依赖。hive、intl 这些看似不起眼的间接依赖,才是工作量的大头。如果你手里也有类似的纯 Dart 库要搬,优先把依赖树里所有涉及原生能力的包标红,逐个确认鸿蒙替代方案,再开始写真正业务代码。先把路蹚平,再开车,永远比边开车边铺路稳当。