☰
鸿蒙 Flutter 中 fixnum 库的适配:64 位整数精度丢失解决方案
2026/10/6 14:14:39 网站建设 项目流程

前段时间有同事在鸿蒙设备上跑一套 Flutter 项目时,碰到一个特别诡异的问题:后台接口返回的设备编号,在 Android 上显示完全正常,换到鸿蒙真机上就末尾几位开始跳动,日志里看起来还没有任何异常。排查到最后,问题就出在 64 位整数被当作普通 JS 数字跨端流转,精度悄悄丢了。做过金融业务、做供应链系统、做物联网设备接入的同学,大概率都踩过这种坑。这也是我这次决定把 fixnum 的鸿蒙化适配单独写一篇的原因,它不是一个需要改源码的三方库,但如果你不知道它的边界在哪,会在 64 位算术、溢出安全和跨端通信上吃大亏。

fixnum 是 Dart 语言里专门处理 64 位有符号/无符号整数的类库,核心提供了 Int64 和 Uint64 两类对象。Flutter 项目跑上鸿蒙以后,Dart VM 和原生 ArkTS 侧的数值语义并不完全一致,尤其在跨端通信或者 WebView 场景里,普通数字很容易退化成 IEEE 754 双精度浮点数。这篇内容适合正在做鸿蒙 Flutter 应用迁移、又需要处理时间戳、金额、位运算、协议字段的开发者,我会从为什么丢精度、怎么用 fixnum、怎么在鸿蒙通道里安全传输,再到金融级计算实战,把思路一次性捋清楚。

1. 为什么 Flutter 和鸿蒙一碰到 64 位数字就出问题

很多人刚开始不理解:Dart 的 int 不也是 64 位的吗?鸿蒙的 ArkTS 不也支持数字类型吗?为什么还要单独拿一个库出来讲。这里的问题不在于“有没有 int 类型”,而在于不同运行环境对数字的表示上限完全不同,尤其是跨端通信时会自动走 JSON 或者标准消息编解码,一编一解,精度就崩了。

1.1 前端世界里的“半个整数”

在原生 Dart VM 上,int 确实是 64 位有符号整数,范围可以到 9.22e18 左右。但问题在于:Flutter 跑在鸿蒙上,不可避免地要和 ArkTS 侧打交道,比如 MethodChannel、EventChannel,或者通过 JavaScript 引擎做混合渲染。ArkTS 遵循的是 ECMAScript 规范,里面只有一种数字类型,就是双精度浮点数,安全整数范围只有 2 的 53 次方减 1,也就是 9007199254740991。

超过这个值以后,连续整数之间就出现了“空隙”,比如 9007199254740992 和 9007199254740993 在双精度浮点里可能被解析成同一个数。把 64 位整数的低 12 位丢掉还算轻的,碰上 64 位完整范围,末尾几位直接变成随机变化。设备 ID、订单号、时间戳纳秒值、低频通信里的序列号,几乎全是重灾区。

所以不要把 fixnum 当成一个“增加内存开销的花架子”,它真正解决的是环境边界问题:不管底层是 Dart VM、Web 引擎还是鸿蒙 ArkTS,只要你显式使用 Int64 类型,计算和转换都不会因为运行环境不同而改变语义。

1.2 ArkTS 与 Flutter 通信时的精度陷阱

鸿蒙 NEXT 生态里,Flutter 和 ArkTS 之间的通信通常要经过消息序列化。Dart 侧如果直接发一个 int,平台侧收到以后往往会转成 JS Number,然后再做处理。你以为你传的是 64 位整数,实际上对方拿到的是 double。反过来也一样,ArkTS 侧返回一个超出安全范围的整数,Flutter 侧收到的也不再是准确数值。

在这个场景里,单纯的“类型转换”解决不了问题。你得在传输协议层面做设计:要么把 64 位数字拆成两个 32 位部分,要么直接转成字符串按文本传,要么用字节数组按固定大小端编码。fixnum 的价值就在于,它给了你稳定的二进制表达和解析能力,让这些跨端方案有统一的落点。后面我会给出一套可以直接用的传输格式。

