做鸿蒙 Flutter 开发的朋友,应该都经历过这种崩溃现场:后台同步一份几十 MB 的 JSON,你下意识jsonDecode下去,内存直接爆掉。我在做鸿蒙版日志上报模块时,就被一份 200MB 的堆栈 JSON 干翻过。后来把整套解析链路切成了json_events这个流式 JSON 解析库,分块喂数据、按事件消费,内存峰值直接降了一个数量级,这才把鸿蒙真机上的稳定性救回来。这篇就把 json_events 的鸿蒙化适配思路、完整代码和踩坑记录一次性讲清楚,适合正在做鸿蒙 Flutter 应用、又卡在大数据量解析性能上的开发者参考。
1. 为什么会在鸿蒙项目里盯上 json_events:一次真实的 OOM 现场
1.1 崩溃现场复盘:一份 200MB 日志的教训
先交代下背景。当时我在做的是一个日志回溯功能,把离线采集到的崩溃堆栈、网络错误、性能指标这堆东西打成一个 JSON 包,由服务端下发,App 解析后按条件筛选上传。鸿蒙版本联调第一天,我拿了一份压测用的真实数据,大概 200MB,直接拖到首页触发解析流程,结果应用白屏几秒后立刻闪退。抓日志发现问题就停在jsonDecode上。
这个结果并不意外,但要说明白为什么崩,还是要回到 Dart 一次性解析的内存模型上。jsonDecode做的事是:先把整个 JSON 文件读进内存,得到一份完整的 UTF-16 字符串,然后同步构建出一棵完整的Map/List对象树。嵌套容器、字符串键、字符串值、数值对象全都分配在堆上,HashMap 扩容还会反复拷贝桶数组。我统计过一个 80MB 的 JSON 文件,经过jsonDecode之后进程 RSS 能冲到 1GB 以上,差不多是原文件体积的 10 到 20 倍。这还没算 GC 还没来得及回收的中间产物。
鸿蒙设备的问题比 Android 那边更明显。App 自身有 UI、有各种长连接,内存水位本来就高,再叠加这种“突然涨一个数量级”的解析操作,非常容易碰到系统内存压力线,然后被直接杀掉,连onMemoryWarning都不给你反应时间。开发机 12GB 内存跑起来没事,不代表 6GB 甚至 4GB 的鸿蒙真机也能扛住。
1.2 选型对比:为什么不是 json_stream,也不是自己写正则
崩溃之后我列过一轮方案,重点看了四个方向。
| 方案 | 内存模型 | 适合场景 | 鸿蒙适配成本 | 主要缺点 |
|---|---|---|---|---|
jsonDecode | 完整对象树 | 小文件、随机访问 | 无 | 内存峰值高,大数据必崩 |
json_stream | 流式读取+按路径构建子树 | 从超大 JSON 里抽取指定字段 | 低 | 处理整个数组时子树仍会被构建 |
| 正则+逐行扫描 | 无状态流 | 扁平结构取字段 | 无 | 嵌套层数一多就漏,转义也难处理 |
json_events | 事件流,不建树 | 逐条处理大数组、管道过滤、ETL | 低(纯 Dart 无原生依赖) | 需要自己维护状态和 key 路径 |
json_stream是个好库,也确实是流式思路,但它的问题是“流式读取”和“按路径取字段”绑得比较紧,内部为了响应路径查询会把沿途的子树构造出来。如果我只是想把这个 10 万条记录的数组逐条消费掉,这种方式会在某个时刻把子节点树保留下来,内存又会反弹。
自己写正则就更不靠谱了。JSON 是上下文无关文法,对象嵌套、数组嵌套、字符串里的转义引号、Unicode 转义,这些东西用正则做很容易在某个犄角旮旯翻车。调试起来极其痛苦,而且毫无通用性。当时我需要的是稳定处理任意合法 JSON、同时能精准区分对象层级和数据类型的东西。
最终选json_events,理由很直接:第一,它把 JSON 拆成一串细粒度事件,每收到一个 token 就回调一次,处理完就可以扔掉,不会把完整结构留在内存里,这正是我要的大数组逐条处理模型;第二,它是纯 Dart 实现,没有 Android/iOS 的目录结构,也没有 FFI 依赖,意味着鸿蒙适配基本不需要改它自身,只需要改我的数据通路。对于一个第三方库来说,这种“零原生代码依赖”的属性在鸿蒙生态里价值极高。
2. json_events 的事件模型:流式解析为什么能压住内存
2.1 API 长什么样:事件流就是 JSON 的骨架
json_events这个包在 pub.dev 上直接搜得到,引入方式就是普通的dart pub add json_events。它没有复杂配置,核心概念只有一个:你把字符串按块喂给解析器,解析器把 JSON 结构改成事件往外抛,你自己决定每个事件要做什么。
下面是基于 json_events 0.9.x 系列的基本结构。不同小版本的事件类名和订阅入口略有差异,但整体长这样:
import 'package:json_events/json_events.dart'; final parser = Parser(); parser.events.listen((JsonEvent event) { switch (event) { case JsonArrayStartEvent(): arrayDepth++; case JsonObjectKeyEvent(:final key): currentKey = key; case JsonStringValueEvent(:final key, :final value): if (key == 'traceId') { traceIds.add(value); } case JsonNumberValueEvent(:final key, :final value): if (key == 'costMs') { totalCost += value; } case JsonObjectEndEvent(): processedRecords++; default: break; } }); parser.parse(jsonBlock);事件类型基本上覆盖了 JSON 的所有语法单位:对象开始、对象结束、对象里的 key、字符串值、数值值、布尔值、null、数组开始、数组结束。你在回调里做的事,就等价于“顺着 JSON 的骨架走一遍”。
2.2 为什么事件模型能省内存
一次性解析和事件流解析的核心差异,不在功能,而在“持有数据”的策略上。
一次性解析就像把整本字典复印一份放到脑子里,之后不管查哪个词都很快,但复印的过程已经占满了书架。事件流解析则像拿着放大镜顺着纸面一个字一个字扫过去,当前这一行看得清清楚楚,但看完就翻页,不需要把整本书留在桌上。
落到内存模型上:
jsonDecode的处理过程是:完整文本字符串 -> 递归产生中间 token -> 构建 Map/List 对象图 -> 把字符串值复制到新的 Dart String 对象里。在整个解析周期里,原始文本和对象树并存,再加上 HashMap 扩容和 VM 的 GC 延迟,峰值很高。json_events的处理过程是:文本块进入 -> token 被识别 -> 触发对应事件 -> 回调里拿到值做消费 -> 下一块进来。过去的值如果没有被你自己缓存,就彻底消失了。解析器内部最多维护一个从根到当前节点的路径栈和当前值的缓冲,这部分内存是常量级的。
所以我常说一句话:流式解析省内存不是因为解析算法有多高深,而是因为“不长期持有数据”本身就是最大的优化。你只需要在回调里做累加、过滤、转换、写文件这些即时动作,数据就水过鸭背,完全不占用堆空间。
2.3 流式不是银弹:什么时候别用它
流式解析确实有脾气,它要求你改变思考方式:不能再像用 Map 一样随时往回翻某个字段,只能顺着事件流往下走。这几个场景我建议直接放弃流式,老老实实用jsonDecode:
- JSON 本身只有几十 KB,构建完整对象树的成本可以忽略,硬上流式反而增加代码复杂度。
- 你需要对同一个对象反复随机访问不同字段,并且顺序不固定,纯事件流会逼着你手动缓存。
- 你最终必须把完整对象图交给 UI 层做渲染,那不管中间怎么处理,最后还是要构建一版大对象。
适合用的场景反而非常集中:日志分析、数据管道、ETL、从超大数组里做过滤和聚合、把一个超大 JSON 转成另一种格式落地。你要先判断自己的场景是不是“读一次、处理完就扔”,如果是,流式模型才有收益。
3. 鸿蒙化适配第一步:工程环境和数据源层的改造
3.1 让 Flutter 工程在鸿蒙真机上跑起来的环境要点
在聊 json_events 本身之前,得先把鸿蒙 Flutter 的开发环境说清楚,因为后面所有验证都在这个环境里做。
你需要用支持 OpenHarmony 的 Flutter SDK,也就是华为社区维护的 ohos 分支版 Flutter。安装好之后,用 DevEco Studio 来构建 hap 包。这里最需要注意的就是版本对齐:Flutter 版本对应一套 Dart SDK,而 json_events 这种包对 Dart 版本有最低要求,如果拉下来的 SDK 太旧,编译期就会报 Dart 语法不支持。我在接入前会先跑一遍flutter doctor确认 Flutter、Dart、DevEco 三者版本都在一个已经验证过的组合上,而不是随手拿最新版。
如果你的 Flutter 工程本来就能在鸿蒙真机上跑通一个 hello world,那 json_events 的接入在编译层面基本不会遇到任何问题。真正的工程量在数据源,也就是下一节讲的路径和通道问题。
3.2 json_events 其实是“不需要适配的三方库”
这里我想把话说直白一点:json_events 的鸿蒙化适配,本质上不是改这个库,而是改你的数据通路。
为什么这么说?因为它是一个非常标准的纯 Dart 库,包结构里没有 android/ 目录,没有 ios/ 目录,没有任何平台原生代码,也不通过 FFI 调 C 库。鸿蒙的 Flutter 兼容层对它来说是透明的,它只需要 Dart VM 和dart:async这些基础能力,而体系内 Dart VM 是完整可用的。
所以不要听到“鸿蒙化适配”就觉得要写一堆 plugin 桥接代码。真实工作量集中在两个地方:第一,怎么在鸿蒙沙箱体系下拿到文件或者字节流;第二,怎么把字节流高效地分块喂给解析器,别卡在 IO 和线程调度上。
3.3 第一个数据通路:鸿蒙沙箱文件怎么读
鸿蒙的沙箱路径和 Android 不一样,不能硬编码以前熟悉的/data/data/...这种结构。鸿蒙应用沙箱一般是/data/storage/el2/base/haps/entry/files/这种形态,具体路径受签名和配置影响,在不同设备上可能有差异。
我实际调试时的做法是先在真机上用 hdc shell 进去,找到文件真实路径,确认归属哪个沙箱目录,再决定哪一段用原生读、哪一段用dart:io直接读。dart:io里的File、File.openRead在鸿蒙 Flutter 适配版里是能用的,但如果你要访问的资源本身在 rawfile 里,或者某个受限目录只能通过ohos.file.fs访问,那就需要走原生通道。
这里有一个非常重要的调试原则:分两步验证。第一步先确认能不能读到字节,比如打印文件长度、前几个字节的 hex;第二步再把读到的数据接进解析器。很多人直接把“读文件-解析-处理”一条链路写完,出了问题根本分不清是文件没读出来,还是解析器被喂进了脏数据。
4. 流式解析实战:从原始数据到事件处理的完整链路
4.1 起步:把字符串块喂给解析器
先看一个最小可运行的形态。假设数据已经是一个完整的字符串,直接用parse喂进去:
void parseSingleChunk(String source) { final parser = Parser(); parser.events.listen((JsonEvent event) { // 在这里处理事件 }); parser.parse(source); }这个形态适合小字符串、接口返回的 JSON、或者从某个缓存里拿到的整段文本。但它解决不了大文件问题,因为“把整段文本拿在手里”这件事本身已经违背了流式的初衷。
4.2 大文件链路:openRead + Utf8Decoder 分块喂入
真正的实战是从文件流开始的。File.openRead()会产生一个Stream<Uint8List>,我把它 bind 到utf8.decoder上,再await for逐块拿字符串,喂给 parser。整个过程边读边解析,内存里只有当前块和解析器内部的状态。
Future<void> parseLargeJsonFile(String path) async { final file = File(path); final totalBytes = await file.length(); final parser = Parser(); var bytesRead = 0; parser.events.listen(_onEvent); await for (final text in utf8.decoder.bind(file.openRead())) { bytesRead += utf8.encode(text).length; parser.parse(text); if (bytesRead % (10 * 1024 * 1024) < 65536) { // 每读大约 10MB 打一条日志,方便观察进度 final progress = (bytesRead * 100 ~/ totalBytes); debugPrint('parsed $progress%'); } } await parser.close(); }这里有个坑一定要提醒:流式边界上多字节字符会被拆散。JSON 文件里的中文路径、中文日志描述非常常见,如果直接对每个 chunk 单独做utf8.decode,遇到某个汉字正好被切断在两个 chunk 之间,就会得到乱码甚至解码异常。正确做法就是上面示例里写的:用utf8.decoder.bind()让 Stream 框架自己处理跨块边界,不要手动去分块解码。
4.3 跨端数据链路:鸿蒙原生侧推流给 Dart
还有一种更常见的情况:文件只能通过鸿蒙原生 API 读取,比如 rawfile 资源、需要特定权限才能访问的目录,或者数据源根本不是文件而是某个回传通道。这时候数据在原生侧,Dart 侧想要流式接收,最好的方式是用EventChannel分块推送。
原生侧用ohos.file.fs打开文件,循环读块,每读 256KB 就发到 channel。ArkTS 的整体结构大致是这样:
import fs from '@ohos.file.fs'; const file = fs.openSync(path, fs.OpenMode.READ_ONLY); const length = 256 * 1024; while (true) { const buffer = new ArrayBuffer(length); const readLen = file.readSync(buffer); if (readLen <= 0) break; // 注意:这里必须把字节切片 emit 出去,不能用同一个可变的 buffer this.eventChannel.emit(buffer.slice(0, readLen)); } file.closeSync();Dart 侧对应这样接收:
const EventChannel _channel = EventChannel('flutter_events/array_file'); Future<void> parseFromNative() async { final parser = Parser(); parser.events.listen(_onEvent); await for (final bytes in _channel.receiveBroadcastStream().cast<Uint8List>()) { final text = utf8.decode(bytes); parser.parse(text); } await parser.close(); }这段代码里最值得注意的一点:原生侧如果为了省内存反复复用同一个ArrayBuffer,每次 emit 出去的都是指向同一块内存的引用,Dart 侧后收到的块可能已经被原生侧下一次 read 覆盖了。所以稳妥做法是每读一块就新建 Buffer,不要复用。虽然会多一些分配,但换来的是跨端传输的安全性。
4.4 事件处理端的一个实战应用:从 10 万条记录里提取字段
最后把事件处理端的实践也放上来。假设文件结构是{"records": [{...}, {...}]},每条记录里有deviceId、time、latency三个字段,现在要抽出这 10 万条记录的三个字段写入另一个文件。核心做法是:不构建数组,用两个状态量跟踪“当前是否在目标数组里”和“当前对象的 key”,在JsonObjectEndEvent时把收集到的记录写出去。
final _currentRecord = <String, Object?>{}; String _currentKey = ''; bool _insideRecords = false; void _onEvent(JsonEvent event) { switch (event) { case JsonArrayStartEvent(): if (arrayDepth == 1) { _insideRecords = true; } arrayDepth++; case JsonObjectKeyEvent(:final key): _currentKey = key; case JsonStringValueEvent(:final key, :final value): if (_insideRecords && (key == 'deviceId' || key == 'time')) { _currentRecord[key] = value; } case JsonNumberValueEvent(:final key, :final value): if (_insideRecords && key == 'latency') { _currentRecord[key] = value; } case JsonObjectEndEvent(): if (_insideRecords && _currentRecord.isNotEmpty) { _sink.add(_currentRecord); _currentRecord.clear(); } case JsonArrayEndEvent(): arrayDepth--; if (arrayDepth == 0) { _insideRecords = false; } default: break; } }这段逻辑本身不难,但它体现了一个思考转换:你不再有records[i]['deviceId']这种随机访问,所有字段都必须通过“key 事件先到达、value 事件后到达”的顺序来收集。这也是流式解析唯一需要适应的地方。
5. 内存实测:json_events 在鸿蒙设备上的真实表现
5.1 测试怎么做
口说无凭,我直接在鸿蒙真机上做了压测。测试设备是一台中端鸿蒙真机,8GB 内存,系统后台应用全部清掉。样本是同一份合成 JSON,分别用jsonDecode和 json_events 跑完整解析,两个方案各跑三遍取中位数。
内存指标用 DevEco Profiler 抓进程 RSS,同时用 hdc shell 的进程内存命令做交叉验证。需要注意两点:第一,解析前先触发一次 GC,避免上一次测试的残留对象干扰采样;第二,关闭 Debug 日志输出,因为日志量大时本身也会让内存抖动。
5.2 实测数据
下面是我在具体设备上拿到的近似值,供量级参考,不同设备、不同 JSON 结构会有浮动。
| 文件大小 | jsonDecode 峰值 RSS | json_events 峰值 RSS | 解析耗时对比 |
|---|---|---|---|
| 50MB | 约 780MB | 约 120MB | 1.8s / 2.4s |
| 200MB | 约 2.8GB,已有失败风险 | 约 260MB | 9.2s / 11.5s |
| 500MB | 测试时直接 OOM | 约 530MB | 无法完成 / 31.6s |
5.3 结果解读
先看 50MB 档位:jsonDecode峰值 RSS 已经到 780MB,在单个界面已经非常夸张,不过应用勉强还活着。这种体量下换流式解析的收益是 6 倍左右,但老实说,如果 App 本身内存压力不大,用户不一定感知得到差异。
到了 200MB 档位,jsonDecode基本就是不可用的状态,2.8GB 的峰值 RSS 在任何鸿蒙设备上都会触发系统回收或直接 OOM。而 json_events 只用了 260MB,这就是从“不可用”到“可用”的质变。
到了 500MB 档位,流式方案也逼近 530MB 的峰值了。这个体量已经是边界情况,需要进一步做两件事:一是事件回调里少分配临时对象,比如字符串拼接尽量用StringBuffer;二是对处理端做背压控制,别让解析器的速度追着 IO 的输出跑,后面第六节有具体展开。
耗时方面,json_events 会比jsonDecode慢 20% 到 30%,这是事件分发和字符串转换的合理开销。对于“只要不崩就行”的大文件场景,这个代价完全值得。
6. 适配过程中绕不开的坑:鸿蒙环境里的排查记录
6.1 大数字精度:订单号被解析成了科学计数法
第一次在实际数据上跑的时候就发现了问题:输出记录里的orderId变成了8.45678912345678e+18。根因是 JSON 数字如果被默认按 double 处理,超过 2^53 的整数就会丢精度,而很多业务 ID、时间戳、订单号都是 18 位甚至 19 位。
解决方法是拿到数字事件时不要直接转 double,优先看这个库是否提供返回原始 token 字符串的数值事件。如果需要精确整数运算,就用BigInt.tryParse处理。日志分析和数据管道场景里,这类大整数字段特别常见,一定要提前约定类型。
6.2 UTF-8 BOM:第一个 key 前面多了个不可见字符
第二个坑是编码层面的。有些同事用 Windows 下的工具生成 JSON,文件开头会带 UTF-8 BOM,也就是 EF BB BF 三个字节。如果直接把字节流 bind 给utf8.decoder,这个 BOM 会被当成普通字符,导致事件流里第一个 key 变成\uFEFFrecords,而正常处理逻辑匹配的是records,于是第一个字段全部匹配不上。
处理方式是在文件流的第一个块上做一次判断:如果流的前三个字节是 EF BB BF,就裁掉再交给解码器。Dart 的utf8.decode本身不会主动清理 BOM,这个动作必须自己加。
6.3 Stream 背压:500MB 文件时内存又涨了
前面提到 500MB 文件跑到 530MB 峰值,其中一部分原因就是背压没处理好。现象是:原生侧文件读取的速度远超解析回调的处理速度,如果回调里还涉及数据库写入、网络上报之类的重活,Dart 事件队列里的字符串块就会堆积,内存又涨回来。
解决思路有两个层次。第一层是在 Dart 侧改手动拉取:不要用listen被动接收,而是用await for一读一块、同步解析,解析完再拉下一块。这样天然限流,不会堆积。第二层是在跨端场景做确认信号:原生侧每发 100 块,就等待 Dart 侧回一个“已处理”信号再继续发。这套确认信号模式在大文件跨端传输里非常实用,虽然多了一次往返,但内存曲线能稳下来。
6.4 畸形 JSON 和重复键的容错
最后是数据合规问题。老系统产出的 JSON 经常有不闭合对象、数组末尾多逗号、同一个 key 出现多次这些情况。json_events 是严格解析器,遇到这类问题会在 close 时抛异常。
我的做法是:解析循环外面包 try-catch,抓到异常以后记录它给出的偏移位置,然后把出问题的那个字符串块按边界切成两半,分别再喂给新 parser 重试,用类似二分定位的方式找到坏区。如果坏区本身不影响关键字段,就跳过那一段继续;如果影响,就明确返回解析进度和失败位置。
重复键的问题则需要在事件回调里自己定策略:JSON 规范里同名 key 应该覆盖,但在日志数据里同一个对象里重复 key 往往意味着合并或计数。这个没有标准答案,我的建议是显式选择“保留第一个”或“保留最后一个”,并且打印一行警告,不要静默处理。
最后分享一个实战习惯。我每次接这样的流式解析管线,都会先拿真实数据的一个小子集,比如 2000 条记录,写一个独立的单元测试,事件回调里只统计字段和记录数,不落地不持久化,先把事件流的正确性验证出来。等小样本跑通了,再切真实大文件。这样能把“数据本身的问题”和“代码逻辑的问题”分开定位,排查效率会高很多。json_events 这个库本身很小,它不值得你依赖,但理解它背后的事件流思维,对鸿蒙 Flutter 项目里所有大数据处理场景都有长期价值。