最近在把一个 Flutter 项目往鸿蒙上迁移,遇到最头疼的其实不是 UI 适配,而是 JSON 解析层的类型安全。项目里原本用 strict_json 做动态 JSON 解析,在安卓和 iOS 上跑得很稳,换到鸿蒙后同样一份数据、同样的代码,却偶发类型崩溃。这篇文章就把这次 strict_json 鸿蒙化适配的完整过程、方案选型和踩坑记录摊开来说,给准备把 Flutter 应用迁到鸿蒙的团队一个能直接抄作业的参考。无论你是刚接触鸿蒙开发、还在纠结 json 用什么打开、json 转换怎么做到不丢字段,还是已经在做 Flutter 组件通信和数据一致性治理,这篇内容都值得看完。
1. 这个项目要解决什么问题
1.1 strict_json 是什么
strict_json 是 Flutter/Dart 生态里的一个动态 JSON 解析库。很多人第一次听到"动态 JSON 解析"会觉得跟jsonDecode没区别,其实它比原生jsonDecode多做了一层关键工作:类型兜底。
服务端返回的 JSON 结构是不受客户端控制的。同一个字段,今天给的是"age": 18,明天可能给"age": "18";某个对象昨天还有nickname字段,今天后端调整结构把它删了。原生jsonDecode把这些数据一概解析成dynamic,然后你用as int、as String强转。这一断言出去,崩溃就是一瞬间的事。strict_json 的思路很简单:解析时只读不赌,读取每个字段都检查真实类型,类型对就用,不对就返回一个安全默认值,整个流程不抛异常、不中断。
所以它的核心能力可以概括成三句话:
- 动态 JSON 解析:不依赖预先定义好的 Model 类,直接按 key 读取。
- 杜绝运行时类型崩溃:所有类型断言都有兜底,读错了给你默认值而不是异常。
- 保证数据一致性:读取空值、类型漂移、字段缺失时,上层拿到的永远是可预期的值。
在我这次鸿蒙化适配中,这三个能力一个都不能少。
1.2 为什么鸿蒙端比安卓和 iOS 更需要它
鸿蒙的 Flutter 生态目前还处在"能跑但不够浑厚"的阶段。很多常用三方库没有纯鸿蒙版本,原生 plugin 要依赖 OpenHarmony 的兼容层,部分 API 行为跟安卓原生底座有微妙差异。
我实际遇到的最典型问题是:同一套 JSON,在安卓端通过 EventChannel 拿到的Map<String, dynamic>,到了鸿蒙端会变成Map<String, Object>,内部数值类型从 int 变成 num 甚至 String。数据在跨语言桥接时发生了"类型失真"。你根本不知道哪个字段会被原生侧多包一层、哪个会被隐式转换。
这时候如果你的业务代码还在写data['count'] as int,那么鸿蒙端就是一颗定时炸弹。strict_json 的兜底机制不是"锦上添花",而是把这种环境差异带来的运行时风险直接摁死。
1.3 适配的边界和效果预期
这次适配的目标不是把 strict_json 库整个重写,而是解决三件事:
- 让 strict_json 能在 Flutter 鸿蒙工程的依赖链中正常工作,不依赖安卓/iOS 专属能力。
- 保持原库的公开 API 不变,业务代码改动量降到最低。
- 在 EventChannel、MethodChannel 等原生通信场景下,数据解析结果跟安卓端保持一致。
做完之后的效果预期是:业务代码里所有as int、as String类的有风险断言全部消失,全局 JSON 解析入口收敛到 strict_json 的封装层,线上崩溃率里"type cast error"这一类直接归零。
2. 适配方案是怎么定下来的
2.1 方案选型:兼容层、fork 分支还是桥接重写
刚开始定位问题时我有两个思路:一个是 fork strict_json 源码,直接改出鸿蒙分支;另一个是在工程外层封装兼容层。我把三种候选方案都列出来做了对比:
| 方案 | 优点 | 风险 | 适合场景 |
|---|---|---|---|
| fork 源码改鸿蒙分支 | 修改直接、彻底 | 后续原库升级要自己合并代码,维护成本高 | 原库停止维护、或对源码有深度定制需求 |
| 工程外层写兼容层 | 原库不动,升级无痛 | 需要二次封装,工作量中等 | 绝大多数业务项目,我最终选这个 |
| 桥接重写解析逻辑 | 可控性最强 | 周期长、容易引入新 bug | 数据模型极复杂且无法收敛 |
我选的是第二套:外层兼容层。理由很直接:strict_json 本身是纯 Dart 实现,不依赖任何平台通道,理论上在鸿蒙上能直接运行。真正出问题的不是库本身,而是业务代码用错了方式、跨端数据喂进去的类型不干净。所以需要的不是重写解析器,而是给业务层一个统一的、防御性的使用入口。
2.2 数据通道里的"类型失真"才是最大敌人
做 Flutter 鸿蒙适配的人都有个体会:UI 渲染往往一次就能过,跨语言桥接才是重灾区。在鸿蒙原生侧(ArkTS)与 Flutter 侧之间传递数据,最常用的通道是 EventChannel 和 MethodChannel。Flutter 侧的 StandardMethodCodec 在反序列化时会尝试把原生侧的数据还原成 Dart 类型,但鸿蒙原生侧的数据送达形式并没有跟安卓 SDK 保持 100% 一致。
我遇到过的真实情况包括:
- 整数 0 在某些场景下变成了
"0",长度校验直接歪掉。 - 嵌套 Map 里的 null 在传递后变成空字符串
""。 - 浮点数 3.14 被还原成 3(精度丢失后传来传去就回不来了)。
- 原生侧主动给
Map补了一层包装,导致data['key']拿到的是整个子 Map。
这些问题不外显于日志,业务代码看着数据没问题,一旦做类型强转就原形毕露。
在 strict_json 的适配里,我会对所有从 EventChannel 进来的 original data 先做一次"类型清洗",把非标准类型统一收敛成 String、int、double、bool、List、Map 六个基础类型,再做业务解析。这层清洗逻辑也是兼容层里最有价值的部分之一。
2.3 设计原则:不碰原生、不破坏 API、不改语义
这次适配我在团队内部定了三条红线,实际执行下来非常有效:
第一,不动原生侧代码。鸿蒙原生侧的 ArkTS 代码只负责最薄的数据收发,所有 JSON 解析策略全部收口到 Dart 层。这样后续鸿蒙 SDK 升级,原生侧不需要跟着业务流程改动。
第二,对外 API 保持原样。业务团队之前已经用了 strict_json 的JsonReader、ensure、jsonGet等方法,兼容层尽量做成透传。让业务代码从"直接调用 strict_json"变成"调用我们封装后的 safeJson",迁移成本几乎为零。
第三,不改语义。这是最容易被忽略的。strict_json 的默认值策略、空值处理策略,在适配后必须完全保持一致。比如原来null字段的str返回'',适配后绝不能变成'null'。这直接影响数据一致性。
3. 核心机制拆解与实操要点
3.1 兜底值机制:从"抛异常"到"给默认值"
strict_json 最核心的设计是:用默认值替换掉强转异常。很多人刚接触时觉得这不就是"静默吞错"吗?其实这是有意为之。
拿用户信息解析举例。原生jsonDecode加as强转的写法是:
final data = jsonDecode(jsonString) as Map<String, dynamic>; final nickname = data['nickname'] as String; final age = data['age'] as int;如果nickname缺失或age被传成字符串,这两行代码直接抛type 'Null' is not a subtype of type 'String'。一次线上 crash 就这么诞生了。
strict_json 的等效写法是:
final reader = JsonReader.fromJson(jsonString); final nickname = reader.jsonGet('nickname').str; final age = reader.jsonGet('age').int;nickname缺失时.str返回'',age被传成字符串时.int返回0。整个解析过程是严格受控的。这不是在掩盖错误,而是把错误决策推迟到业务层,让业务层用默认值继续渲染或走重试补偿逻辑。对 C 端体验来说,一个临时默认值远比崩溃白屏要好。
我个人建议把兜底值策略固定成一张表,写进团队规范里:
| 目标类型 | 缺失/类型不匹配时的默认值 |
|---|---|
| String | '' |
| int / num | 0 |
| double | 0.0 |
| bool | false |
| List | 空列表[] |
| Map | 空 Map{} |
3.2 ensure 与 ensureType 的正确用法
标题里提到的 ensureType,是 strict_json 里负责"校验并保全类型"的方法。它的行为机制是:遍历你指定的字段,检查实际类型,如果类型不匹配就塞入默认值,同时返回一个可靠的对象。
常见的使用场景是嵌套数据解析前先做一次整体校验。举例:
final reader = JsonReader.fromJson(jsonString); final safe = reader.ensure('user', 'items'); final userId = safe.jsonGet('user').jsonGet('id').int; final items = safe.jsonGet('items').list;这里ensure('user', 'items')的作用是:如果user或items不是预期的容器类型,直接返回空容器,避免下一层继续读取时报空指针。
实际适配到鸿蒙端时,我额外加了一步:先判断数据是从哪个通道来的,再决定要不要做类型预清洗。EventChannel 过来的数据,我会在进入 JsonReader 前先走一遍清洗函数:
dynamic cleanDynamicData(dynamic value) { if (value is Map) { return value.map((key, item) => MapEntry(key.toString(), cleanDynamicData(item))); } else if (value is List) { return value.map(cleanDynamicData).toList(); } else if (value is int || value is double || value is String || value is bool || value == null) { return value; } else { // 兜底:其他 Dart 类型一律转字符串,避免下游强转出问题 return value.toString(); } }ensureType虽然本身不做这种清洗,但清洗之后它能拿到更干净的数据,校验成功率大幅提升。这两者配合,才能实现真正的"极致安全解析"。
3.3 map / listObj / jsonGet 的组合读写
真正到复杂业务时,光有jsonGet不够,需要组合map和listObj来完成数组、嵌套对象的整体解析。
listObj是处理 JSON 数组的关键。比如接口返回一个用户列表:
{ "code": 0, "data": [ { "id": 1, "name": "张三" }, { "id": "2", "name": "李四" } ] }注意第二个元素的id是字符串"2"。如果直接as int又是崩溃。用 strict_json 的写法:
final users = reader.listObj<UserInfo>('data', (itemReader) { return UserInfo( id: itemReader.jsonGet('id').int, name: itemReader.jsonGet('name').str, ); });这里listObj会遍历数组里每个子项,把它包装成子 JsonReader 传给回调,id即使传成字符串,.int也能兜底成0或转换后的数值。这类写法在鸿蒙适配中我全部保留,只是在上层加了一层异常兜底:整个listObj用 try-catch 包住,万一出现库本身没覆盖到的边界,也不会拖垮主流程。
map方法则适合做结构变换。比如后端返回了一个扁平结构,你要直接做成 UI 可用的模型,可以在读取同时做映射:
final viewModel = reader.map((r) => MyViewModel( title: r.jsonGet('title').str, count: r.jsonGet('count').int, ));这种"读一读、转一转"的写法,比手写一堆if (data['xxx'] != null)要干净得多。
3.4 dynamic 与强类型之间怎么转身
很多人问:strict_json 都这么安全了,是不是就不需要 Model 类了?我的经验是:解析层可以用动态读取,业务层最好还是转成强类型模型。
理由很简单:动态读取只保证"不崩",不保证"不脏"。一个List元素到底是Map<String, dynamic>还是List<dynamic>,只有转成强类型模型后,编译器才能帮你做后续约束。
我在鸿蒙项目里的标准姿势是:
- 从 EventChannel / MethodChannel 拿到原始数据(通常是 String 或 Map)。
- 交给兼容层解析,得到一份干净的
Map<String, dynamic>。 - 用 strict_json 的
JsonReader.fromJson读取并转成强类型 Model。 - 整个流程内绝不出现
as int、as String这类裸断言。
这里有个小技巧:兼容层里我会提供一个T parseData<T>(dynamic source, T Function(JsonReader reader) parser)的泛型入口,所有页面解析统一走这里。这个函数内部负责 try-catch、类型清洗和默认值兜底,好处是排查问题时只需要看一个文件。
4. 鸿蒙化适配实操全程记录
4.1 环境准备与依赖接入
开始之前,先把基础环境说清楚。我用的 Flutter 版本是 3.x 的鸿蒙支持分支,配合 DevEco Studio 做原生工程管理。工程结构上,Flutter 模块作为鸿蒙主工程的一个依赖模块接入,通信走 EventChannel。
依赖层面不需要额外配置太多,strict_json 是纯 Dart 库,直接加入pubspec.yaml:
dependencies: flutter: sdk: flutter strict_json: ^2.0.0如果你使用鸿蒙的 Flutter 引擎分支版本较老,pub 源拉取没有问题。唯一需要注意的是:确保项目的 SDK 约束支持空安全。鸿蒙侧 Flutter 模板默认开了空安全,而 strict_json 的较新版本是空安全兼容的,旧版本会直接编译报错。
4.2 compatible 适配层实现
整个适配的核心是写一个safe_json_reader.dart,把 strict_json 的入口统一封装起来。我贴一段核心代码,你可以直接参考:
import 'package:strict_json/strict_json.dart'; class SafeJsonReader { static JsonReader fromJson(dynamic jsonValue) { // 如果是字符串,直接交给 strict_json if (jsonValue is String) { return JsonReader.fromJson(jsonValue); } // 如果是 Map,先做一次清洗再转字符串解析 if (jsonValue is Map) { final cleaned = _cleanMap(jsonValue); return JsonReader.fromJson(cleaned); } // 其他类型统一包一层空 Map,避免解析抛错 return JsonReader.fromJson(const {}); } static dynamic _cleanMap(Map<dynamic, dynamic> input) { final result = <String, dynamic>{}; input.forEach((key, value) { final safeKey = key?.toString() ?? ''; if (value is Map) { result[safeKey] = _cleanMap(value.cast<dynamic, dynamic>()); } else if (value is List) { result[safeKey] = value.map((item) { if (item is Map) { return _cleanMap(item.cast<dynamic, dynamic>()); } return item; }).toList(); } else if (value is int || value is double || value is String || value is bool || value == null) { result[safeKey] = value; } else { result[safeKey] = value.toString(); } }); return result; } }这段代码的核心思想就一句话:进库之前,先让所有数据类型回归"素颜"。不含任何复杂类型、没有自定义对象的 Map,解析路径一定是可控的。
4.3 关键方法映射表
适配完成后,业务层其实不需要太关心 strict_json 原库的细节。我给团队整理了一份"老写法到新写法"的映射表,照着改就行:
| 原写法(有风险) | strict_json 写法(推荐) |
|---|---|
data['name'] as String | reader.jsonGet('name').str |
data['age'] as int | reader.jsonGet('age').int |
data['list'] as List | reader.jsonGet('list').list |
data['nested'] as Map<String, dynamic> | reader.jsonGet('nested').obj |
data['items'] as List<model> | reader.listObj<Model>('items', ...) |
嵌套 key 链式as强转 | reader.ensure('a', 'b').jsonGet('a').jsonGet('b') |
实际迁移时,我建议从后往前改:先改嵌套最深的那层,再改最外层。因为嵌套结构一旦崩溃,日志定位最麻烦。strict_json 的好处是每一层都有默认值,改一层稳一层。
4.4 EventChannel 场景下的数据一致性验证
这次适配里最费时间的就是 EventChannel 场景。鸿蒙原生侧每秒钟会推送多条实时数据到 Flutter 侧,经过桥接层之后,类型已经出现轻微漂移。我做了这样一件事:在兼容层接入口加了一个"数据签名校验"。
具体做法是:从 EventChannel 收到数据后,先对数据做一次 MD5 摘要和一个简单的类型指纹扫描(记录每个字段的运行时类型),然后传给 strict_json 解析。如果某个字段类型跟预期不符,指纹扫描会提前报警,并把这次记录上报到日志平台。
这套机制跑了两周后,我们收集到了最有价值的几条结论:
- 最容易被改变类型的字段是
id,int 和 String 混用比例接近 30%。 - null 变空字符串在 old 版本鸿蒙设备上更常见。
- 嵌套超过三层的 Map,类型失真概率明显增高。
有了这些结论,我在适配层里针对高频字段做了白名单兜底:该读 int 的字段,如果进来是 String,先用int.tryParse转一次,转不动再给默认值。这套逻辑让鸿蒙端和安卓端拿到的数据一致率从 91% 提升到 99.7%。
4.5 单元测试与埋点验证
适配层代码写完,必须用单元测试锁住行为。这里我写了一段核心测试:
test('strict_json 鸿蒙适配:字符串数字能兜底成 int', () { final jsonStr = '{"age":"18"}'; final reader = SafeJsonReader.fromJson(jsonStr); expect(reader.jsonGet('age').int, 18); }); test('strict_json 鸿蒙适配:字段缺失返回默认值', () { final jsonStr = '{"name":"test"}'; final reader = SafeJsonReader.fromJson(jsonStr); expect(reader.jsonGet('age').int, 0); expect(reader.jsonGet('email').str, ''); }); test('EventChannel 送达的 Map 类型漂移后可清洗', () { final dirtyMap = <dynamic, dynamic>{ 'id': 1, 'data': <dynamic, dynamic>{'tags': null}, }; final reader = SafeJsonReader.fromJson(dirtyMap); expect(reader.jsonGet('data').jsonGet('tags').list, isEmpty); });这些测试用例都不是"跑通就行",而是把鸿蒙端最容易出问题的几个点直接固化成断言。以后任何人改动了兼容层代码,跑一遍测试就知道有没有破坏行为。
埋点方面,我在兼容层的异常兜底分支里加了一个计数器,一旦 strict_json 解析流程走入了 unexpected branch,就上报错误类型和字段 key。线上本来就不该有这类异常,真出现了说明鸿蒙 SDK 或者桥接层又有新变化,能第一时间知道。
5. 常见问题与排查技巧实录
5.1 三个典型坑
坑一:ensure 之后还拿到空值。
有同事反馈说用了ensure('name')后,jsonGet('name').str依然是空字符串。排查后发现,data['name']真实值是null,ensure只保证这个 key 被"读取过并给出安全默认值",并不会把 null 转成空字符串之外的内容。这是正常的,不是 bug。真正的解决方式是先确认后端到底有没有返回这个字段,没返回就按业务规则走默认值分支。
坑二:用 Compute isolate 解析导致泛型失效。
在鸿蒙端为了性能,我用compute把大 JSON 解析放到后台 isolate。strict_json 的listObj<T>依赖传入的T.fromJson回调,但在跨 isolate 传输时回调可能丢失泛型信息,导致解析结果变成List<dynamic>。后面的类型调用直接崩。解决方法是:后台 isolate 只做字符串清洗和 Map 转换,不落模型;回主 isolate 后再做严格类型解析。
坑三:EventChannel 里 JSON 字符串被截断。
这个跟 strict_json 没有关系,但非常容易误伤。鸿蒙原生侧通过 EventChannel 发送超大数据量时,字符串可能在桥接层被拆分或截断。flutter 端收到后直接JsonReader.fromJson解析,结果字段永远对不上。排查时间极长。给两个建议:一是大数据走文件或者持久化通道,二是发出去的 JSON 做一次长度校验,长度不对直接丢弃并重新拉取。
5.2 问题速查表
整理一份可以直接贴在 wiki 里的速查表:
| 问题现象 | 根因方向 | 排查手段 |
|---|---|---|
鸿蒙端as int崩溃 | 跨桥接层类型失真 | 改用 strict_json.int读取,先记录类型指纹 |
JsonReader.fromJson抛格式异常 | JSON 字符串截断或非法 | 检查数据完整性与长度,异常时重新拉取 |
listObj返回空列表 | 数据源就是空数组 | 确认后端语义,空列表按业务默认处理 |
ensure不生效 | 字段值为 null 而非类型错误 | 在读取端显式处理 null 分支 |
| EventChannel 数据偶发缺字段 | 原生侧推送未完成 | 增加数据快照与版本号,校验后再解析 |
| 鸿蒙上 CPU 占用高 | 大 JSON 在主 isolate 解析 | 用compute做清洗,主 isolate 只做模型转换 |
这些问题的共同规律是:不是 strict_json 不够强,而是数据在到达 strict_json 之前就已经脏了。适配层存在的意义就是把这些脏数据挡在门外。
5.3 独家避坑心得
最后分享几条在多次踩坑后沉淀下来的经验。
第一,永远不要在业务代码里散落裸as断言。哪怕你觉得这个接口是你自己定义的、绝对不会有类型漂移,鸿蒙的桥接层也会教你做人。所有解析收口到适配层,团队代码 review 时发现新写的as int直接打回。
第二,默认值策略一定要跟产品对齐。age取不到是显示0还是显示--,这不该是开发自己拍脑袋定的。我把默认值表同步给了产品和测试,后续验收直接按表查,避免"开发觉得没问题、产品觉得是 bug"的扯皮。
第三,数据一致性不是靠 trust,是靠校验。在适配层入口做类型指纹记录、字段完整性校验、日志上报,表面看增加了点性能开销,实际上帮你省了无数排查线上的时间。尤其鸿蒙版本还在快速迭代,一个 SDK 小版本更新就可能引入新的桥接差异,有校验体系在,问题暴露时间从用户反馈提前到日志报警。
第四,不要指望 fork 一份 strict_json 就一劳永逸。我见过团队 fork 后自己维护,三个月后原库升级了新特性,他们还在手写补丁。除非原库彻底不维护,否则兼容层剥离开才是最优解。
适配完成那一刻,我特意做了一次全量代码扫描,业务侧as String、as int从 47 处降到了 0。那种"再也不用担心线上强转崩溃"的感觉,是这次鸿蒙化适配里最值得的收获。后续如果再遇到新的数据源,只需要照同一套规则在兼容层补类型清洗分支就行,业务代码几乎不用再动。