☰
Flutter+鸿蒙双端协议自动化:proto_generator实战记录
2026/9/26 5:29:53 网站建设 项目流程

就算是同一个 App 内的两个端,Flutter 侧和鸿蒙原生侧之间传数据,也会因为协议对不上吵上半天。以前我维护的工程里,协议序列化代码全靠手写,每次加字段、调字段号,第一反应不是改代码,而是先把两边的人都拉齐,否则联调大概率翻车。后来我把 proto_generator 真正引入到鸿蒙工程里,把整套 Protobuf 协议生成链路跑通,才体会到什么叫自动化的“协议生产线”。这篇文章是这次鸿蒙化实战的完整记录,包含方案选型、生成机制、完整实操和踩坑清单,适合正在做 Flutter + HarmonyOS 双端联调、或者打算用代码生成代替手写协议代码的移动端开发者。

1. 为什么是 proto_generator:一次解决协议维护的所有烂摊子

1.1 手写协议的时代,到底痛在哪里

先说个真实场景。之前我所在的工程有三端:Flutter、鸿蒙原生、服务端,同一个“订单”数据结构要写三份序列化代码。服务端用标准 protobuf 的 Java 实现还好,麻烦的是 Flutter 侧和鸿蒙侧。Flutter 侧当时直接用 JSON,字段少了就塞一个Map<String, 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工作。大致流程是这样的:

  1. build_runner根据配置文件扫描指定目录下的 Dart 源码。
  2. proto_generator对每个源文件做 AST 解析,用的不是正则,而是 Dart 官方的analyzer库。
  3. 在 AST 中找到带@Proto()注解的类,遍历它的字段,从@ProtoField(fieldNumber)里读出字段号。
  4. 根据字段类型推断二进制编码方式:int32、int64、string、bytes、enum、嵌套消息、repeated列表。
  5. 生成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 不同,它是字段号驱动的 TLV(Tag-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) List<OrderItem> items; @ProtoField(4) OrderStatus status; @ProtoField(5) Int64 createdAt; }

这里说几个容易踩的点:

  • 每个字段必须显式标注字段号,不能和 protobuf 的“字段名”混淆。字段号一旦发布,就尽量不要改,否则老客户端解析会出问题。
  • Int64不是 Dart 基础类型,需要从fixnum导入,生成代码会把它编码成 protobuf 的 64 位变长整型。
  • repeated字段直接用List<T>,生成器会识别并按 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; List<OrderItem> 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' can't 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 一行行看数据对不对了。

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

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

立即咨询