2. 认识 fixnum:它到底给我们封装了什么

fixnum 最早是 Google 的 Dart 团队为 Protocol Buffers 准备的,因为 protobuf 里的 int64、uint64、sint64 字段必须对应到固定 64 位语义。后来因为太好用,很多非 protobuf 项目也拿它来做精确计算。它的核心内容并不复杂,但每一层都有讲究。

2.1 Int64 和 Uint64 的内部表示

不知道为什么,很多人以为 fixnum 是把 64 位拆成了两个 32 位 int 来存储。早期版本确实是这个思路,但现在的 fixnum 在 Dart VM 上直接用原生 int 存储,同时用算法去保证 64 位截断、符号扩展和溢出规则。这样做的优势很明显:计算路径短,性能比拆字段模拟高得多。

对外暴露的就两个核心类:

  • Int64:有符号 64 位,范围从 -9223372036854775808 到 9223372036854775807。
  • Uint64:无符号 64 位,范围从 0 到 18446744073709551615。

这两个类都实现了 Dart 的Comparable、hashCode、operator等接口,你可以像使用普通数字一样做加法、减法、乘法、除法、取模、位运算、比较、序列化。唯一要注意的是,它们本质上还是对象,不能完全替代基础类型。

2.2 谁在实际项目里依赖 fixnum

最典型的是 protobuf 生成的 Dart 代码。你可以打开一个 Flutter 项目看,只要pubspec.yaml里有protobuf依赖,几乎一定会带上fixnum。因为.proto文件中的int64字段生成后默认就是Int64类型,转换函数里也会调用 fixnum 的方法。

第二个典型场景是跨平台文件格式解析。比如你解析数据库文件、日志文件、影像头文件,里面经常有 8 字节的长整型字段,用 Dart 原生 int 去读 ByteData 很容易出现符号扩展问题,用 Int64 配合字节操作就干净很多。

第三类就是金融计算。金额字段用分、厘甚至微做单位以后,累加、拆账、计息很容易突破 53 位安全整数,这时候 fixnum 不是“锦上添花”,而是“唯一选择”。

3. fixnum 在鸿蒙项目里的适配实操

很多人看到“鸿蒙化适配”几个字,会本能地觉得要改源码、改编译配置。实际上 fixnum 是纯 Dart 实现,不依赖dart:io、不依赖 ffi、不依赖 PlatformView,所以它在鸿蒙 Flutter 环境下天然可以编译。真正的适配工作集中在:依赖引入、代码替换、跨端传输方案三个层面。

3.1 纯 Dart 库为什么能做到零成本迁移

这可能出乎你意料,但从鸿蒙 Flutter 工具链的角度看,只要一个包只用了dart:core级别的 API,它就能直接编译进鸿蒙产物。fixnum 正是这种“干净”的三方库,它没有原生插件、没有平台通道、没有任何反射调用。

所以我在做适配时,第一步其实只是把依赖写进pubspec.yaml:

dependencies: flutter: sdk: flutter fixnum: ^1.1.0

然后执行flutter pub get,跑一遍编译,确认鸿蒙构建链路上没有报 MissingPluginException 之类的问题。如果这一步通过了,后面就是纯代码层的替换和适配工作了。

真正需要花心思的,是你项目里到底哪些地方用了可能超过安全范围的整数。我的习惯是先在项目里全局搜索这几种模式:DateTime.now().microsecondsSinceEpoch、id.toInt()、接口返回的dynamic转 int、还有ByteData.getInt64的调用点。每一个都值得问一句:这个数值会不会超过 9007199254740991。

3.2 代码替换:从 dart int 到 Int64 的迁移要点

替换不是简单地把int改成Int64就结束。Int64是对象,不能用+、-这些运算符直接和普通 int 混算,必须显式调用方法或者通过构造函数转换。

