先说一个真实的场景。我手上有一块跑着 OpenHarmony 的开发板,上面用 Flutter 做了个端侧应用,后端是公司自建的 tRPC 服务集群。按理说 Flutter 的HttpClient够用了,但我们的场景不是简单 REST 接口,而是有一套完整的 IDL 契约、多种消息类型、需要流式交互和连接复用的端云一体通信。我把社区里的trpc_client三方库直接拉进鸿蒙工程后,发现事情远没有想象中顺利:编译过了,真机上一跑就报网络栈初始化异常,Dart 侧的错误信息指向dart_vm_initializer,日志里还能看到 unhandled exception。折腾了几天,最终把整个传输层按鸿蒙的网络能力重写了一遍,才算把这套"端云契约"在鸿蒙端跳顺了。
这篇文章我就把整个鸿蒙化适配过程拆开讲:为什么一定要在鸿蒙端做 tRPC、传输层怎么换血、契约代码怎么落地、真机实测数据是什么样的,以及后续维护要注意什么。适合正在做鸿蒙 Flutter 混合开发、或者打算把现有 Flutter 项目迁到鸿蒙生态的团队参考。
1. 为什么偏偏要在鸿蒙端做 tRPC:端云一体的契约价值
1.1 tRPC 先定义契约,再写代码:前后端在同一份 IDL 上跳舞
tRPC 最大的特点和 REST 不一样的地方在于:它强调"契约先行"。服务端和客户端共用一份 IDL 文件描述接口、消息结构、字段约束,通过代码生成器分别产出服务端和客户端的桩代码。客户端拿到的Service对象在编译期就决定了能调哪些方法、请求和响应长什么样,字段拼错了直接编译报错,而不是跑到线上返回一个模糊的 400。
这意味着前后端不会因为接口文档不同步而互相踩脚。放到鸿蒙这个场景里,端侧 Flutter 代码、云侧 Go 或 C++ 服务,只要 IDL 还是同一份,两端就像跳同一支舞,谁也不能擅自改步子。我最初被 tRPC 吸引,也正是因为这一点:团队里多端并行,契约一旦定下来,Android、iOS、鸿蒙、云函数都能按同一套桩代码开发,联调成本被砍掉一大截。
当然契约驱动也有它的代价。IDL 改一个字段,所有端都要重新生成代码、重新发版。但比起没有契约的 REST 接口在线上因为某个字段改名引发的事故,这点代价完全值得。
1.2 端云一体场景下的传输诉求:不只是 HTTP 调用
鸿蒙生态经常谈"端云一体",很多人以为就是把接口地址指向云函数就行。但端云一体的深层诉求是:端侧和云侧不只是"能通信",还要"通信得稳、通信得高效、通信得有状态"。比如登录态要透传、超时要在端侧统一拦截、云端下发的数据流要能持续推送,这些都是普通 HTTP 短连接很难优雅处理的。
tRPC 的传输层天生支持连接复用、请求多路复用、流式数据推送和单向通知,这些正是端云一体场景需要的。云函数或业务网关可以暴露成 tRPC 服务,端侧通过同一个长连接完成多次调用,减少握手开销,弱网环境下的表现也比每次新建连接好很多。我在做这套适配时,目标就很清晰:不是把 WebSocket 或 HTTP 硬套上 tRPC 的壳,而是把 tRPC 真正的二进制协议在鸿蒙端跑通,保留它对连接、流式、超时、拦截器的完整语义。
1.3 鸿蒙生态下 Flutter 网络栈的真实处境
鸿蒙的 Flutter 适配,底层不是 Android 的 TCP 栈,也不是 iOS 的 Network.framework,而是 OpenHarmony 自己的网络能力。我在开发板上实测下来,dart:io的Socket、HttpClient能用,但表现和原生环境有差距,尤其在 HTTPS 双向认证、HTTP/2 多路复用、连接迁移这些偏底层的特性上,支持程度参差不齐。
我用 Flutter 自带的HttpClient去连一个 HTTP/2 的 tRPC 服务端,日志里能看到它退回 HTTP/1.1 的迹象,这直接影响连接复用和流式调用的性能。后来我果断决定不再依赖 dart:io 的完整能力,而是把网络收发下沉到鸿蒙侧,用 OpenHarmony 的网络接口实现连接管理,再通过方法通道桥回 Dart 层做协议编解码。这也成了这次鸿蒙化的核心思路。
2. 动手前的摸底:trpc_client 的依赖边界与鸿蒙能力盘点
2.1 trpc_client 内部就这么四块:协议、传输、序列化、拦截器
任何 RPC 客户端,拆到最后都逃不过四块:协议层、传输层、序列化层、调用链层。trpc_client在 Dart 侧也一样。
协议层负责把业务消息包成 tRPC 帧:head 里放魔数、版本、消息类型、消息 ID、调用超时和 body 长度,body 里放序列化后的业务数据。传输层负责把帧从本机发出去、把响应收回来,同时维护连接池和读写缓冲。序列化层在 Dart 这边主要是 Protobuf 编解码,把 IDL 生成的对象转成字节、把字节还原成对象。调用链层则是拦截器、超时控制、重试、日志这些横切逻辑的挂载点。
这几块的依赖关系是单向的:调用链依赖序列化,序列化依赖协议,协议依赖传输。所以鸿蒙化改造时,只要传输层的行为不变,上层基本不用动。这给了我一个重要的判断依据:适配的核心不在协议编解码,而在传输层能不能在鸿蒙环境里可靠地收发包。
2.2 鸿蒙 Flutter 引擎里哪些网络能力是实实在在可用的
动手之前我把开发板上 Flutter 引擎的网络能力过了一遍。dart:io的Socket和HttpClient可以创建连接,但有两个现实限制:
第一,HTTP/2 支持不稳定。Flutter 的HttpClient本身对 HTTP/2 支持就是跛脚的,在鸿蒙适配版里这个问题被放大了,主要表现在连接复用逻辑失效,每次请求都像新建连接,延迟高得离谱。
第二,TLS 证书校验策略与鸿蒙系统安全双签名的集成还需要打磨。鸿蒙应用如果要做自定义证书校验,走dart:io的BadCertificateCallback往往不如走系统原生网络接口顺手。
我在真机上用hdc抓日志,发现部分 socket 写操作在弱网下会直接抛异常,而且不会自动重连。这些问题单独看都能绕,但叠加在一起,意味着直接把trpc_client原样搬到鸿蒙上是不可行的。
2.3 适配方案选型:MethodChannel 桥接,还是纯 Dart 替换传输层
我考虑过两条路线,列个表对比一下比较直观。
| 方案 | 实现路径 | 优点 | 缺点 |
|---|---|---|---|
| 整体桥接 | 整个 tRPC 调用由鸿蒙原生实现,Dart 侧只通过 MethodChannel 发起 | 原生网络能力直接用,性能潜力最大 | IDL 生成代码和拦截器链路要在鸿蒙侧重写,工作量大且容易分裂 |
| 仅下沉传输层 | 协议编解码、序列化、拦截器留在 Dart,只把字节流收发桥到鸿蒙网络接口 | 上层逻辑完全复用,改动面小 | 需要自己管理通道生命周期和线程切换,编码要小心 |
我最终选了第二条路。原因很直接:Dart 侧已经把协议、序列化、拦截器写得挺完整了,推翻重来成本太高,而且以后上游trpc_client更新时,改动全堆在鸿蒙侧,维护会很痛苦。只替换传输层,改动边界清晰,上游合代码也相对容易。
具体做法是:保留 Dart 侧的帧编解码,把"建立连接、发送指定缓冲区、接收指定长度数据、关闭连接"这几个原子操作抽象成接口,让鸿蒙侧通过 MethodChannel 实现这些接口。Dart 侧完全不知道底层是 Socket 还是系统网络库,它只关心字节流能不能按预期的顺序进出。
3. 核心改造:把传输层从 dart:io 换成鸿蒙网络能力
3.1 第一步:抽出 Transport 接口,把传输和协议彻底解耦
改动前先做接口抽象。我在trpc_client的外层定义了一个Transport抽象类,只暴露四个方法:connect、send、receive、close。
abstract class Transport { Future<void> connect(TransportConfig config); Future<void> send(Uint8List data); Stream<Uint8List> receive(); Future<void> close(); }这样做的理由很朴素:协议层只认字节流,它不管字节是从哪来的。抽象出Transport之后,dart:io的SocketTransport和鸿蒙的HarmonyTransport可以共存于同一个代码库里,通过配置项切换。我在适配阶段保留原来的 Socket 实现,方便在真机上做对照测试,确认鸿蒙实现没问题再默认切换。
接口里receive返回的是一个Stream<Uint8List>,这一点很关键。tRPC 的响应帧到达是异步且可能分片的,Stream 天然适合表达"数据分块到达"这一语义。协议层拿到 Stream 后,内部按帧长度做缓冲拼接,head 里的 body 长度字段决定一个完整帧什么时候算到齐。
3.2 第二步:封装鸿蒙网络客户端,给 Dart 侧一个干净的桥
鸿蒙侧的网络能力,我通过一个NetworkClient的 ArkTS 类封装起来,对外暴露connect、write、read、close。Dart 侧通过 MethodChannel 调用,通道名类似harmony_trpc/channel,每次请求用递增的 requestId 对应。
这里有个细节常被忽略:MethodChannel 不适合高频小数据包的流式传输,它的调用开销比直接内存读写高一个量级。所以我在设计时把"连接"和"读写"分开:连接阶段用 MethodChannel 建连,成功后鸿蒙侧把该连接对应的 socket fd 或句柄缓存住,后续数据读写通过一个独立的、基于 FIFO 或事件监听的通道来搬运。实际工程里我用的是鸿蒙的BackgroundTaskManager搭配事件回调,把接收到的字节块主动推给 Dart,而不是让 Dart 反复来轮询。
如果你不想引入太重的机制,一个简化的方案是用EventChannel做接收推送、MethodChannel做发送和关闭。接收方向由于是服务端主动下发的数据,用 EventChannel 很自然。发送方向因为是请求驱动,用 MethodChannel 也够用。我在真机上测下来,单连接每秒能处理几千个小包,瓶颈反而在 Protobuf 编解码上。
3.3 二进制帧的编解码:head+body 怎么在鸿蒙传输层进出自如
tRPC 的帧结构是典型的 length-prefix 设计:head 里的 body 长度字段告诉接收方 body 占多少字节。Dart 侧协议层在receiveStream 上做帧缓冲时,必须处理"半包"和"粘包"问题。
我在适配过程中真实遇到的场景是:鸿蒙侧一次 read 回调里可能只有半个 head,也可能一次带了两个完整请求的响应。所以帧缓冲不能简单按"读一次拼一次",而要按照状态机来走:先缓存到一个BytesBuilder,不断尝试从缓冲里解析 head,拿到 body 长度后,再判断缓冲里的字节数是否已经满足head长度 + body长度,满足才切出一个完整帧。
Uint8List? tryParseFrame(BytesBuilder buffer) { final bytes = buffer.toBytes(); if (bytes.length < headLength) return null; final bodyLength = ByteData.sublistView(bytes, headOffset, headOffset + 4) .getUint32(0, Endian.big); if (bytes.length < headLength + bodyLength) return null; final frame = Uint8List.sublistView(bytes, 0, headLength + bodyLength); return frame; }这个解析逻辑放在 Dart 侧的好处是:鸿蒙侧只需要保证"字节按序到达",不用理解 tRPC 的业务语义,两边职责清晰。以后如果协议升级,只改 Dart 侧解析就行,鸿蒙桥不用动。
3.4 连接复用与超时控制:性能的生命线
传输层重写后,连接复用成了最需要盯的性能点。tRPC 在长连接上会跑很多个请求,每个请求有一个唯一消息 ID,响应回来时靠 ID 匹配到对应的调用方等待队列。这就相当于多路复用:一条连接同时有多个在途请求,而不是排队等前一个完成再发下一个。
鸿蒙侧建连后,我在 Dart 侧维护一个Map<int, Completer<Uint8List>>,key 是消息 ID,value 是等待响应的 Completer。数据流到达时,协议层解析出消息 ID,找到对应的 Completer 并 complete,调用方从await中恢复。这个映射表在高并发下要注意清理,超时或被取消的请求要主动从表里移除,否则会内存泄漏。我踩过一次:某个调用超时后没有移除 Completer,结果响应晚到了几秒,发现时调用方已经放弃,但 Completer 永远悬在那里,连接上的消息 ID 也没回收,跑久了内存和消息 ID 池都被吃光。
超时控制要分两层:连接层超时和请求层超时。连接层超时指 connect 几秒内没成功就失败;请求层超时指发出请求后多久等不到响应就返回超时错误。我在鸿蒙化的传输层里把这两个超时都做成可配置的,默认连接 5 秒、请求 3 秒,弱网场景会调到连接 10 秒、请求 5 秒。注意超时时间不是越长越好,太短在跨网络链路下容易频繁失败,太长用户感知卡顿明显,要根据业务场景调。
4. 契约侧落地:Protobuf 生成代码与泛型序列化的坑
4.1 鸿蒙 Flutter 工程里接 protoc 编译链
协议传输层解决之后,真正让业务跑起来的是契约代码。trpc_client依赖 Protobuf 生成的 Dart 代码,但这些代码在鸿蒙工程里不能直接编译进产物,得先解决两个问题:protoc 工具的接入,以及生成代码对 Dart 标准库的依赖是否完整。
我在工程里用protoc配合protoc-gen-dart插件,把 IDL 文件生成 Dart 代码,输出到独立目录。这个流程和 Android、iOS 环境没什么区别,唯一的坑在 CICD:鸿蒙工程的构建流程经常要先跑hvigor再跑 Dart 编译,如果 protoc 步骤放在 Dart 编译之后,生成代码变化时会产生一次无效构建,浪费很多时间。我最后把 protoc 步骤挂在了资源预处理阶段,确保 IDL 一变,生成代码先更新,后面的编译直接用最新产物。
另外,生成代码里会有大量基于dart:typed_data的Uint8List操作。鸿蒙 Flutter 引擎对dart:typed_data支持没有问题,但要注意生成代码里如果有package:fixnum之类的依赖,需要在pubspec.yaml里显式声明版本,不要指望传递依赖,否则鸿蒙工程的依赖解析器可能选错版本。我遇到过fixnum版本不一致导致 int64 字段解析错位的诡异问题,排查了很久,最后锁版本解决。
4.2 IDL 生成代码在 ohos 上遇到的兼容性问题
生成代码本身不依赖 dart:io,理论上纯计算,所以兼容性风险不高。真正麻烦的是 tRPC 的运行时封装,比如连接鉴权、请求头组装、以及把业务异常转换成统一的错误码。这些运行时逻辑在鸿蒙上跑,偶尔会踩到DateTime、Random这类和系统时间源相关的 API。鸿蒙设备如果时间同步不准,DateTime.now().millisecondsSinceEpoch会有偏差,影响请求超时时间戳计算。
另一个容易被忽略的点是:IDL 生成的 service 类是抽象接口,真正调用时要注入一个"调用器"对象。trpc_client里的调用器负责把方法名、请求体、响应类型组合成一个完整调用。适配时,我建议把调用器与 Transport 解耦,让调用器只依赖一个抽象的Invoker接口。这样鸿蒙侧换传输层时,业务侧生成的 service 代码一行都不用改。
4.3 用泛型封装 Service:请求、响应、拦截器串起来
为了让业务方用起来不痛苦,我用泛型封装了一个TypedService,把"请求对象、响应对象、服务名、方法名"一次性传进去,内部走统一的调用链路。
class TypedService<Req, Resp> { final Invoker invoker; final String serviceName; final String methodName; Future<Resp> call(Req request) async { final resp = await invoker.invoke<Req, Resp>( serviceName, methodName, request, ); return resp; } }泛型封装的好处是业务代码干净,但泛型在 Dart 里是编译期擦除的,运行时拿不到Resp具体类型。所以内部invoke必须通过TypeToken或显式传类型信息来反序列化。这个坑很隐蔽:如果你只写invoke<Req, Resp>(...),在 Dart VM 上运行时Resp的类型参数已经没了,反序列化时只能拿到一个空类型。我在鸿蒙真机上第一次跑就遇到这个,响应解析出来全是 null,后来在调用器里显式传递Parser<Resp>实例才解决。
拦截器链也建议在泛型封装外层挂载:日志、埋点、熔断、重试这些逻辑放在泛型类外面,保证每个业务 service 都默认走同一套横切逻辑,又不用在每个 service 里重复写。我实测下来,鸿蒙端的日志拦截器在弱网环境下特别有价值,它能记录每一次请求在传输层的具体耗时,配合鸿蒙的hdc日志定位慢请求很管用。
5. 端云一体实测:模拟器到真机的真实数据与踩坑记录
5.1 测试环境与压测用例怎么搭
适配完成后,我没有直接上生产环境,而是先在本地搭了一套测试拓扑:一台开发板或模拟器跑鸿蒙应用,PC 上运行一个 trpc 服务端,两边通过局域网互通。服务端我写了一个简单的 echo 方法,入参是一个嵌套结构体,出参原样返回,这样能验证序列化和协议帧的完整性。
压测用例分三类:
- 单请求延迟:1000 次连续调用,统计 P50、P95、P99。
- 并发能力:模拟 50 个并发请求同时发出,观察连接是否稳定、消息 ID 是否都能匹配。
- 流式场景:服务端每 200ms 推送一条消息,持续 30 秒,验证 EventChannel 推送链路不丢包、不乱序。
调试时用hdc抓日志很关键,鸿蒙真机上如果开了无线调试,配合hdc hilog能同时看到 ArkTS 侧网络日志和 Dart 侧异常栈,定位问题比在模拟器里高效得多。我在真机上遇到的许多奇怪问题,都是通过 hilog 里几个关键时间戳对齐后定位的。
5.2 首包延迟、吞吐量、连接复用:一组有代表性的结果
我把鸿蒙适配版和 Android 原版(同一个 trpc_client 在 Android 上跑的)做了对照,数据有代表性但不算极端,仅供参考。
| 指标 | 鸿蒙适配版 | Android 原版 |
|---|---|---|
| 首包延迟(P50) | 12ms | 9ms |
| 首包延迟(P95) | 28ms | 20ms |
| 50 并发成功率 | 99.6% | 99.8% |
| 单连接在途请求上限 | 256 | 512 |
鸿蒙适配版的首包延迟比 Android 高 3ms 左右,主要来自 MethodChannel 桥接和 EventChannel 推送的额外调度开销。50 并发下成功率差距不大,说明传输层重写后的稳定性是及格的。单连接在途请求上限我保守设置成 256,是考虑到鸿蒙侧读写缓冲和 Dart 侧消息 ID 映射表的压力,这个值可以按业务模型调,但不建议拍脑袋设大。
需要注意:如果你在测试时发现并发成功率掉得厉害,先别急着怀疑鸿蒙网络栈,大概率是 Dart 侧消息 ID 映射表在高并发下出现塞车。我在 200 并发时见过低于 95% 的成功率,排查到最后发现是Completer的 complete 操作在同一个 isolate 事件循环里排队太久,后来把映射表的 key 改成整数 ID、并限制单条连接同时在途不超过 256,才正常。
5.3 弱网断线与并发异常:重试幂等性排查
真正让我头疼的是弱网断线。我把开发板 Wi-Fi 断掉再秒连,模拟移动网络切换的场景,tRPC 连接直接断开,Dart 侧还没感知到,下一批请求进来时发现 Transport 已经死了。这个问题如果不处理,线上表现为偶发请求失败,很难复现。
我的处理思路是给 Transport 加一个心跳探测:每隔 30 秒发一次 ping,如果连续两次 ping 没有 pong,就标记连接不可用,并触发重连。重连逻辑要放在调用链的"重试拦截器"里,而不是放在单次请求里,因为连接级故障会影响后续所有请求。
重试还有一个必须注意的点:区分幂等请求和非幂等请求。tRPC 请求在超时或断线重连后,服务端可能已经执行了操作,只是响应丢了。如果业务方法本身不幂等,重试会导致重复扣款、重复下单。所以我在拦截器里给每个请求打一个Idempotent标记,只有标记为幂等的请求才自动重试,其他的一律直接返回错误,让上层业务决定怎么处理。
6. 鸿蒙化之后的维护建议:版本同步与协议兼容
6.1 上游 trpc_client 更新后怎么跟着合
鸿蒙化最怕的不是开发期,而是维护期。上游trpc_client每隔一段时间会更新协议解析、序列化生成代码的规则,这些更新大部分在protocol和serialization目录,和传输层基本不重叠。所以我在本地代码仓库里做了明确目录划分:上游原文件放在upstream/子目录,鸿蒙适配的桥接代码放在harmony/子目录,两者用一条显式的适配层隔离。
上游更新时,我的合代码流程是:先跑一遍 diff,看有没有改动到Transport依赖的接口;如果协议层新增字段或新增消息类型,只影响协议解析逻辑,理论上传输层无感。如果上游改了传输层接口,那我这边的HarmonyTransport就要跟着做适配。这个流程跑下来,单次合代码成本基本控制在一小时以内。
6.2 协议版本兼容:做好灰度兼容的缓冲
协议兼容是个常被忽视但很重要的点。tRPC 协议本身有版本字段,但业务侧 IDL 的演进更快。我维护了一个小的兼容策略:所有 IDL 字段只做"新增"不做"删除",新增字段全部用 optional 修饰。这样老客户端拿到新响应时,未知字段会被忽略;新客户端拿到老响应时,optional 字段为空,代码里做好默认值兜底即可。
在鸿蒙端这个策略同样适用,而且更关键,因为鸿蒙设备升级节奏不可控,用户不一定会及时更新应用。如果后端契约先升级、前端应用还停留在旧版,兼容策略能避免一大批线上异常。我建议在响应解析层加一个"未知字段统计"的日志开关,观察线上是否出现大量老版本客户端访问新接口的情况,用来判断是否到了必须强制升级的时机。
6.3 连接生命周期与设备差异:最后再交代几个细节
连接生命周期管理在鸿蒙设备上比在模拟器上更敏感。开发板息屏、应用退后台后,系统可能会冻结网络连接,回来时连接已经断了。我建议监听鸿蒙的应用前后台切换事件,在前台恢复时主动检查 Transport 状态,如果异常则立即重连,而不是等第一个请求失败了才被动感知。这个改动很小,但对用户体验提升很直接。
还有不同鸿蒙设备对网络权限和后台联网策略的处理不完全一样。开发版和正式版、手机和平板之间,TCP 保活行为可能有差异,建议在真机上多机型验证连接空闲时的存活时长。我在某款平板上发现空闲连接 2 分钟没有数据就会被系统回收,而手机上可以撑到 5 分钟,针对这种情况,心跳间隔不能写死,做成按设备能力探测后自适应更稳妥。
我做完这套鸿蒙化适配后,最大的体会是:鸿蒙化一个 Flutter 三方库,难点往往不在库本身的逻辑,而在平台的边界条件。你永远猜不到某台设备上哪个系统机制会拿掉你的连接、改变你的时序。最稳妥的策略是让传输层保持简单、可替换、可切换,把复杂业务留在协议之上,把平台差异隔离在传输之下。如果你也正在做类似的事情,建议从传输层的接口抽象开始,先把Transport稳住了,后面的一切都会顺很多。