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的泛型接口。
所以鸿蒙化适配的正确理解是:协议引擎不用动,动的是给它喂数据的管道。你在鸿蒙设备上只要能找到一个可以抽象成Stream<String>的通道——不管是 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 和 Server:
- Client 发请求(Request),Server 回响应(Response)。
- Client 发通知(Notification),Server 不回任何东西。
- 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 要跑起来,需要的是一个StreamChannel<String>。最简单的通道就是 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; Future<void> connect(String url) async { _socket = WebSocketChannel.connect(Uri.parse(url)); _client = json_rpc.Client(_socket.cast<String>()); _client.registerMethod('deviceStatusChanged', (params) async { // 服务端主动请求设备状态时的处理逻辑 return _collectCurrentStatus(); }); await _client.ready; _client.notifications.listen((notification) { // 处理服务端推送的通知 _handleNotification(notification.method, notification.params); }); } Future<dynamic> invoke(String method, [Map<String, 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.cast<String>()是因为 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); }); } Future<T> call<T>(String method, Map<String, 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包装。我在鸿蒙项目里封装了一层 TimeoutRpc:
class TimeoutRpc { final json_rpc.Client _client; final Duration timeout; Future<dynamic> call(String method, Map<String, 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。
- 服务端推送
configPushed,Flutter 侧无反应。 - 但此时 Flutter 侧主动发
sendRequest('getConfig'),能正常拿到数据。
根因:鸿蒙对后台进程的 socket 有节能策略,连接并没有被断开,但长连读事件在某些框架实现里会被挂起。等 App 回到前台,socket 恢复读事件,但那片刻的推送窗口已经错过了。更麻烦的是:如果服务端在断流期间发的是 Request 而不是 Notification,Flutter 侧还会误报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 服务类里加了一个ValueNotifier<RpcConnectionState>,把connecting / connected / timedout / reconnecting轮流暴露给 UI 层。顶部状态栏显示连接状态,排查问题的时候一眼就能看出是连接断了还是服务端挂了。这个习惯让我少死了很多脑细胞。
第三个习惯是重连一定要带指数退避。鸿蒙设备网络波动常见,直接固定 3 秒重连容易被系统判定为非法高频请求,导致 IP 被限。我采用 1s、2s、4s、8s、16s 封顶的退避策略,配合随机抖动,实测下来稳定很多。
这套方案上线后,RPC 通道在鸿蒙平板和手机上的表现都远比之前的单向 HTTP 轮询稳定,双向交互的延迟也降了一个量级。json_rpc_2 的鸿蒙化适配根本不需要改库,它只是在提醒你:结构化通讯的价值,不在于协议本身有多高级,而在于你敢不敢把所有交互场景都收敛到同一条双向通道上。