我给你一个实战中很好用的替换模板:

import 'package:fixnum/fixnum.dart'; // 以前写法 int deviceId = json['device_id']; int nextId = deviceId + 1; // 替换写法 Int64 deviceId = Int64.parseInt(json['device_id'].toString()); Int64 nextId = deviceId + Int64.ONE;

这里的Int64.ONE、Int64.ZERO是库里预定义的常量,能省去重复构造对象的开销。在需要和普通 int 混用的地方,再用.toInt()转回来,但要非常小心,如果数值已经超过 53 位安全范围,toInt()在 Web 或鸿蒙 ArkTS 侧又会产生误差。

更推荐的做法是:金融计算全程用 Int64,所有加法、乘法、比较都不落回 int,只有到了展示层,才把 Int64 转成字符串交给 UI 渲染。转字符串这一步没有精度损失,而且显示的效果也更符合金融业务要求。

3.3 跨端传输方案:鸿蒙和 Flutter 之间怎么传 64 位数字

这是整个适配里最有含金量的一部分。如果你的 Flutter 鸿蒙应用只是单端跑 Dart 逻辑,不跟 ArkTS 通信,那 fixnum 的作用主要是防内部溢出。但现实情况是,很多应用要接入鸿蒙的推送、支付、安全能力,或者要从原生侧拿设备信息,跨端传 64 位数字几乎是绕不开的。

我们团队最后沉淀出一套比较稳的传输格式,核心原则是:绝对不在跨端 JSON 里传超过 2^53 的数字对象,统一用字符串或字节数组。

如果你走 MethodChannel,我推荐字符串方案,因为可读性好、排查方便:

// Dart 侧发送 final Int64 value = Int64.parse('123456789012345678'); final Map<String, Object> args = {'value': value.toString()}; await channel.invokeMethod('sendInt64', args);

ArkTS 侧接收以后,用BigInt(value as string)转成 ArkTS 的 bigint 处理。反过来,ArkTS 侧要回传大整数时也先转字符串:

// ArkTS 侧返回 let big = BigInt("123456789012345678"); return { "value": big.toString() };

如果你要传高频数据,比如每帧都有时间戳,字符串方案会有额外的编解码开销。这种场景建议用固定 8 字节的字节数组,Dart 侧按大端序写入,ArkTS 侧用 DataView 读取。两种方案各有利弊:

方案优点缺点适用场景
字符串可读性好、跨语言兼容性最强编解码有开销、数据量变大低频调用、接口调试
字节数组体积小、解析快、无语义歧义需要统一字节序、可读性差高频流、协议字段传输
拆两个 int32兼容老旧 JSON 结构容易漏转换、代码冗余老系统接口改造

无论选哪种,我都建议在 Dart 侧封装一个工具类,把 Int64 的序列化和反序列化收敛到同一个文件里。这样以后一旦要换传输方案,只改一个文件。

4. 溢出安全与金融级高精度计算实战

fixnum 这个名字听起来很底层,但它在业务代码里最大的价值其实是“兜底”。现代金融系统、电商计费系统、供应链结算系统,只要有一行代码没考虑到溢出安全,线上就有可能出现金额对不上的事故。下面我会用几个实操场景,把溢出安全和位运算这部分讲透。

4.1 先看一次典型的溢出事故是怎么发生的

假设你在做积分系统,用户积分余额来自服务端,类型是 int64。Dart 侧拿到以后默认存成int,在 Android 原生 VM 上跑没问题,因为底层确实能表示 64 位。但只要你把积分值传给鸿蒙的 ArkTS 侧做一次动画展示,或者经过一次 JavaScript 引擎,数值就可能开始“漂移”。

更隐蔽的是本地计算溢出。比如你写:

int totalMicros = duration.inMicroseconds * count;

如果count比较大,duration.inMicroseconds又在千万级别,乘积完全可能超过 2^53。单看 Dart VM 还是 safe,因为 64 位 int 还没满,但一旦这个值被 JSON 序列化,或者被某个只认双精度浮点的组件消费,精度就开始坏了。这种 bug 特别难复现,因为你本地调试可能没事,到了真机或者特定数据量级就偶现。

