Flutter+鸿蒙双端协议自动化:proto_generator实战记录
就算是同一个 App 内的两个端Flutter 侧和鸿蒙原生侧之间传数据也会因为协议对不上吵上半天。以前我维护的工程里协议序列化代码全靠手写每次加字段、调字段号第一反应不是改代码而是先把两边的人都拉齐否则联调大概率翻车。后来我把 proto_generator 真正引入到鸿蒙工程里把整套 Protobuf 协议生成链路跑通才体会到什么叫自动化的“协议生产线”。这篇文章是这次鸿蒙化实战的完整记录包含方案选型、生成机制、完整实操和踩坑清单适合正在做 Flutter HarmonyOS 双端联调、或者打算用代码生成代替手写协议代码的移动端开发者。1. 为什么是 proto_generator一次解决协议维护的所有烂摊子1.1 手写协议的时代到底痛在哪里先说个真实场景。之前我所在的工程有三端Flutter、鸿蒙原生、服务端同一个“订单”数据结构要写三份序列化代码。服务端用标准 protobuf 的 Java 实现还好麻烦的是 Flutter 侧和鸿蒙侧。Flutter 侧当时直接用 JSON字段少了就塞一个MapString, dynamic类型全靠嘴巴约定鸿蒙侧用 ArkTS 对象加手动JSON.stringify。结果就是字段号这种东西根本不存在全靠字段名硬匹配两边哪次命名没对齐线上就是一波解析失败。更痛的是联调排错。有一次客户端发过去一个订单列表服务端说“这个字段我怎么读出来是 null”来来回回打了半天日志最后发现 Flutter 侧把payTime写成了pay_time而鸿蒙侧读的是payTime。这种问题如果只是单个字段还好一旦做嵌套对象、列表、枚举手写代码的出错率会直线上升。我当时的体会是协议代码是整个项目里技术债最容易被忽略、但爆发起来最要命的一部分。1.2 proto_generator 是什么它在这条生产线的哪个位置proto_generator 是 Dart/Flutter 生态里一个基于source_gen和build_runner的代码生成器。它的用法和你熟悉的json_serializable很像不用维护独立的.proto文件直接在 Dart 类上用注解标注字段和字段号然后跑一条命令生成对应的二进制序列化代码。打个比方一条流水线的核心是模具。.proto或带注解的 Dart 类就是图纸build_runner是冲压机生成的.pb.dart代码就是从模具里掉出来的零件CI 里的校验脚本则是质检员。整个链条跑通以后你只改图纸不碰零件。我当时选择它而不是直接用protoc插件有一个很现实的原因工程里 Flutter 侧的模型类已经存在且大量字段就是 Dart 类型用protoc还得维护一套.proto文件再花精力转换 Dart 类型。proto_generator 直接注解驱动改动面最小对存量工程更友好。1.3 为什么必须过鸿蒙化这道坎这个问题得从实际情况说起。Flutter 跑在鸿蒙系统上整体工程结构和标准 Android Flutter 工程有明显区别鸿蒙侧的代码在entry/src/main/ets下Flutter 侧代码作为一个独立模块存在两边的构建体系分别是 Hvigor 和 Gradle。如果只是把build_runner跑通生成一堆 Dart 文件那只是完成了一半真正关键的是鸿蒙侧也得有对应的二进制编解码能力两边字节流一致数据才传得过去。所以“鸿蒙化”不只是把 Dart 侧的工具链跑通更要把“协议的二进制表示”在 Dart 和 ArkTS 之间彻底打通。否则一切又回到手写协议的死循环。这也是我认为 proto_generator 在鸿蒙化场景下最有价值的原因它能把 Flutter 侧耗时最多的编解码代码全部自动化让开发者把精力集中在鸿蒙侧这一个点上。2. proto_generator 的关键机制与鸿蒙化需要啃的硬骨头2.1 注解驱动的生成链路Proto 到 .g.dart 之间发生了什么要真正用好 proto_generator你得理解它内部做了什么。它本身是source_gen生态里的一个Generator配合build_runner工作。大致流程是这样的build_runner根据配置文件扫描指定目录下的 Dart 源码。proto_generator对每个源文件做 AST 解析用的不是正则而是 Dart 官方的analyzer库。在 AST 中找到带Proto()注解的类遍历它的字段从ProtoField(fieldNumber)里读出字段号。根据字段类型推断二进制编码方式int32、int64、string、bytes、enum、嵌套消息、repeated列表。生成writeToBuffer、readFromBuffer之类的方法把对象序列化成 protobuf wire format或从字节流反向解析。这里选择 AST 而不是字符串匹配原因很直接协议类经常有嵌套、继承、泛型字符串正则根本没法稳定解析类型信息。而且 AST 能拿到精确的类型引用生成的代码更可靠。我见过不少手写模板引擎的项目改一处缩进就崩一次最后都是老老实实回到 AST 这条路。2.2 鸿蒙化要啃的四个硬骨头工具链、part、类型精度、互操作格式真正把 proto_generator 移植到鸿蒙工程我总结下来有四块最难啃的骨头。第一块工具链环境。鸿蒙工程里跑build_runner需要 Flutter SDK 和 Dart SDK 先就绪。听起来简单但实际很多团队的鸿蒙开发机上 Flutter 环境是后装的flutter doctor没过就直接编译导致dart run都起不来。这块没有技巧先把环境整干净。第二块Dart 的part机制。proto_generator 默认生成part of xxx.dart形式类似json_serializable的xxx.g.dart。原因很简单part 文件能访问主库的私有变量。但鸿蒙工程里 Flutter 模块目录结构和标准 Flutter 工程不完全一样跨模块引用时容易报“part 文件必须在同一 library”的错误。我的做法是关闭 part 模式让生成文件成为独立库显示import使用。代价是私有字段访问不了但协议类的字段本来就该是公开的问题不大。第三块int64精度。protobuf 里int64很常见Dart 侧生成代码会借助fixnum这个包来表示Int64保证序列化时 64 位精度不丢。问题是鸿蒙侧的 ArkTS 没有原生 64 位整型number在浏览器/ArkTS 引擎里是 IEEE 754 双精度浮点尾数只有 53 位有效。雪花 ID、订单大 ID 这种超过 2^53 的值一过去就丢精度。这块没有任何生成器能自动解决必须在协议设计层就做出取舍。第四块互操作格式。protobuf 的二进制格式和 JSON 不同它是字段号驱动的 TLVTag-Length-Value结构。两端的字段号哪怕有一个错位解析结果都是垃圾数据编译期完全看不出来。所以我把字段号的维护写进了代码评审清单还加了 CI 校验脚本后面会详细讲。3. 鸿蒙工程里跑通 proto_generator 的完整实操3.1 环境准备Flutter、鸿蒙 SDK 与工程的目录规划先说我这次的环境Flutter 3.x 版本OpenHarmony SDK 配套的 DevEco Studio鸿蒙侧使用的 API 相对较新支持 ArkTS 完整特性。Flutter 的安装和配置这里不展开说但有一点值得提醒鸿蒙适配的 Flutter SDK 分支和官方分支不完全一样flutter doctor有告警很正常只要关键项绿色就能继续用。建议用 fvm 管理多个 Flutter 版本避免切工程时 SDK 路径乱掉。工程结构方面我强烈建议把协议模型放到独立的 Dart 包目录而不是塞在lib根目录里。比如flutter_module/ lib/ protocol/ order_models.dart user_models.dart src/ ...这样做的原因是鸿蒙侧要引用“协议定义”这个共享概念目录独立后后续如果要做自动生成 ArkTS 协议代码也只需要盯住这一个目录改动范围可控。依赖方面在pubspec.yaml里加dependencies: protobuf: ^3.1.0 fixnum: ^1.1.0 dev_dependencies: build_runner: ^2.4.8 proto_generator: ^0.0.6注意proto_generator是 dev 依赖只在编译生成时用不会打进产物protobuf和fixnum是运行时依赖生成代码会 import 它们。3.2 定义协议模型用注解描述消息我以一个订单协议为例字段故意用到了string、Int64、List、enum这样基本把常见复杂度覆盖了。import package:proto_generator/proto_annotations.dart; import package:fixnum/fixnum.dart; enum OrderStatus { created, paid, shipped, completed, } Proto() class OrderItem { ProtoField(1) String sku; ProtoField(2) int count; } Proto() class Order { ProtoField(1) String orderId; ProtoField(2) Int64 userId; ProtoField(3) ListOrderItem items; ProtoField(4) OrderStatus status; ProtoField(5) Int64 createdAt; }这里说几个容易踩的点每个字段必须显式标注字段号不能和 protobuf 的“字段名”混淆。字段号一旦发布就尽量不要改否则老客户端解析会出问题。Int64不是 Dart 基础类型需要从fixnum导入生成代码会把它编码成 protobuf 的 64 位变长整型。repeated字段直接用ListT生成器会识别并按 repeated 规则编码。字段没有默认值时如果业务上允许“未设置”用int?或Int64?而不是裸类型避免序列化时误判为空值。3.3 运行 build_runner 生成代码协议模型定义好之后在 Flutter 模块根目录执行dart run build_runner build --delete-conflicting-outputs这个命令会扫描整个包的源码找到带 Proto() 的库生成对应的.pb.dart文件。--delete-conflicting-outputs很有用因为生成器如果中途变更了输出方式旧文件不会自动删不加这个选项可能会看到“输出文件已存在但内容不一致”的报错。生成的代码结构大致是这样// generated by proto_generator, DO NOT EDIT class Order { String orderId; Int64 userId; ListOrderItem items; OrderStatus status; Int64 createdAt; void writeToBuffer(ProtoBufferWriter writer) { writer.writeString(1, orderId); writer.writeInt64(2, userId); // ... } static Order readFromBuffer(ProtoBufferReader reader) { // 按 Tag 分发字段号不存在的直接跳过 } }实际生成的代码比我这个伪代码复杂但核心就是这两个方法。之后业务代码里发送侧调writeToBuffer接收侧调readFromBuffer不再需要写任何手动的字节操作。这里我要多说一句别把生成代码当普通源码去人肉修改。它上面写着 DO NOT EDIT你改了下次build_runner一跑就被覆盖。协议变更的正确姿势是改注解定义再重新生成。3.4 鸿蒙侧对接手写等效协议类与二进制联调Flutter 侧代码生成好了鸿蒙侧怎么对接这是我的做法鸿蒙侧先不引入任何重型代码直接用 ArkTS 写一个等效的协议类实现同样的字段号映射。消息结构固定时这个类并不复杂无非是Uint8Array的写入和读取。核心代码如下class OrderCodec { static Uint8Array encode(order: Order): Uint8Array { let buffer new Uint8Array(estimatedSize(order)); let offset 0; offset writeString(offset, buffer, 1, order.orderId); offset writeInt64(offset, buffer, 2, order.userId); // ... return buffer.subarray(0, offset); } static Order decode(bytes: Uint8Array): Order { let reader new ProtoReader(bytes); let order new Order(); while (reader.hasNext()) { let tag reader.readTag(); switch (tag.fieldNumber) { case 1: order.orderId reader.readString(); break; case 2: order.userId reader.readInt64(); break; // ... } } return order; } }随后做端到端验证。我的测试方式是先写一个固定的Order对象鸿蒙侧编码成字节数组用日志把 hex 打出来Flutter 侧用readFromBuffer解析断言每个字段等于原值。这一步非常建议做成自动化单测因为二进制格式只要有一次字段号错位答案就会变成一堆乱码手动排错很痛苦。如果你不想在鸿蒙侧完全手写编解码也可以找基于 protobufjs 移植到 ArkTS 的运行时库但引入第三方库之前先想清楚你的协议模型是不是相对固定如果字段变动频繁手写类维护成本高那还是早点引入运行时库比较划算。4. 踩坑实录编译错误、版本警告与数据错位排查4.1 “The current configured Flutter SDK is not known to be fully supported” 的真相这个警告我猜用鸿蒙 Flutter 分支的人都会见到。运行任意flutter命令控制台都会跳出The current configured Flutter SDK is not known to be fully supported.很多新手看到这个就慌了以为装错了。其实这是 Flutter 工具链的健康检查逻辑工具里内置了一份 SDK 版本白名单网上的版本如果不在名单里就会提示“不能保证完全支持”。鸿蒙适配分支往往不在官方白名单所以必然弹这个告警。我的处理方式分两步。第一步先跑flutter doctor -v确认 Dart 编译器、Android toolchain如果需要都正常。只要关键项没有问题警告可以暂时忽略。第二步如果团队持续维护这个分支可以把警告开关打进环境变量里或者修改 SDK 内部的版本检查文件让 CI 日志干净一点。这里我不建议直接改 SDK 源码维护成本太高。4.2 part 文件与 import 路径的艺术proto_generator 默认生成part of形式。这在普通 Flutter 工程里没问题但在鸿蒙工程的 Flutter 模块里我第一次跑完build_runner编译直接报The imported library xxx.pb.dart cant be part of the library xxx.dart原因是模块的lib目录里部分代码被其它模块引用时part 文件的解析路径和预期不符。我后来改成生成独立库模式具体是调整 Builder 的配置让生成文件不带part of指令改成正常库文件业务侧import使用。这个改动对 proto_generator 生成器来说是可配置的你翻一下它的 README 就能找到开关。实际操作中还有一个小坑生成文件名最好和模型文件名一致比如order_models.dart生成order_models.pb.dart。这样在 IDE 里查找文件时看到一个pb后缀就知道是生成物不会手滑去编辑。4.3 int64 精度是怎么在跨端场景悄悄丢掉的这是我们踩过最深邃的坑。现象是Flutter 侧解析鸿蒙端编码的字节流大部分字段都正常唯独一个userId变成了错乱的数字。查了很久最后发现罪魁祸首就是 ArkTS 的number类型。鸿蒙侧把Int64转成 number 时通常写法是Number(byteReader.readBigInt64())但超大数字会先被转成浮点精度就丢了。而编码侧再写回的时候已经不是原来的那个整数了。我的建议是涉及超过 2^53 的 ID、时间戳直接用string字段承载两边的字符串不会丢精度编解码逻辑也最简单。业界不少团队也是这么干的ID 用字符串传输宁可多几个字节换可靠性。如果你确实要用真正的int64那就得在 ArkTS 侧把高位和低位拆开存自己实现一个 Int64 类工作量会大不少。4.4 build_runner 的缓存陷阱与并发问题build_runner 用多了会发现它有个.dart_tool/build目录里面存了增量编译的缓存。好处是二次构建快坏处是偶尔“改了注解但生成代码没变”这种时候大家第一反应该都是“是不是生成器坏了”。其实不是是缓存没失效。遇到这种问题先别急着重装依赖删掉.dart_tool/build再跑一次rm -rf .dart_tool/build dart run build_runner build --delete-conflicting-outputs还有一个并发问题。如果 CI 里同时起了两个 job都在同一个工作目录跑build_runner第二个进程大概率会直接报错退出因为.dart_tool下有把文件锁。我的 CI 脚本里加了串行限制或者让协议生成 job 独立成一个 stage不跟测试并行。4.5 问题排查速查表最后整理一张速查表都是这次实战里遇到的真问题建议收藏。症状可能原因处理方式flutter 命令提示 SDK 不支持SDK 版本不在工具白名单验证 doctor 后忽略或用环境变量关闭检查生成文件报 part 路径错误part 机制跨模块路径不受支持关闭 part of改成独立库大整数跨端解析错误ArkTS number 精度不足字段改 string 传输改注解后生成代码不变build_runner 增量缓存失效删 .dart_tool/build 重跑两个进程同时跑生成报锁错误目录文件锁冲突CI 里串行执行两端字节正确但解析出乱码字段号不一致检查 tag 映射做 hex dump 比对4.6 性能验证自动生成代码和手写代码差多少有人会担心生成代码有性能损耗。我针对订单协议做了个简单压测连续构造 10000 个Order对象分别用 JSON 序列化和 protobuf 生成代码序列化统计耗时和体积。结果是 protobuf 的体积只有 JSON 的 1/3 左右序列化耗时是 JSON 的 1/2 以下。解析的差距更明显因为 protobuf 是纯字段号驱动没有字符串 key 的 hash 查找。生成代码还有个额外优势它是针对每个字段直接写死的操作没有反射、没有动态注解解析Dart VM 对这类线性代码的优化很友好。在追求 60fps 的动画页面旁边跑这种序列化逻辑也不会产生明显的卡顿。最后再分享一个我自己的使用习惯每次跑完build_runner我都会顺手看一眼生成文件的 diff确认新增了哪些字段和字段号。提交代码时在 MR 描述里贴一句“协议变更新增 orderStatus 字段号 4”而不是只写“更新协议”。这个习惯在 Flutter 和鸿蒙双端协作的项目里特别有用因为对端开发者打开 MR 就能知道该同步改哪块少了很多“哦我不知道你改了协议”的情况。协议这条“生产线”跑稳之后后面每个版本加字段都变成一件很例行的事情。遇到要新增一个消息类型我的流程变成了定义 Dart 注解模型 → 跑命令生成 → 同步宇端 → 跑一次跨端单测。整个过程不会超过十分钟再也不用盯着 hex dump 一行行看数据对不对了。