写 Flutter 鸿蒙化适配,最让人头疼的往往不是 UI 能不能画出来,而是通信层怎么打通。前阵子我把项目里的json_rpc_2库往鸿蒙端迁移,折腾了几天,踩了不少坑,也把整个通信架构重新理了一遍。今天把这套适配方案整理出来,从为什么选 JSON-RPC 2.0、怎么定义通讯准则,到怎么在鸿蒙 Flutter 环境里实现双向交互,一次性说清楚。这篇东西适合正在做 Flutter 鸿蒙化、或者想把原生与 Dart 侧通信梳理得更规范的开发者,哪怕你还没碰过json_rpc_2,按着这个思路走也能少走弯路。
1. 为什么要在鸿蒙上做 json_rpc_2 适配
1.1 json_rpc_2 是什么,能解决什么问题
json_rpc_2是 Dart 生态里一个非常成熟的 JSON-RPC 2.0 协议实现,纯 Dart 编写,不依赖任何平台通道。它提供了 Server、Client 两套完整的 API,以及基于StreamChannel的传输层抽象。简单说,它就是一套“定义好了请求、响应、通知、错误码、批量调用”的通讯框架,你只需要把一个双向的字节流通道交给它,剩下的协议细节它全包了。
那这套东西在鸿蒙化场景里能解决什么问题?核心是解决 Flutter 与鸿蒙原生之间通讯“太随意”的问题。平时我们用 MethodChannel,无非是定一串字符串方法名,两边写一堆 if-else 分发。方法少还好,一旦超过几十个,参数还带各种嵌套结构,维护起来就是灾难。json_rpc_2把通讯规范变成协议本身的一部分:请求里有 method、有 params、有 id,响应里有 result 或者 error,错误的语义也是标准化的。鸿蒙侧和 Flutter 侧都不用再各自维护一套“私有协议”,而是共同遵守 JSON-RPC 2.0 这套公开标准。
我在实际项目里最深的感觉是:json_rpc_2不是来替代 MethodChannel 的,而是给 MethodChannel 套上了一层“协议骨架”。通道还是那条通道,但上面跑的不再是零散的方法调用,而是结构化、可追踪、可校验的 RPC 消息。对于需要双向交互、需要超时语义、需要批量处理的场景,这套骨架几乎是必需的。
1.2 鸿蒙化适配的三个关键难点
把纯 Dart 库往鸿蒙迁移,理论上很顺,因为json_rpc_2的依赖基本是stream_channel、synchronized这类同样纯 Dart 的包,不涉及原生代码。但实际落地,有三个坎几乎绕不过去。
第一个坎是传输通道的建立。json_rpc_2自己不管消息怎么走,它只要求你提供一个StreamChannel。但在鸿蒙的 Flutter 环境里,Dart 侧没法直接监听鸿蒙原生发来的 TCP 或 Unix Domain Socket(就算能,涉及权限和沙箱限制也极其麻烦)。最实际的做法,是把MethodChannel和EventChannel包装成一个符合StreamChannel接口的适配层。这个方法可靠,但需要注意鸿蒙原生侧的 MethodChannel 实现和 Android 上还不完全一样,事件回调的线程模型有差异。
第二个坎是双向交互的建模。常规思路是 Dart 侧的JsonRpcServer处理鸿蒙侧发来的请求,反过来鸿蒙侧的请求怎么主动打给 Dart?很多人在这里卡住,因为 MethodChannel 的天然方向是“Dart 调用原生”,或者“原生调用 Dart”,但你想在一条逻辑连接上同时承载“Dart 请求-原生响应”和“原生请求-Dart 响应”两种模式,就得在通道之上再造一层消息分发器。json_rpc_2的好处是它天然支持这种双工模型,只要两端各跑一个 Server,各持一个 Client,就能互不干扰。
第三个坎是异步时序的差异。鸿蒙 Flutter 引擎的事件循环、微任务队列与标准 Flutter 引擎有不少细节差异,最典型的例子是 Future 回调的调度时机。json_rpc_2内部大量依赖 Stream、Future、StreamController,一旦某个回调没有在预期的事件循环里被调度,消息就会卡住。这个问题在开发机上很难复现,真机跑一段时间才暴露。所以适配时,日志追踪能力必须前置设计好。
2. JSON-RPC 2.0 结构化通讯准则
2.1 协议基础:请求、通知、响应、错误码
在动手写适配层之前,必须把 JSON-RPC 2.0 的核心结构吃透。它一共只有四种消息形态:请求(Request)、通知(Notification)、成功响应(Success Response)、错误响应(Error Response)。另外支持批量(Batch)调用,一次发送一个数组。
一个标准的请求对象长这样:
{ "jsonrpc": "2.0", "method": "device.getInfo", "params": {"deviceId": "abc123"}, "id": 1 }注意id是必须的、由请求方维护的唯一标识,响应里会原样带回。如果不带id,这就变成一个“通知”——发送方不期待任何响应,接收方也不准回消息。这个机制特别适合鸿蒙侧向 Flutter 侧推送状态变化,比如蓝牙连接状态、电量变化、页面生命周期切换,直接发通知,干净利落。
响应同样必须带jsonrpc字段和id。成功响应用result携带数据,错误响应用error携带一个结构化的错误对象:
{ "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": 1 }错误码是协议内定义的,这一点特别适合用来统一鸿蒙和 Flutter 两侧的错误语义。标准错误码包括:
| 错误码 | 含义 | 说明 |
|---|---|---|
| -32700 | 解析错误 | JSON 无法被解析 |
| -32600 | 无效请求 | JSON 不是合法的请求对象 |
| -32601 | 方法不存在 | 调用的 method 在接收端没有注册 |
| -32602 | 无效参数 | params 的结构与预期不匹配 |
| -32603 | 内部错误 | 处理器内部抛异常 |
| -32000 至 -32099 | 服务端错误 | 预留,可自定义服务端异常 |
建议:业务错误码不要占用 -32000 以下的保留区间,也不要试图覆盖标准错误码。我在项目里把业务错误统一映射到-32000 - code的区间,这样从错误码本身就能区分是协议层错误还是业务层错误,排查问题省下大量时间。
2.2 通讯准则规范:ID、命名、错误码、超时与日志
通讯准则不是协议强制要求的,但不定清楚,后面一定乱。我把这套准则分成五块,适配前就要定好。
ID 生成规则。不要用简单的全局自增。鸿蒙侧如果有多个业务模块各自持有 Client,自增 ID 会发生碰撞,导致响应错配。我采用的方案是“模块前缀 + 单调递增序号”,Dart 侧 ID 开头用F,鸿蒙侧用N,比如F-1001和N-1001。这样日志里看到 ID 前缀就知道消息是谁发起的,排查链路的时候极其好用。
方法命名规范。统一用模块.动作形式,小写点分。例如device.getInfo、bluetooth.startScan、system.setOrientation。拒绝驼峰、拒绝带斜杠、拒绝没有模块前缀的裸方法名。这样在两端各自的方法注册表里,按前缀就能快速分组。
参数校验。接收端注册方法时,必须写参数校验逻辑。json_rpc_2不帮你校验 params,你拿到的是一个动态类型,要自己处理。参数不合法直接返回-32602,不要硬解析然后抛异常。否则你会看到一堆莫名其妙的-32603内部错误日志,完全无法区分是代码 bug 还是调用方传参错误。
超时约定。每个请求必须要有超时语义,json_rpc_2的 Client 没有内建超时,但 Dart 的Future.timeout()可以直接包住client.sendRequest()。超时时间建议按方法分档:快速方法 2 秒,慢方法(如蓝牙扫描、文件读写)5 秒,超长任务用通知+事件回调钳制。不要一个超时走天下。
日志追踪。每条请求在 params 里注入一个traceId,两端按traceId而不是按方法名打日志。这是我在实际踩坑里得出的最重要经验:方法名只能告诉你“调用了什么”,traceId才能告诉你“这一次调用是谁发起的、花了多久、在哪个环节断了”。
3. 鸿蒙化适配实操
3.1 环境准备与依赖替换
先说环境。鸿蒙 Flutter 目前用的是 OpenHarmony 分支的引擎,总体 API 与标准 Flutter 兼容,但不是 100% 对齐。Dart 侧只要有dart:async、dart:convert这类基础库就能跑,json_rpc_2完全满足这一条件。所以适配重头不在 Dart 侧,而在原生侧的通道实现上。
项目级准备工作如下:
- 在
pubspec.yaml里添加依赖:
dependencies: json_rpc_2: ^3.0.7 stream_channel: ^2.1.1 synchronized: ^3.1.0如果原有代码直接引用了
json_rpc_2的 WebSocket 或 HTTP 相关实现(比如用package:web_socket_channel),这部分建议先摘掉,鸿蒙端直接用自研的 MethodChannel 适配层替代,避免底层 socket 差异带来的不确定问题。构建链层面,鸿蒙 Flutter 项目和普通 Android 项目构建流程不太一样,但都是 Gradle 体系。这里要留意的是,跑到真机之前先跑通模拟器,因为模拟器上 MethodChannel 的调试日志更完整,真机往往把早期崩溃吞掉。
我建议在动手写代码前,先把鸿蒙原生侧和 Flutter 侧的基础调用打通:Flutter 调一个空方法、原生回一个空字符串、原生再调一个空方法、Flutter 回一句。这四步通了,再谈 RPC 适配。很多问题其实出在通道初始化的时序上,不是你 JSON-RPC 逻辑写错了。
3.2 通道层封装:MethodChannel 作为传输层
这是整个适配的核心。json_rpc_2需要一个StreamChannel,它内部通过这个通道收发字符串消息。我们要做的,就是让 MethodChannel 看起来像一个 StreamChannel。
思路是这样:Dart 侧用一个StreamController接收“鸿蒙原生发来的消息”,同时把“要发给鸿蒙原生的消息”通过 MethodChannel 的invokeMethod发出去。而鸿蒙侧,原生代码通过 MethodChannel 的回调收到 Dart 侧消息并通过result.success()回传,再把要发给 Dart 的消息通过MethodChannel的invokeMethod主动吐给 Flutter。
说白了两件事:向外复用 invokeMethod 这一条路,向内复用 EventChannel 那一条路。两边各有一个发射管道和一个接收管道,拼起来就是一个逻辑上的双向 StreamChannel。
Dart 侧代码大概长这样(这是关键路径,我贴的是精简可运行版):
import 'dart:async'; import 'package:flutter/services.dart'; import 'package:stream_channel/stream_channel.dart'; class MethodChannelStreamChannel extends StreamChannelMixin<String> { final MethodChannel _ethodChannel; final StreamController<String> _controller = StreamController<String>.broadcast(); MethodChannelStreamChannel(this._methodChannel) { _methodChannel.setMethodCallHandler((call) async { if (call.method == 'onRpcMessage') { _controller.add(call.arguments as String); } return null; }); } @override Stream<String> get stream => _controller.stream; @override Future<void> add(String data) { return _methodChannel .invokeMethod('sendRpcMessage', data) .then((_) {}); } @override Future<void> close() async { await _controller.close(); } }鸿蒙侧则是在主 Ability / 组件里注册 MethodChannel 的 handler,监听sendRpcMessage并处理onRpcMessage的调用。这里天然就是双向的:原生侧调用invokeMethod("onRpcMessage", jsonString),Dart 侧就会收到;Dart 侧调用invokeMethod("sendRpcMessage", jsonString),原生侧就会收到。
有个细节坑要提前说:千万不要用同一个 MethodChannel 名字注册两套 handler。鸿蒙 Flutter 的 MethodChannel 一套名字只能绑定一个 handler,否则后注册的会覆盖先注册的,无声无息。我建议通道名分开:com.example.rpc/in命名接收通道、com.example.rpc/out命名发送通道,实际还是一个 MethodChannel 对象,只是 method 名区分。上面代码里我用的就是单通道双 method 的做法,更简单,也更容易排查。
3.3 Server 与 Client 双向交互实现
通道层准备好了,接下来就是真正的双向交互。目标是两个能力:
- Dart 侧注册方法,鸿蒙侧通过 RPC 调用。
- 鸿蒙侧注册方法,Dart 侧通过 RPC 调用。
先说 Dart 侧作为 Server 怎么做。
import 'package:json_rpc_2/json_rpc_2.dart' as json_rpc; final rpcServer = json_rpc.Server(channel); rpcServer.registerMethod('device.getInfo', (params) { final deviceId = params['deviceId'].asString; if (deviceId.isEmpty) { throw json_rpc.InvalidParamsException('deviceId is required'); } return {'name': 'Harmony Device', 'id': deviceId}; }); rpcServer.listen();json_rpc_2的registerMethod自带参数封装,params是一个Parameters对象,可以用.asString、.asList、.asMap取类型化的值。传参错误会抛InvalidParamsException,框架自动转成-32602错误码,不用自己拼错误对象。
鸿蒙侧作为 Client 发起请求,以 ArkTS 为例:先把要发的请求 JSON 序列化,通过invokeMethod("sendRpcMessage", jsonString)发出。响应会走setMethodCallHandler里的onRpcMessage回来,再按id匹配到具体请求。
这里最大的坑来了:不是每个响应都能立即回来。Dart 侧的方法 handler 如果要等一个真正的异步操作(比如蓝牙扫描、读文件、调鸿蒙 API),json_rpc_2的 Server 是支持异步方法的,registerMethod 的函数返回Future即可。但响应会延迟返回,原生侧必须做好“发请求 -> 记录回调 -> 响应回来再触发回调”的映射表,而不是简单地在setMethodCallHandler里同步 return。
反过来,鸿蒙侧作为 Server、Dart 侧作为 Client 也是同理。你只要在两边各建一个 Server 实例、各建一个 Client 实例,分别挂到同一个双向通道上,双向交互自然就成立了。这里有个性能优化点:不要为每对调用新建 Server/Client,要在应用启动时创建一次,全程复用。json_rpc_2的 Server 在重复注册同名方法时会直接抛异常,我之前在这里栽过跟头,启动时注册一次,后面别再碰注册表。
双向交互的完整消息流大概是这样:
| 消息方向 | 消息类型 | method 示例 | 说明 |
|---|---|---|---|
| 鸿蒙 → Flutter | Request | device.getInfo | 鸿蒙主动查询 Flutter 侧状态 |
| Flutter → 鸿蒙 | Response | (无) | 携带 result 返回 |
| Flutter → 鸿蒙 | Notification | app.pageChanged | Flutter 通知鸿蒙页面切换 |
| 鸿蒙 → Flutter | Request | bluetooth.startScan | Flutter 请求鸿蒙启动蓝牙扫描 |
| 鸿蒙 → Flutter | Notification | bluetooth.deviceFound | 鸿蒙持续推送发现的设备 |
这个表格里的场景是我实际做过的:Flutter 页面要监听鸿蒙蓝牙扫描结果,原生侧每扫到一个设备,就发一条 Notification,Dart 侧订阅事件流直接更新 UI,不需要 Flutter 反复轮询。这就是用 JSON-RPC 2.0 做鸿蒙级双向交互的典型姿势。
4. 踩坑记录与排查技巧实录
4.1 常见错误与排查方法速查表
实际适配过程中,我遇到的问题远不止“代码写错”,更多的是鸿蒙 Flutter 引擎的底层行为差异。下面这些是我整理的最有代表性的坑。
| 问题现象 | 根因 | 排查方法 |
|---|---|---|
PlatformException(channel_error) | MethodChannel 未在原生侧注册,或注册时机晚于 Dart 侧首次调用 | 启动初始化顺序调整:原生侧先注册通道,再加载 Flutter 页面 |
| 消息发出去了但对方没收到 | EventChannel 的监听还没建立,消息已经发出 | 鸿蒙侧发送通知前,先等待 Dart 侧回执“通道就绪” |
| 响应错配/乱序 | 多个 Client 共用了同一个 ID 序列 | 检查 ID 生成规则,加模块前缀,日志对照 |
| JSON 解析异常 | 鸿蒙侧发送的不是合法 JSON,或字符串里混入了 UTF-8 BOM | 在 Dart 侧 catch FormatException,把原始字符串打出来 |
| 异步回调丢失 | 鸿蒙侧setMethodCallHandler的 async 回调在页面消失后被回收 | 确保通道实例生命周期与页面/Ability 一致,不要用局部变量持有 handler |
排查 JSON-RPC 问题,最重要的手段就是日志先行。我在 Dart 侧和鸿蒙侧都加了一层统一的日志钩子:每收到一条消息打[RPC RECV] json,每发出一条消息打[RPC SEND] json。一开始会觉得刷屏,但定位问题的时候,没有这些日志你就是在瞎猜。
有一类问题特别隐蔽:鸿蒙侧的invokeMethod在某些场景下不会走then,而是走catchError。原本在 Android 上能吞掉的错误,在鸿蒙上会直接抛出来。比如原生侧 handler 里抛了一个业务异常,Dart 侧的Future可能会变成 error 状态,导致整个消息链路中断。我的处理办法是:鸿蒙侧 handler 里包一层统一的 try-catch,任何异常都转成 JSON-RPC 错误对象返回,绝不让异常穿透到通道层。
4.2 性能与稳定性优化建议
json_rpc_2本身不算快,因为它内部是纯 Dart 解析 + Stream 分发。在鸿蒙 Flutter 引擎上,性能瓶颈会更明显,尤其是高频小消息场景,比如蓝牙设备每秒上报 20 次状态。这时批量机制就会派上用场,JSON-RPC 2.0 原生支持批量请求,数组里放多个请求,一次通道调用处理完。批量后通道调用次数可以下降一个数量级。
另一个优化点是通道消息大小。MethodChannel 内部传输有消息体大小限制,虽然鸿蒙侧的具体阈值我没能完整测出,但经验上是超过 256KB 就有可能断连。大对象不要走 RPC 通道,改走临时文件或私有目录,RPC 只传路径和签名,这是我在文件传输场景下的明确结论。
还有一个稳定性建议是心跳保活。鸿蒙系统有内存回收策略,长时间无消息的双向通道存在被系统回收的风险。我在两端的 Client 里各开了一个定时器,每 15 秒互发一条ping通知。这个通知不带 id,不期待响应,纯保活。加上之后,渡渡过长时间挂后台再回前台的断线概率明显下降。
内存泄漏也是个大坑,尤其是 StreamController 忘记 close。MethodChannelStreamChannel.close()里必须清理setMethodCallHandler,否则页面销毁后 handler 还会被调用,造成泄漏。我建议在 Flutter 页面dispose时显式调用两端通道的 close,并把 RPC Server 和 Client 置空。
关于性能测试,我个人的建议是:不要只看单次调用的耗时,要压“混合负载”。我在测试里同时跑了高频通知、批量请求、低速大对象传输三种负载,才暴露出消息排队互相阻塞的问题。优化的手段是在 Dart 侧给不同优先级的方法分派到不同的 Server 实例上,相当于给 RPC 通道做了流量隔离。通用场景不需要,但如果你也在鸿蒙上做实时性要求高的功能,这个方案值得参考。
最后再分享一个我自己体会最深的点:json_rpc_2这套协议让两端沟通的“语言”统一了,真正难的不是实现 JSON-RPC,而是憋住“这里加个临时字段就行了”的冲动。通讯准则一旦定下来,就不要轻易改,哪怕改一个字段名,也要走双端版本协商。我后面再做鸿蒙原生与 Flutter 的通信架构,一定会把 JSON-RPC 2.0 作为默认选项,这不是因为它花哨,而是它让我在凌晨三点排查线上问题时,依然能靠日志和 ID 迅速定位问题。