用了 fixnum 以后,你会强制自己把所有可能超界的整数都放进 Int64 的语义里。Int64 * Int64的结果会做 64 位截断,符号位、溢出位都按照补码规则走。虽然生产环境里我们不希望溢出真的发生,但至少行为是确定的。

4.2 金融计算里 Int64 的标准写法

在金融项目里,金额的常规做法是用最小货币单位。人民币就是分,如果涉及更细的计价,有的系统会用到厘甚至微。假设你做一个年化收益计算,本金是 1000000.00 元,存成分为Int64(100000000),年化利率是 3.65%,你按天计息,一天的利息就是:

Int64 capital = Int64(100000000); // 单位:分 Int64 annualRateBp = Int64(365); // 3.65% 用万分之 365 表示 Int64 days = Int64(1); // 利息 = 本金 * 年利率 / 365 / 10000,注意放大倍数防丢失 Int64 interest = (capital * annualRateBp * days) ~/ Int64(3650000);

这里有个细节:中间计算会先放大再缩小,如果不小心先除以 10000 再乘 365,整数除法直接丢掉小数部分,金额就差出来了。我建议所有利率、费率都用“万分之”“十万分之”这种定标整数表示,整个链路都用 Int64,最后一步再换算成 UI 展示字符串。

封装展示层时,可以这样保留精度:

String formatFenToYuan(Int64 fen) { final yuan = fen ~/ Int64(100); final jiao = (fen % Int64(100)) ~/ Int64(10); final li = fen % Int64(10); return '$yuan.$jiao$li'; }

用 Int64 做完除法和取模以后,toString()不会出现浮点尾巴,也不会有科学计数法。这一点在账单明细、报表导出、对账系统里尤其重要。

4.3 位运算和位移的正确姿势

位运算场景里,fixnum 同样提供了和 C 语言一致的行为。shl、shr、and、or、xor这些方法都有,使用时要注意操作数类型。比如你要从一个 uint64 字段里取出高 32 位和低 32 位:

Int64 value = Int64.parse('0x1234567890ABCDEF'); Int64 high = value >> Int64(32); Int64 low = value & Int64(0xFFFFFFFF);

这里的>>运算符是 fixnum 重载过的,移位位数要用 Int64 类型,不能直接传普通 int。这一点最容易被新手忽略,编译器通常也会给提示,但如果你写了一堆二进制的标志位判断,可能一时半会儿注意不到。

如果你要处理的是无符号语义,记得使用Uint64。例如解析网络协议里的长度字段,无符号数最高位不是符号位,如果错误地用 Int64 去解释,超过 2^63 的数值会变成负数,后面所有比较逻辑都乱掉。

5. 我在鸿蒙化适配过程中踩过的坑

这部分是我最想分享的。很多坑不是 fixnum 本身的问题,而是鸿蒙 Flutter 混编环境带来的。下面几条经验,每条都是我花实际时间填出来的。

5.1 随手 toInt() 导致精度二次丢失

我见过最典型的错误是:费了半天劲把数据转成 Int64,算完以后又因为“要返回给原生”或者“要传给某个旧模块”直接.toInt()。如果这个值已经超过 53 位安全整数,这一步就把修复全毁了。

凡是要跨端的数据,一律不落回 int,保持 Int64 或者 String。实在要转回普通 int,先判断value > Int64(9007199254740991),超出就直接抛异常或走字符串方案,绝不做静默截断。

5.2 字节序不对导致数据全乱

字节数组方案里,Dart 侧默认用大端序写高位在前,如果 ArkTS 侧用了小端序去读,传出来的数完全是另一个值。我建议统一在注释里写明“Big Endian”,并且出一个 8 字节的黄金测试用例,比如0x0102030405060708,两边打通以后再跑业务数据。

