鸿蒙Flutter适配json_rpc_2:双向通信与RPC协议实践
1. 为什么 json_rpc_2 是鸿蒙场景下绕不开的那个库我最早接触 json_rpc_2是因为在鸿蒙 Flutter 应用里做设备与后台的双向实时通信来回换了好几套方案最后发现真正好用的还是这个在纯 Dart 层就能闭环的库。先说结论json_rpc_2 是 Dart 官方维护的 JSON-RPC 2.0 协议实现它不依赖任何原生 UI 组件也不依赖 Flutter 引擎的私有能力纯 Dart 实现这决定了它在鸿蒙化这条路上天然具备极高的可移植性。很多人一听“鸿蒙化适配”就紧张觉得是不是要动 C 层、动 Native 层其实对于 json_rpc_2 这个库来说适配的核心根本不是改写协议逻辑而是解决一件事怎么把鸿蒙平台上的字节通道接进来。1.1 这个库到底帮你省掉了什么如果不用 json_rpc_2你直接在鸿蒙 Flutter 应用里写双向通信通常会遇到三层麻烦协议层请求和响应怎么关联怎么区分通知和调用错误码怎么定义才能让前后端不吵架序列化层参数类型怎么映射嵌套对象怎么处理服务端返回的字段名和 Dart 字段名不一致时怎么办异步调度层多个请求同时发出响应乱序回来怎么把每个响应准确送到对应的调用方json_rpc_2 把这三层都封装好了。你只需要给它一个“能收发字符串的通道”它就能在上面跑完整的 JSON-RPC 2.0 语义。别的库还需要你自己拼 JSON、自己维护 pending 表这个库直接给你一个Client或Server对象内部把请求关联、超时、错误码全管了。1.2 鸿蒙适配的本质不是迁移是换通道我见过不少团队做鸿蒙适配时一上来就翻 json_rpc_2 的源码想找到底哪段代码依赖了 Android API 或者 iOS API结果发现根本没有。这个库从头到尾只依赖dart:async、dart:convert这些标准库底层通信抽象用的是Stream和Sink的泛型接口。所以鸿蒙化适配的正确理解是协议引擎不用动动的是给它喂数据的管道。你在鸿蒙设备上只要能找到一个可以抽象成StreamString的通道——不管是 WebSocket、TCPSocket、鸿蒙轻量级数据通道还是通过 MethodChannel 桥接原生 socket——json_rpc_2 就能直接跑。这个认知非常重要因为它直接决定了你的工作量分配90% 的精力应该花在通道层的选型和稳定性上10% 花在业务方法的注册和接入上。下面我按这个思路把协议细节、通道落地、双向交互设计、踩坑记录一次讲透。2. 先把协议吃透JSON-RPC 2.0 的四个核心物件聊适配之前必须先聊协议本身。JSON-RPC 2.0 规范本身不长但实际项目里写错的概率非常高。json_rpc_2 这个库把规范固化成代码后几个语义边界必须搞清楚否则后面写业务时会非常别扭。2.1 Request / Response / Notification 的语义边界协议里最基础的两个角色是 Client 和 ServerClient 发请求RequestServer 回响应Response。Client 发通知NotificationServer 不回任何东西。Server 也可以主动向 Client 发请求或通知这构成了双向交互的基础。json_rpc_2 的类设计里Client和Server都继承自Peer所以两边都能收发请求和通知。新手最容易搞混的是Client不一定是“主动调用方”它也可以被服务端调用。在鸿蒙设备场景里常见架构是 Flutter 侧作为 JSON-RPC Client鸿蒙原生侧或云端作为 Server但服务端经常要把状态变更推给 Flutter 侧这时候推送有两种姿势一是服务端发 Notification通知Flutter 侧监听广播流二是服务端发 Request请求要求 Flutter 侧执行某个操作并返回结果。两者语义完全不同姿势json_rpc_2 里的路径适用场景服务端通知服务端sendNotification客户端监听notifications流单向状态推送不需要客户端回复服务端请求服务端sendRequest客户端注册methodCallHandler自动应答需要客户端处理后返回结果的操作我建议在鸿蒙侧做双端需求时先把这个区分写进接口文档里否则很容易出现“服务端发了个 Request客户端没注册 handler超时报错”这类沟通成本。2.2 错误对象与 id 关联最容易写错的三个细节JSON-RPC 2.0 的错误对象长这样{ jsonrpc: 2.0, error: {code: -32601, message: Method not found}, id: 1 }规范里预约了-32700解析错误、-32600无效请求、-32601方法不存在、-32602参数错误、-32603内部错误业务错误码从-32000到-32099这个区间里自定义。json_rpc_2 库里有RpcError和RpcException两个类和这个机制对应写业务时我有三个经常踩的细节id 不是必填字段但关联时必须保证唯一。Notification 没有 id它也不会产生响应所以你可以放量发。但普通请求的 id 一旦重复响应关联就会串掉。响应里的 id 值必须原样返回。有些后端喜欢把字符串 id 转成数字或者反过来这会导致 json_rpc_2 内部找不到对应的 pending 请求然后抛Unexpected response异常。别改 id 的类型。错误详情最好放在 error.data 里而不是塞进 message。message 是给人看的data 是给程序看的。我在鸿蒙侧做错误分类时习惯把错误码、设备状态、堆栈全部放进data这样 Flutter 侧解析统一不用各写一套字符串匹配逻辑。2.3 批处理与 json_rpc_2 的惰性处理方式JSON-RPC 2.0 支持把多个调用打包成一个 JSON 数组一次发送json_rpc_2 内部实现了对List协议帧的解析。这个能力在鸿蒙场景里特别有价值因为鸿蒙设备的网络环境往往不如手机稳定高频小包容易被系统或网关限流批处理能把多次往返压缩成一两次。不过要注意json_rpc_2 默认采用惰性处理策略它不会等整个批次全部完成才响应而是按单个请求依次回调响应也是逐个返回的。这让批处理在业务层的体验几乎和普通请求一致你不需要额外增加等待逻辑。代价是如果你的服务端实现不够规范可能会把多个响应复用同一个 id或者用数组形式返回全部响应这两种情况 json_rpc_2 都处理不了。3. 打通鸿蒙通道Flutter 工程里的适配落地步骤现在进入真正的“鸿蒙化适配”实操。我的做法是从一个最小可跑通的 Demo 开始逐步加业务内容。如果你对这套流程不熟直接照下面步骤走完全能落地。3.1 环境形态Flutter 鸿蒙分支与 json_rpc_2 的纯 Dart 属性鸿蒙上跑 Flutter当前主流路线是使用 OpenHarmony 生态维护的 Flutter 引擎适配分支。你的 Flutter 工程结构跟普通工程几乎一样只是构建目标变成了鸿蒙的 HAP 包。json_rpc_2 是纯 Dart 包这意味着只要你的 Flutter 鸿蒙分支能正常执行 Dart 代码它就能正常工作不需要改任何原生代码。我在工程里引入依赖时只需要在pubspec.yaml里加dependencies: json_rpc_2: ^3.0.2 web_socket_channel: ^2.4.0之所以同时引入web_socket_channel是因为我用它来做底层通道。鸿蒙环境没有内置 Dart 的 WebSocket 实现但web_socket_channel这个库的底层IOWebSocketChannel在鸿蒙上是走普通 TCP/UDP socket 能力的那条路径实测可以跑通。3.2 自建 Transport 层用 WebSocket 把请求送进鸿蒙侧json_rpc_2 要跑起来需要的是一个StreamChannelString。最简单的通道就是 WebSocket因为 WebSocket 天然是字符串帧和 JSON-RPC 的传输模型完全吻合。落地的代码骨架如下import package:json_rpc_2/json_rpc_2.dart as json_rpc; import package:web_socket_channel/web_socket_channel.dart; class RpcClient { late final WebSocketChannel _socket; late final json_rpc.Client _client; Futurevoid connect(String url) async { _socket WebSocketChannel.connect(Uri.parse(url)); _client json_rpc.Client(_socket.castString()); _client.registerMethod(deviceStatusChanged, (params) async { // 服务端主动请求设备状态时的处理逻辑 return _collectCurrentStatus(); }); await _client.ready; _client.notifications.listen((notification) { // 处理服务端推送的通知 _handleNotification(notification.method, notification.params); }); } Futuredynamic invoke(String method, [MapString, dynamic? params]) async { if (params null) { return _client.sendRequest(method); } return _client.sendRequest(method, params); } void teardown() { _socket.sink.close(); } }这里有几个容易忽略的点逐个说明_client.ready是一个Future它表示 WebSocket 连接建立且协议握手完成。发送请求前必须 await 这个 Future否则会出现“连接还在握手请求已经发出去了服务端根本没收到”的诡异问题。_socket.castString()是因为 WebSocketChannel 默认的事件类型可能是dynamic需要显式转成String流json_rpc_2 的泛型约束才认。registerMethod注册的方法是给服务端反向调用用的。如果服务端在客户端注册之前就发来了请求这个请求会直接走默认错误处理返回Method not found。3.3 方法注册与服务端推送的双向骨架双向交互的关键是把 json_rpc_2 的「Client 能收请求」这个能力用起来。很多团队只把它当单向 RPC 用服务端要推数据就另搞一套推送通道结果维护两套链路成本翻倍。我的建议是基于 JSON-RPC 2.0 统一承载所有双向语义。具体来说在鸿蒙 Flutter 侧维护一个核心 RPC 服务类内部维护若干业务模块的注册入口class RpcGateway { final json_rpc.Client _client; void registerModule(String module, ModuleHandler handler) { _client.registerMethod(module, (params) async { return handler.dispatch(params); }); } FutureT callT(String method, MapString, dynamic params) async { final result await _client.sendRequest(method, params); return result as T; } }服务端可以调用deviceStatusChanged获取设备状态也可以发configPushed通知让 Flutter 侧刷新配置Flutter 侧可以通过call(cloud.rpc.invoke, params)调云端业务。一条通道两种方向三个角色请求方、响应方、广播方这就是“鸿蒙级双向交互专家”的底层架构。4. 双向交互专家的核心设计请求关联、超时与批量这里把 json_rpc_2 内部的几个高级主题讲清楚也是实战中最容易出问题的地方。4.1 请求关联与乱序响应每个请求在发出时json_rpc_2 内部会分配一个自增 id并把这个 id 的记录放进 pending 表。收到响应时根据响应里的 id 找到对应的 completer把结果或错误交给调用方。这就是请求关联的机制。在鸿蒙设备这种网络抖动频繁的环境下响应乱序是常态。你不需要自己处理乱序——json_rpc_2 的 pending 机制天然支持乱序返回。真正需要注意的是不要在业务层假设响应顺序等于请求顺序。比如连续发出“设置亮度 50”和“设置亮度 80”你期望最终亮度是 80但如果服务端串行处理且第一笔请求回包更快你收到最后一个响应时得到的可能是 50 的回包。这类问题不是协议 bug是业务设计问题。我的处理是在关键链路操作上强制串行化或者给参数带sequence字段。4.2 超时熔断与清理机制json_rpc_2 自身没有一个内置的“每个请求 N 秒超时”的开关但sendRequest返回的是Future你可以用标准的Future.timeout包装。我在鸿蒙项目里封装了一层 TimeoutRpcclass TimeoutRpc { final json_rpc.Client _client; final Duration timeout; Futuredynamic call(String method, MapString, dynamic params) { return _client.sendRequest(method, params).timeout(timeout, onTimeout: () { throw RpcTimeoutException(method); }); } }这里真正要讲的是超时后的清理。Future.timeout只是让调用方不再等结果但底层 pending 表里的条目仍然存在。如果服务端过一会儿才回包json_rpc_2 会拿这个到来的响应去找已经删掉的请求记录然后抛Unexpected response异常。这个异常如果不捕获会冒到顶层未处理异常里导致崩溃或日志爆炸。解决思路有两个一是超时后主动_client重建连接彻底清空连接上下文二是在全局监听里把这类异常静默处理掉。我在生产环境里用的是第一个方案也就是“超时即断链重连”的熔断策略。因为对多数业务来说一个请求超时往往意味着链路已经亚健康继续复用同一条连接反而会带来更多超时。4.3 批量请求在高频上报场景下的实测收益鸿蒙设备做指标上报比如每秒上报 CPU 温度、内存占用、网络延迟如果用单请求模式每秒钟要建立大量 JSON 帧WebSocket 开销不小。用 json_rpc_2 的批处理能力把 10 条上报打包成一个数组帧整个过程只需要一次网络往返。具体做法是直接传List给通道final batchFrame jsonEncode([ {jsonrpc: 2.0, method: report, params: {...}, id: 1}, {jsonrpc: 2.0, method: report, params: {...}, id: 2}, ]); _socket.sink.add(batchFrame);实测下来我在相同网络条件下单条上报模式每 100 条消息需要大约 3.4 秒批处理模式压缩到 0.8 秒带宽消耗减少了 40% 左右。不过要注意json_rpc_2的Client对象并不直接提供“发送批量请求并等待全部完成”的 API所以你如果要在业务层用批处理需要自己拼 JSON 帧并维护 id 映射。我通常只在日志类、指标类低优先级场景用批处理核心控制指令还是走标准sendRequest避免复杂化。5. 鸿蒙化过程中实打实踩过的坑复现链路版这部分我想用排查链路的方式来写因为这些坑我翻了很多 issue 才定位到根因直接给结论帮大家省时间。5.1 坑一通道握手成功但首帧请求丢失现象Flutter 侧日志显示 WebSocket 已连接await _client.ready也通过了但服务端就是收不到第一条请求。复现链路在鸿蒙设备上启动 App连接 RPC 服务端。立刻进程启动后 500ms 内发出getDeviceInfo请求。服务端日志为空没有任何收包记录。等 3 秒后再发同一条请求服务端能正常收到。根因鸿蒙设备上系统调度偶尔会让 Flutter engine 的微任务队列延迟执行WebSocketChannel.connect底层虽然完成了 TCP 握手但_client.ready的 complete 事件和首个sink.add的数据帧在某些执行时序下被排到了不同的任务批里。也就是说“连接建立完成”这个信号发出来了但发送数据的指令还没被真正灌进 socket。解决办法包一层“连接完成后的确认握手机制”。我让 Flutter 侧连接成功后的第一帧固定发送一个ping通知服务端收到ping后回一个pong通知Flutter 侧收到pong后才把对外暴露的rpcReady置为 true。业务方统一等rpcReady再发起请求基本上就杜绝了首帧丢失。5.2 坑二服务端推送被生命周期打断后的恢复策略现象鸿蒙 App 退到后台再回前台服务端推送的configPushed通知经常收不到但 RPC 请求还能正常返回。复现链路App 在前台RPC 通道正常服务端能推通知。按 Home 键退后台设备息屏再点亮屏幕回到 App。服务端推送configPushedFlutter 侧无反应。但此时 Flutter 侧主动发sendRequest(getConfig)能正常拿到数据。根因鸿蒙对后台进程的 socket 有节能策略连接并没有被断开但长连读事件在某些框架实现里会被挂起。等 App 回到前台socket 恢复读事件但那片刻的推送窗口已经错过了。更麻烦的是如果服务端在断流期间发的是 Request 而不是 NotificationFlutter 侧还会误报Method not found因为注册的 handler 在 Flutter engine 恢复后已经丢失了部分分发上下文。解决办法三个动作配合使用。第一在鸿蒙侧监听前后台切换生命周期回到前台时主动发一个clientResumed请求让服务端立刻补偿推送关键状态。第二在 RPC 服务类里加一个onReconnected回调重连成功后重新拉取全量状态。第三把重要推送尽量设计成“状态快照拉取”而不是“事件流订阅”降低丢失单条通知的影响面。5.3 坑三字符串编码与参数类型塌缩现象服务端返回的{success: true, count: 5}Flutter 侧解析后count变成了int但客户端代码里写的是double导致 UI 上显示异常或者中文文案变成乱码。复现链路鸿蒙原生侧用 ArkTS 发送 JSON 字符串给 Flutter 侧引擎。Flutter 侧用jsonDecode解析。打印jsonRpcResult.runtimeType发现数字被解析成了int或double的混合值。中文变成乱码大概率是渠道层编码不统一。根因Dart 的jsonDecode会把不含小数点的数字解析成int这是符合 JSON 规范的但很多后端习惯把数值统一返回成字符串或整数导致前端类型判断出错。中文乱码则是因为鸿蒙原生侧某些 WebSocket 实现默认用 UTF-8 没问题但如果你走的是 MethodChannel 桥接容易因为字符串编码没有显式声明而踩坑。解决办法第一在 RPC 接入层统一做一次类型归一化把num类型按业务字段约束转成固定类型。第二服务端返回数值型字段时强制定义类型语义整数/小数/字符串不要用字数相同的数字“看心情返回”。第三所有跨引擎字符串传输固定使用 UTF-8 并配合jsonEncode再jsonDecode不要手动拼字符串。6. 从适配到架构我在这套方案里沉淀的三个习惯最后一个部分不说太多技术细节聊几个我做完整个鸿蒙化适配后沉淀下来的使用习惯。第一个习惯是给所有 RPC 方法名建立注册表。刚开始我在代码里到处直接写client.sendRequest(getConfig)字符串散落各处后来业务多了方法名拼错一个字母排查成本极高。现在我把所有方法名收敛成一个常量类编译期就能发现拼写错误服务端和客户端共用同一份文档。第二个习惯是通道状态可视化。我在 RPC 服务类里加了一个ValueNotifierRpcConnectionState把connecting / connected / timedout / reconnecting轮流暴露给 UI 层。顶部状态栏显示连接状态排查问题的时候一眼就能看出是连接断了还是服务端挂了。这个习惯让我少死了很多脑细胞。第三个习惯是重连一定要带指数退避。鸿蒙设备网络波动常见直接固定 3 秒重连容易被系统判定为非法高频请求导致 IP 被限。我采用 1s、2s、4s、8s、16s 封顶的退避策略配合随机抖动实测下来稳定很多。这套方案上线后RPC 通道在鸿蒙平板和手机上的表现都远比之前的单向 HTTP 轮询稳定双向交互的延迟也降了一个量级。json_rpc_2 的鸿蒙化适配根本不需要改库它只是在提醒你结构化通讯的价值不在于协议本身有多高级而在于你敢不敢把所有交互场景都收敛到同一条双向通道上。