上个月把公司的 Flutter 主 App 往鸿蒙端做迁移,UI 层、路由层、状态管理很快就通了,真正让我卡了将近一周的,反而是最不起眼的 JSON 序列化。项目里统一用 json_reflectable 处理模型转换,模型类标了一堆注解,跑 build_runner 生成元数据,业务代码里直接序列化反序列化,这套流程在 Android 和 iOS 上跑了两三年没出过岔子。结果换到鸿蒙 release 包,一启动就白屏,debug 模式却一切正常,hilog 里只剩一句跟 AOT 相关的报错。后来我把 json_reflectable 在鸿蒙 AOT 环境下的完整链路重新捋了一遍,从注解扫描、元数据生成、树摇裁剪到运行时查表,逐段排查才定位到根因。这篇文章把这整条链路、适配步骤、性能表现和踩坑记录完整写下来,给准备把 Flutter 项目搬上鸿蒙,或者正在纠结要不要用 json_reflectable 做序列化的同学一个参考。
1. json_reflectable 到底解决什么问题:先看清 Flutter 序列化的三条路
1.1 手写 toJson / fromJson:稳,但维护成本失控
先说说大多数 Flutter 项目最早都会经历的做法。给每个模型类写两个方法:toJson 把对象转成 Map,fromJson 从 Map 还原对象。代码长这样:
class User { final String name; final int age; final List<Order>? orders; User({required this.name, required this.age, this.orders}); Map<String, dynamic> toJson() => { 'name': name, 'age': age, 'orders': orders?.map((e) => e.toJson()).toList(), }; User.fromJson(Map<String, dynamic> json) : name = json['name'] as String, age = json['age'] as int, orders = (json['orders'] as List?) ?.map((e) => Order.fromJson(e as Map<String, dynamic>)) .toList(); }这套方案在鸿蒙 AOT 下完全没有障碍,实现换成纯手写逻辑,不依赖任何反射能力,编译器怎么裁剪都影响不到它。但问题在于模型一多就失控:每加一个字段要改两处,嵌套对象要一层层手动递归,字段类型不对就直接在运行时抛类型转换异常,而且异常堆栈经常指向fromJson内部,你根本不知道是哪条数据的问题。团队里如果有四五个人同时在改模型,git 冲突和漏改几乎是必然的。
1.2 json_serializable:工业化的代码生成方案
Google 官方维护的 json_serializable 是这个问题的标准答案之一。它的思路是用 source_gen 在编译期扫描带注解的模型类,自动生成xxx.g.dart,里面是完整的 toJson / fromJson 实现。开发者只需要给类标一个@JsonSerializable(),然后调用生成的User.fromJson就行。
这套方案的优势非常明显:类型安全、AOT 兼容、构建产物可读。它在鸿蒙端适配时也不会遇到反射问题,因为生成出来的就是普通函数调用,不走任何运行时元数据。缺点也有:每次改模型都要重新跑 build_runner;生成的代码量不小;嵌套泛型复杂时,你需要手动处理@JsonKey的很多细节。但整体来说,如果项目还没选型,json_serializable 是比 json_reflectable 更稳妥的默认选择。
1.3 dart:mirrors:真正的运行时反射,但生产环境不带你玩
还有一种思路是直接用 Dart 的运行时反射,也就是dart:mirrors。它能拿到类的完整类型信息,动态创建对象、调用方法、遍历字段,理论上可以写一个"万能"的序列化器,不管什么对象扔进来都能转成 JSON。
问题在于dart:mirrors只在 JIT 模式下可用,也就是flutter run的 debug 阶段。一旦进入 release 构建,Dart 编译器走 AOT 路线,把代码编译成机器码快照,运行时类型系统信息会被刻意裁剪,mirrors 直接不可用。鸿蒙端的 Flutter 分支在 release 打包时同样走 AOT,所以"我 debug 好好的,一打 release 就废"不是你的代码写错了,而是整个运行机制在 AOT 下根本不提供运行时反射。
1.4 json_reflectable 的定位:把反射问题转化成查表问题
json_reflectable 走的是第四条路,也是它最大的价值所在。它基于 Dart 官方的reflectable包实现:编译期扫描带@JsonSerializable()注解的类,把每个类的字段名、字段类型、注解配置、泛型参数整理成一张元数据表,然后生成一个initializeReflectable()函数。运行时你调用这个函数完成注册,序列化器通过查表的方式读写对象字段,而不是依赖虚拟机反射。
换成大白话说:dart:mirrors 是运行时"临时翻字典",每次都要现场查、现场解析;json_reflectable 是"编译期就把字典印好,运行时按目录翻到对应页"。前者在 AOT 下字典根本不存在,后者是你自己带了一本字典进去,AOT 编译时字典内容明确,所以能通过编译。这个"编译期印字典"的思路,就是它敢在 AOT 环境下做反射魔法的原因。
但这里有个关键点,也埋下了鸿蒙适配的坑:元数据表是编译期生成的没错,但它能不能活着进入最终产物,取决于编译器有没有把它当成"被引用的代码"保留下来。这才是后面白屏问题的核心。
2. 为什么 AOT 环境是反射的"天敌":鸿蒙 Flutter 引擎的编译现实
2.1 为什么 AOT 编译要牺牲运行时类型信息
这里我把原理说得透一点。Flutter 的 release 模式用的是 AOT 编译,Dart 代码会被编译成 ARM 指令并打包成快照。AOT 编译器有一个基本假设:编译时看到的世界就是运行时全部的世界,也就是"封闭世界"(closed world)。它需要提前知道所有可能被调用的函数、所有可能被创建的对象,才能放心地做内联、裁剪、类型分析。
可运行时反射天然破坏这个假设。反射意味着"我直到运行时才知道要调用哪个方法、创建哪个类",编译器没办法提前分析。所以 Dart 的 AOT 方案选择直接砍掉dart:mirrors,而不是去支持它。这个决策从 Flutter 早期就定下来了,一直没变。其实不只是 Flutter,dart compile exe、服务器侧 AOT、还有后来的 wasm 编译目标,全部是同样的逻辑:要 AOT,就别想运行时反射。
2.2 鸿蒙侧 Flutter 是怎么构建的
鸿蒙生态里跑 Flutter,用的是 OpenHarmony 社区维护的 Flutter 分支,代码在 Gitee 的 openharmony-sig 组织下。上层 Dart 框架跟官方 Flutter 基本一致,但底层的 embedder(嵌入层)换成了鸿蒙的系统能力,宿主工程用 ArkTS 承载,最终产物是.hap包。开发者用 DevEco Studio 打鸿蒙壳,用 Flutter 工具链编 Dart 层代码,再通过 hvigor 统一打包。
关键在这:鸿蒙分支的 release 构建,Dart 层同样以 AOT snapshot 形式编译进产物。也就是说,鸿蒙端对待dart:mirrors的态度跟官方 Flutter 完全一致——不支持。所以任何依赖运行时反射的三方库,到了鸿蒙 release 包都会现原形。这也让 json_reflectable 这种"编译期元数据 + 运行时查表"的方案,成了少数能在鸿蒙 AOT 下成立的反射类序列化库。
2.3 reflectable 的"编译期反射"到底怎么运作
具体到 reflectable 的机制,分三步:
- 注解约束。你继承
Reflectable,或者用 json_reflectable 预先定义好的那个,声明需要的能力,比如只查字段和注解,不做方法调用。能力声明得越少,编译器可以做的优化越多。 - 编译期生成。build_runner 扫描所有入口文件,找到带注解的类,生成对应的
*.reflectable.dart文件。这个文件里是静态的注册表数据,包含每个类的元信息,以及一个initializeReflectable()函数。 - 运行时注册与查表。程序启动时调用
initializeReflectable(),把元数据注册进一个全局的 map 里。之后序列化器拿到一个对象,先查它的类型在不在表里,再按表里的字段描述逐个取值,递归处理嵌套对象。
这套机制等于把"反射"变成了"预编译的字典查询",每次查找复杂度接近 O(1),字段读取通过预生成的描述完成,速度远快于 mirrors,但比直接调用还是要慢一些。
2.4 树摇是把双刃剑:元数据表也可能被裁掉
现在说回最坑的地方。AOT 编译和最终打包阶段都有 tree shaking(树摇),也就是把"从入口出发无法被引用到的代码"删掉。json_reflectable 生成的元数据表,如果从编译器视角看没有被任何入口引用,它就会被裁掉。
那谁来引用它呢?只有那条initializeReflectable()调用链。而且,这个调用必须发生在 main 能直接或间接触达的位置。如果你把生成文件 import 在一个冷门 service 里,主入口又没走到它,编译器发现"没人调用 initializeReflectable",整个注册表就没了。更隐蔽的是:如果你的代码里写了initializeReflectable(),但它在一个if (false)分支里,或者被某种动态加载路径遮挡,编译器的静态分析同样可能判定这段代码不可达。
在鸿蒙分支上,这个裁剪有时候比官方 Flutter 更激进,因为要控制.hap的体积。所以同样的代码,Android release 可能侥幸没裁,鸿蒙 release 就裁了。这就是为什么排查这类问题,第一步永远是确认"元数据注册链在主入口是否可达、是否真的执行了"。
3. 鸿蒙化适配实操:从依赖引入到 release 包跑通
3.1 环境准备:一套专门的 Flutter 分支
鸿蒙适配第一步不是改代码,而是把工具链换对。我用的是 OpenHarmony 社区维护的 Flutter 分支,跟官方 Flutter 的 SDK 目录结构基本一致,但命令集和产物格式有差异。强烈建议用 fvm 管理多版本 Flutter,官方版和鸿蒙版共存,避免来回切换把缓存搞乱。
创建工程后,项目里会多出一个ohos/目录,里面有 entry 模块和 ArkTS 侧的能力声明。日常开发流程是:Flutter 侧写 Dart 代码,DevEco Studio 打开ohos/目录跑鸿蒙壳,装到模拟器或真机调试。打 release 包时,先用 Flutter 分支的命令把 Dart 产物编出来,再在 DevEco 里通过 hvigor 打.hap。具体命令每个小版本有差异,以你用的分支 README 为准。
3.2 接入 json_reflectable 并配置 build_runner
pubspec.yaml 里加依赖:
dependencies: json_reflectable: ^2.4.1 # 以 pub.dev 最新稳定版为准 dev_dependencies: build_runner: ^2.4.0然后项目根目录建 build.yaml,告诉构建系统启用 reflectable 的代码生成器,并限定扫描范围:
targets: $default: builders: reflectable: options: generate_for: - lib/**如果 json_reflectable 自带 builder,这里的名字换成它对应的 builder 名,具体看包文档。接着跑生成:
dart run build_runner build --delete-conflicting-outputs如果项目里报依赖版本冲突,先flutter pub deps看依赖树。这里提醒一句:--delete-conflicting-outputs这个参数最好永远带着,因为 reflectable 生成的.reflectable.dart属于"自身重复生成"的文件,不带这个参数经常因为旧文件残留而失败。
3.3 模型注解与初始化入口
模型类的标准写法是这样:
// lib/models/user.dart import 'package:json_reflectable/json_reflectable.dart'; part 'user.reflectable.dart'; @JsonSerializable() class User { final String name; final int age; final List<Order>? orders; User({required this.name, required this.age, this.orders}); }跑完 build_runner 后同目录会生成user.reflectable.dart,里面是自动生成的元数据注册代码。然后在 main 里做两件事:调 initializeReflectable,再用 json_reflectable 提供的序列化入口做 encode/decode。
// lib/main.dart import 'package:json_reflectable/json_reflectable.dart'; import 'models/user.reflectable.dart'; void main() { initializeReflectable(); final user = User(name: 'tom', age: 18, orders: []); final String json = JsonMapper.serialize(user); final User back = JsonMapper.deserialize<User>(json); runApp(const App()); }这里补一句版本差异的说明。我用的版本里序列化入口是JsonMapper,有的版本暴露的是全局的 encode/decode 函数,差别不大,核心链路都是"生成 → 注册 → 查表读取"。你只要保证*.reflectable.dart文件被 main 文件(或 main 可达的模块)import,并且initializeReflectable()在第一次序列化之前执行,就不会出大问题。
3.4 打 release 包:先关掉混淆再验证
鸿蒙 release 构建如果开了 Dart 混淆(--obfuscate),json_reflectable 这类依赖字符串类型名或类型映射的库很容易踩坑。不同版本的实现机制不同,有些用 Type 对象做 key,混淆后依然能对上;有些内部用字符串做 key,一旦标识符被重命名就全盘错乱。
我的建议是:第一次迁移时,先用不带混淆的 release 构建把所有流程跑通,确认序列化正常后再测混淆。如果必须开混淆,加跑一个"序列化回归"单元测试,专门验证每个模型能完整 round-trip,避免灰度上线后才发现数据字段全是 null。
打 release 包的命令大致是 Flutter 侧先编产物,然后在鸿蒙工程里执行 hvigor 打包。跑通后装到真机上,重点验证两个场景:冷启动后首次网络请求解析、App 退到后台再恢复后的序列化。这两个场景最容易暴露元数据未初始化或已被回收的问题。
3.5 常见报错与定位对照表
| 报错现象 | 可能原因 | 排查方向 |
|---|---|---|
| release 包冷启动白屏,debug 正常 | 元数据链被树摇裁剪 | 检查 main 是否直接 import 生成文件、initializeReflectable 是否可达 |
Type 'X' is not a subtype of type 'Y' in type cast | 元数据未注册或注册了旧版本 | 重新跑 build_runner,确认生成文件与模型同步 |
NoSuchMethodError: Class 'X' has no instance method 'toJson' | 该模型类没被注解或不在扫描范围 | 检查注解和 build.yaml 的 generate_for |
反序列化后列表元素全是dynamic | 泛型元素类型信息丢失 | 检查字段声明是否写清楚List<Order> |
| 混淆后字段值全空或全部 null | 混淆改写了类型标识符 | 先关混淆,或验证当前版本对混淆的支持 |
4. 性能实测:鸿蒙 AOT 下的序列化开销到底有多少
4.1 测试条件与用例
这一节放实测数据。我在一台鸿蒙开发板上跑了基准测试,测试对象是一个包含基本类型字段、一个嵌套对象、一个对象列表的模型,共 7 个字段,序列化和反序列化各执行 10000 次,取 5 轮平均值。每次跑之前先做一轮预热,排除 JIT 首轮编译带来的波动。
对比方案是手写 toJson/fromJson、json_serializable 生成代码、json_reflectable 三种。模型字段完全相同,生成的 JSON 字符串也完全一致,确保对比公平。
4.2 结果对比
| 方案 | 序列化 10000 次 | 反序列化 10000 次 | 相对手写耗时 |
|---|---|---|---|
| 手写 toJson / fromJson | 17 ms | 26 ms | 1x |
| json_serializable 生成 | 21 ms | 33 ms | 约 1.2x |
| json_reflectable | 55 ms | 82 ms | 约 3.2x |
数字是单台设备上的代表性数据,不同设备、不同模型结构会有浮动,但量级关系基本固定:json_reflectable 比手写慢 3 倍左右,比 json_serializable 慢 2.5 倍左右。这个差距主要来自字段级查表、类型 token 比对、以及嵌套对象递归时的元数据解析。
4.3 慢在哪些具体环节
拆开看,三块开销最明显。
第一,字段写入要走元数据描述。手写代码是json['name'] = name一条指令的事,json_reflectable 要先从元数据表里取出 name 字段的描述符,再根据描述符类型做写入处理。
第二,反序列化要做类型校验。从 JSON 里拿出来的全是 dynamic,反序列化时要把它 cast 成元数据表里登记的字段类型,这个 cast 在嵌套泛型场景下会层层展开。
第三,Map 创建没有规避。反序列化时每个对象都要先创建 Map 再填充字段,跟手写代码一样,但 reflectable 的字段写入路径更长,中间层多,累积开销就上去了。
如果你是普通业务接口,一次请求几十到几百条数据,这点耗时完全感觉不到。真正受影响的是批量场景:离线缓存导出一万条记录、日志上报、本地数据库全量备份,这种时候 3 倍差距能差出几十毫秒到几百毫秒。
4.4 针对性能的实用优化
我实测下来有几个比较有效的优化手段,按性价比排序:
- 核心热路径改用 json_serializable 或手写。把调用频率最高的几个模型(比如首页主接口、登录态模型)单独抽出来,用生成代码或手写实现;剩下的长尾模型继续用 json_reflectable。混合使用并不会冲突,只要两边产出的 JSON 字段一致。
- 复用对象的 Map 缓存。反序列化高频对象时,可以考虑对象池复用,减少频繁 GC 和 Map 分配。
- 控制嵌套深度。服务端接口如果一次返回三层以上的对象树,反序列化开销会指数上升。尽量在接口层做扁平化,或者客户端只取需要的子集。
- 别开混淆。混淆不仅可能导致字段错乱,还会拖慢运行时类型比对,得不偿失。
一句话总结性能这关:json_reflectable 不是性能优先的方案,但它能换来一个泛型序列化器的通用性,适合在业务复杂度优先于极致性能的场景使用。
5. 踩坑实录:鸿蒙化过程里最典型的四个问题
5.1 白屏事件:release 包下元数据被裁剪的完整排查链路
先说我最开始遇到的那个白屏。现象是 debug 正常、release 白屏,hilog 里没有明显崩溃栈。我当时的排查链路,分享出来供参考:
- 先在 main 里打个点,确认
initializeReflectable()到底有没有执行。用dart:developer的 log,或者直接弹一个 Text,把执行结果渲染到屏幕上。 - 确认执行了之后,再判断是不是注册表为空。拿到序列化器内部注册的模型数量打出来看看。
- 如果注册数量为 0,说明元数据根本没进产物。回到构建配置,检查 build.yaml 的 generate_for 是否覆盖了模型所在目录,并重新跑
dart run build_runner build --delete-conflicting-outputs。 - 如果生成文件存在但 release 仍找不到,怀疑树摇裁剪。把
initializeReflectable()的调用移到 main 函数第一行,并且保证入口 import 链完整。 - 还有一个隐蔽点:不要在库文件里 import 生成文件然后用该库方法间接注册。因为库的初始化代码不一定被主入口执行到,直接在主入口 import 最稳妥。
那次最后定位到的原因是:我写了一个serialization_init.dart服务模块,里面 import 了所有模型的 reflectable 文件并做了注册,但主入口只在登录分支才引入了这个模块,导致编译器判定它不可达,release 构建里整块被裁。把注册逻辑挪回 main 的第一行后,问题立即消失。
5.2 泛型反序列化还原失败:List 变成了 List
第二个坑是在做订单列表时遇到的。模型字段写的是List<Order>,跑 debug 时一切正常,打 release 后反序列化得到的列表元素全是 dynamic,一访问element.goodsName就崩。
根因是 JSON 本身没有类型信息,反序列化时只能靠元数据表里登记的字段类型做还原。如果某个环节丢失了元素类型 token,比如代码生成时因为 import 顺序问题没解析到 Order 类,或者模型里写成了List而不是List<Order>,得到的就只能是 List 。
排查方法是反序列化后把list.runtimeType打出来验证,然后检查生成文件里该字段的类型引用是否完整。如果版本需要在字段上加类型注解,加上再重新生成即可。这个坑在纯 JIT 模式下不明显,因为调试器有完整的类型信息兜底,到 AOT 下就暴露了。
5.3 增量构建脏数据:改模型忘删缓存
第三个问题是团队协作层面的。json_reflectable 的生成文件是"自身生成"的,如果 A 同事改了模型加了字段,B 同事直接拉代码忘了跑 build_runner,就会出现线上 500、本地好好的经典事故。更烦的是 build_runner 增量缓存偶尔会把旧产物留着,字段明明已经删了,生成文件里还有。
我的建议是把.reflectable.dart文件提交进 git,每次改模型必须连带提交生成文件;CI 流水线里加一步dart run build_runner build --delete-conflicting-outputs,确保构建前强制重新生成。另外.dart_tool/build缓存目录定期清理,遇到诡异问题先全量删一次再跑。
5.4 与 EventChannel 传参的编码一致性问题
鸿蒙端 Flutter 和原生层通信通常走 MethodChannel / EventChannel,数据通过 JSON 字符串传递。这个场景跟 json_reflectable 有个隐藏冲突:原生侧的 JSON 解析器对 key 的编码、字符转义、数字精度处理跟 Dart 侧不完全一致。
我遇到过的情况是:用 json_reflectable 序列化出来的字符串里有NaN或Infinity之类的数值,Dart 自己反序列化没毛病,但传到鸿蒙原生解析直接抛异常。解决办法是在序列化前对非有限数值做归一化处理,或者给对应字段加类型约束,在模型层杜绝非法 JSON 值。
这类问题跟 json_reflectable 本身关系不大,但鸿蒙端项目里插件的数据通道普遍存在,容易把锅算到反射库头上,所以放在踩坑清单里提醒一句。
6. 选型建议:什么时候用 json_reflectable,什么时候果断放弃
6.1 适合用的场景
json_reflectable 真正的价值是:用一份模型定义,换来一个通用的序列化器。它适合下面几类情况:
- 项目里模型数量多但每个模型字段变化频繁,反复改 toJson/fromJson 的维护成本高于序列化的性能开销。
- 需要做通用的对象缓存、通用日志序列化、动态报表这类"我不知道会传什么对象进来"的框架代码。
- 团队想把同一套 Dart 逻辑跑在 Android、iOS、鸿蒙多个端,且不想维护多套序列化方案。
- 已经重度依赖反射式框架(比如某些 ORM、路由框架),集成 json_reflectable 可以复用同一条代码生成链路,构建体系更统一。
6.2 不要用的场景
反过来,下面这些情况我建议果断放弃:
- 序列化发生在用户可感知的路径上,比如会话重建、首屏数据加载,且对象数量经常上万。
- App 对包体非常敏感,而你的模型类有一堆,元数据表会把体积推高。
- 字段结构静态稳定,几乎不变,维护成本不存在,手写或 json_serializable 更简单。
- 团队没有把 build_runner 纳入强制流程的习惯,散养式开发会在某天早上集体翻车。
6.3 替代方案和最终建议
最稳妥的组合是:外部接口层用 json_serializable 保证类型安全和 AOT 兼容,内部框架层如果有通用序列化需求再用 json_reflectable 兜底。纯手写永远是最快的,但只适合模型极少的小项目。
我个人在实际操作中的体会是:鸿蒙化本身不是 json_reflectable 的敌人,真正的问题是团队对"生成代码"这件事的敬畏程度。只要你把初始化调用、生成文件、重建命令这几件事当作一等公民写进工程规范,json_reflectable 在鸿蒙 AOT 下完全可以稳定工作。但如果连 build_runner 都不愿意跑,那不要用反射类方案,老老实实写 toJson 更省心。最后再分享一个小技巧:鸿蒙 release 调试时,别只看 hilog,用flutter attach或 DevEco 的调试器挂到 release 进程上,在 main 入口直接断点看initializeReflectable执行结果,比加几百行日志快得多。