我在实际项目里专门写了一个发送端测试方法,把固定字节发过去,ArkTS 侧转成字符串回传,两边比对原值。这套验证流程不是可有可无,是每天的 CI 都要跑。

5.3 日志里出现 E/flutter 31173 之类的报错

鸿蒙 Flutter 跑起来以后,日志系统有时会输出和 Dart VM 初始化相关的错误,比如E/flutter (31173)或dart_vm_initializer.cc开头的信息。很多人一看到就以为和 fixnum 有关,其实大部分是引擎侧的基础环境问题,或者是某个插件在鸿蒙上没找到原生实现。

排查思路是:先把 fixnum 相关的代码注释掉,看错误是否消失;如果还在,说明问题在插件注册或引擎配置链路。千万不要因为一个错误码本身长得吓人就怀疑库有问题。fixnum 是纯 Dart 逻辑,它本身不产生这种引擎级日志。

5.4 大数字在 JSON 里被科学计数法显示

有些日志系统会把 double 自动转成科学计数法,比如1.2345678901234568e+18,一眼看过去完全不像整数。此时别慌,先确认数据是不是已经从 Int64 变成 double,再查是不是在 JSON 序列化之前做了隐式转换。

我建议所有接口模型里,64 位字段都用String或者Int64接收,不要用dynamic。用dynamic意味着把类型判断交给了运行时的实现细节,这在鸿蒙混合环境里等于放弃精度控制权。

6. 性能优化:什么时候该用,什么时候别用

虽然 fixnum 用起来很好,但它不是“银弹”。Int64 是对象,每次运算都会产生新的实例,在高频循环里如果滥用,GC 压力和内存分配量都会上去。我的经验是:

  • 金额、费率、ID、位数等业务核心字段:用 Int64,安全第一。
  • 计数器、数组下标、短循环里的临时变量:用原生 int,性能第一。
  • 高频发送的时间戳:优先用 Int64 固定格式,但避免在循环里反复parse和toString。

如果你要连续处理十万个 Int64 的加法,建议把数据放进列表后一次性批量计算,或者提前.toInt()到安全范围内再算。从实际压测看,fixnum 的四则运算比原生 int 慢一个数量级左右,但业务系统里大部分场景不是计算密集型,慢的那点时间完全可以接受。真正的性能杀手是你为了“保险”而把每一个数字都包一层 Int64,导致整个链路里创建了大量临时对象。

再补充一个优化点:Int64.parseInt在解析超长字符串时相对较重,如果你能从底层拿到字节数组,优先用Int64.fromBytes或者先读取 ByteData 再构造。这种写法在解析二进制协议时能省掉一次字符串拷贝。

7. 把 fixnum 的适配能力沉淀成团队规范

说到最后,我想强调一个观点:fixnum 的鸿蒙化适配不是靠一两个文件改完就结束的,它应该变成团队里的约定。我在项目里会要求所有涉及外部接口的 64 位字段都使用明确类型,禁止直接写裸int接大数字;跨端传输禁止直接塞数字,必须走字符串或字节数组封装;所有金额字段默认以最小单位 Int64 存储,展示层统一格式化。

另外我会在代码评审里专门检查一种反模式:把 Int64 和 int 混合运算却不做转换。很多编译器只能做类型提示,不会真的帮你拦下所有精度损失。要是团队里有人图省事,直接在金融模型里用 double 做了除法,问题不会马上暴露,但数据一旦去重对账,绝对够喝一壶的。

我自己在实际适配中的体会是,fixnum 这个库本身不需要什么高深改造,真正的挑战在于识别出鸿蒙生态里那些隐式的数值降级点。你只要把每一处跨端、跨引擎、跨 JSON 的 64 位数据都纳入管理,用统一封装收口,后面的业务代码反而会变得非常清爽。等这样做过一轮以后,你会发现自己已经成了团队里最懂鸿蒙底层数值细节的那个人,这也是标题里“鸿蒙级底层数值专家”的真正含义。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询