鸿蒙适配实践:用JSON-RPC在Flutter边缘端实现设备间协同通信
2026/9/18 16:34:53 网站建设 项目流程

上手鸿蒙适配这事,最早是因为我们边缘端那批 Flutter 客户端已经跑了大半年,Android、iOS 都稳了,突然要接一批鸿蒙开发板。原本架构里这部分设备之间是靠 REST 轮询加中央服务器中转的,但边缘场景网络差、节点又多,轮询的弊端越来越明显。后来我把通信层换成了 jerelo——一个基于 Dart 的 JSON-RPC 2.0 组件,让设备之间直接“远程过程调用”,不再什么都往中心服务器捅。这次把 jerelo 适配到 HarmonyOS 上,中间踩了不少坑,但也把整套边缘端分布式协同架构理顺了。这篇就当是一次完整复盘,给正在做 Flutter + 鸿蒙跨端、或者想用 JSON-RPC 代替 REST 做设备间通讯的朋友做个参考。

1. 项目整体设计与选型思路

1.1 为什么边缘端协同选了 Flutter + jerelo

边缘端和普通 App 不一样,设备数量多、网络抖动大、机器配置参差不齐。我们这边管理的是几十台终端,有的在产线,有的在门店,需要互相调用能力:比如 A 设备请求 B 设备执行一次视频转码、C 设备上报聚合数据、D 设备远程触发一次模型推理。如果用传统 App 的做法,所有请求都走中心服务器,那边缘节点之间一次协作就要绕一大圈,延迟高不说,服务器一挂整个链路就瘫了。

选 Flutter 是因为团队已经在这套代码上沉淀了完整的业务组件,跨 Android/iOS 一直是同一套代码。鸿蒙出现后,社区维护的鸿蒙化 Flutter SDK 已经能跑起不少生产项目,所以客户端层继续用 Flutter 是性价比最高的选择。问题只剩下通信层。

一开始我也考虑过 gRPC,但边缘端设备上要引入 protobuf 编译链、维护 .proto 文件,对轻量化场景来说太重了。REST 又太“面向资源”,两个设备之间要执行一个动作,得先定义 URL、请求方法、状态码,还得处理各种中间态。我们真正需要的是“在远端设备上执行一个函数”,这就是远程过程调用(RPC)。JSON-RPC 2.0 协议本身只有一页纸,简单到几个方法就能实现,配合 Flutter 生态里的 Dart 原生 JSON 能力,非常契合。

jerelo 这个组件就是在这种需求下进来的。它把 JSON-RPC 2.0 的协议层、连接层、调用调度层封装好了,我只需要关心业务方法注册和调用。相比自己从零写一套轮子,它省掉了大量边界处理。

1.2 JSON-RPC 2.0 在边缘端比 REST 强在哪

把 REST 和 JSON-RPC 放在一起对比,可能有人会觉得 REST 才是主流,但要看场景。边缘设备间通信的特点是短小、频繁、双向、状态变化快,JSON-RPC 天然匹配这些特点。

对比项RESTJSON-RPC 2.0
请求语义资源 + HTTP 动词方法名 + 参数
连接利用通常短连接或 Keep-Alive 串行一条长连接并发处理多个请求
响应结构需自定义错误结构统一 result / error 结构
通知消息要么请求要么响应,无单向有 notification,不需要响应
批量调用需额外设计原生支持 batch 请求
协议复杂度低,但业务映射复杂低,语义更直接

这里面最有用的是“通知消息”。设备间心跳、事件上报、状态推送这类单向消息,REST 也得走一次完整请求,RPC 里一个notification就解决了,省掉一半包体积和响应处理逻辑。

另一个很关键的点是长连接复用。边缘端设备经常处在弱网环境,频繁建连非常不可靠。JSON-RPC 可以跑在 WebSocket、TCP 甚至自定义传输层上。我们最终选了 WebSocket 做主要传输,兼容性最好,跨平台实现最省事,鸿蒙的 WebSocket 能力也很完整。

1.3 jerelo 组件在整体架构里的定位

我画过一张分层图,jerelo 处在数据传输的中间层,上面是业务方法,下面是物理连接。具体拆开是这样:

  • 连接层:负责 WebSocket/TCP 的建立、断开、重连、心跳,这一层对鸿蒙来说是最需要注意的;
  • 协议层:负责 JSON-RPC 2.0 报文的编码、解码、ID 映射、错误包装;
  • 调度层:负责把远端请求路由到本地注册的方法,并把本地调用的结果发送回去。

jereelo 把这套逻辑抽象成Jerelo入口类,代码里只需要初始化一次。它不关心具体业务方法内部怎么实现,只解决消息可靠到达、结果正确返回的问题。所以在鸿蒙适配中,主要工作都在连接层和协议层的平台差异处理上。

2. 鸿蒙适配前的环境准备与工程改造

2.1 搭建鸿蒙化 Flutter SDK 开发环境

这里先说明一下,鸿蒙的 Flutter 支持目前来自社区维护的鸿蒙化分支,和官方 Flutter 主干不是完全同步的。所以第一步不是直接flutter doctor,而是先把本机的 Flutter 版本切到一个支持鸿蒙构建的 SDK 上。

我本地用 fvm 管理多版本 Flutter,单独拉了一个鸿蒙分支,项目里加.fvmrc锁定版本。具体流程是这样的:

fvm init --name ohos-flutter --version ohos-3.7.12 fvm use ohos-flutter

然后把 HarmonyOS 的 SDK 目录配置到环境变量里,确保 DevEco Studio 的命令行工具hvigorw能直接识别。安装完 DevEco Studio 后,会自带 HarmonyOS SDK,路径通常在~/Library/Huawei/SdkC:\Huawei\Sdk。建议把hvigorw也加到 PATH,后面构建 HAP 包会用到。

这里有一个容易踩的坑:鸿蒙 Flutter 分支需要匹配对应的 HarmonyOS SDK 版本,如果 SDK 太新或太旧,构建时会报一些莫名其妙的符号错误。建议先跑一下官方示例工程,能跑通再进入到自己的项目。

2.2 把已有 Flutter 工程改造成支持鸿蒙

有了鸿蒙化 Flutter SDK,下一步是把现有项目加上鸿蒙平台目录。理论上在项目根目录执行:

flutter create --platforms=ohos .

这会生成ohos目录,里面是鸿蒙工程骨架,包括AppScopeentryhvigorfile.ts这些文件。如果是老项目,得注意flutter pub get之后,ohos目录里可能没有自动生成完整配置文件,需要手动检查ohos/entry/src/main/module.json5

模块配置文件里最核心的是网络权限。边缘端设备间通信必须申请网络访问权限,否则 WebSocket 连接会被系统拦掉。在module.json5requestPermissions里加上:

{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }

如果你还需要在局域网内做节点发现,比如广播自己的服务地址,可能还需要ohos.permission.DISTRIBUTED_DATASYNC这类分布式软总线相关权限。但从实际经验看,边缘端跨设备协调尽量走明确地址连接,不要过度依赖系统发现能力,权限和兼容性问题会少很多。

2.3 集成 jerelo 依赖并完成初始化

环境准备好后,在pubspec.yaml里加依赖:

dependencies: flutter: sdk: flutter jerelo: ^1.2.0

然后flutter pub get。jerelo 本身是纯 Dart 实现,不依赖原生插件,这点在鸿蒙适配中省了很多事,只要 Socket 和 WebSocket 能力可用,它就能跑。

初始化代码类似这样:

final rpc = Jerelo( transport: WebSocketTransport( Uri.parse('ws://192.168.1.100:8080/rpc'), ), codec: JsonRpcCodec(), maxPending: 128, ); await rpc.connect();

这里有几个参数值得说:

  • transport:可替换成 TCP、WebSocket、甚至自定义的串口通信,边缘端有些设备没有 WiFi,用有线串口也能跑 RPC;
  • maxPending:同一时刻未返回的请求数量上限。边缘设备内存不大,限制并发能防止高负载下请求堆积;
  • codec:编解码器,默认使用 Dart 的dart:convert,后续可以通过自定义 codec 做压缩或二进制优化。

到现在为止,鸿蒙适配的“壳”已经完成了:工程能构建、网络权限有了、RPC 组件也能初始化。接下来要深入看 JSON-RPC 2.0 通讯本身怎么在鸿蒙上稳定跑。

3. JSON-RPC 2.0 通讯核心实现与性能细节

3.1 消息结构与请求 ID 生成策略

JSON-RPC 2.0 协议本身很简单,请求消息长这样:

{ "jsonrpc": "2.0", "method": "device.transcode", "params": { "source": "/data/video/01.mp4", "format": "h265" }, "id": "node-a-1024" }

响应消息要么返回result,要么返回error

{ "jsonrpc": "2.0", "result": {"taskId": "12345"}, "id": "node-a-1024" }
{ "jsonrpc": "2.0", "error": { "code": -32601, "message": "Method not found" }, "id": "node-a-1024" }

这里我特别想提醒id的生成策略。很多人图省事,用一个自增整数,但边缘端同时有多个节点互相调用时,如果有节点恰好也生成了一样的id,响应就会串掉。我在生产环境里一直用“节点 ID + 自增序列号”拼接字符串,比如node-a-1024,这样即使两个设备并发调用,ID 冲突概率也几乎为零。

int _seq = 0; String nextRequestId(String nodeId) { _seq++; return '$nodeId-$_seq'; }

除了请求和响应,还有一类 notification 消息,它没有id,接收方不需要返回任何东西。这类消息适合做心跳、事件通知。在边缘端,设备状态变更频率高,用 notification 推送能让对端及时感知,又不会加重响应负担。

3.2 连接管理与并发请求控制

长连接不是建立完就不管了,核心问题在于如何管理“未完成请求”和“并发上限”。

我在 jerelo 内部维护了一个pending映射,把请求id对应到一个Completer

class PendingRequest { final Completer<dynamic> completer; final DateTime deadline; } final _pending = <String, PendingRequest>{};

当方法调用发出去,就先往这个表里塞一条记录;收到响应后,根据id把对应Completer的值填上,再删掉记录。如果长时间没有响应,就主动取消并返回超时错误。

边缘设备的网络不可靠,所以还要给并发设置闸门。我在设计时给maxPending设成了 128,超过这个数的新请求要么排队要么直接拒绝。否则弱网环境下,一个连不上对端的节点会在短时间内发上百个请求,内存被塞满,整台设备就卡死了。

另外,不要忽略批量请求。JSON-RPC 2.0 支持一次发送一个数组的请求:

[ {"jsonrpc": "2.0", "method": "led.set", "params": {"on": true}, "id": "1"}, {"jsonrpc": "2.0", "method": "motor.stop", "params": {}, "id": "2"} ]

边缘端场景里,一个“动作”经常要联动多个设备的状态,用批量请求可以减少交互轮次。但批量请求并非越大越好,我实测单个 batch 超过 16 个请求,低端设备处理时会明显卡顿,所以需要通过配置限制单批数量,比如分块发送。

3.3 性能调优:编解码、背压与线程调度

鸿蒙设备性能跨度很大,有的开发板 CPU 较弱,JSON 编解码反而成了瓶颈。JSON-RPC 是文本协议,再怎么优化也没有二进制快,但我们可以在几个地方做工程优化。

第一个是编解码策略。默认jsonDecode/jsonEncode在主 Isolate 里执行,如果 RPC 消息很频繁,UI 会掉帧。我的做法是把消息解析放到后台 isolate 里:

final parsed = await compute(parseJson, rawMessage); dynamic parseJson(String raw) { return jsonDecode(raw); }

解析完成后,再通过SendPort把对象传回主 Isolate 做业务分发。这样 UI isolate 的职责只剩状态更新和轻量调度。

第二是背压。RPC 走长连接,如果发送端短时间内发出大量 notification,接收端可能处理不过来。我在 jerelo 的发送队列里加入了一个水位线:如果待发送的字节数超过阈值,就暂停后续发送,等队列排空再继续。这就类似水管里的减压阀,宁可短暂阻塞,也不能把对端冲垮。

第三是方法路由。边缘端各业务方法调用很频繁,如果每个方法都走Map<String, Function>的反射式查找,性能其实一般。在 Dart 里更好的做法是用switch分支,或者把方法名映射为整数 id,减少字符串比较。比如device.transcode映射成1001,路由时直接匹配数字,速度能提升一个数量级。

4. 边缘端分布式协同架构中的 jerelo 落地

4.1 节点注册、发现与能力列表

有了 RPC 通道,下一步就是让设备之间互相知道“你能干什么、你在哪里”。我们用了一个轻量级的中心协调节点,但这不是说所有调用都要经过中心。协调节点只负责注册和发现,真正的数据流走边缘设备之间的点对点连接。

每个节点启动时,会向协调节点注册自己的地址和能力列表:

{ "jsonrpc": "2.0", "method": "registry.register", "params": { "nodeId": "node-a", "endpoint": "ws://192.168.1.101:8080/rpc", "capabilities": ["video.transcode", "model.inference"] }, "id": "node-a-reg-1" }

其他节点需要调用某个能力时,先查协调节点拿到目标端地址,再直接建立点对点连接。这样做的好处是中心节点不参与高频业务数据流,压力小很多,即使中心节点短暂抖动,已建立的边缘连接也不受影响。

鸿蒙上如果要做纯本地发现,可以用 mDNS,但我建议尽量走“中心注册 + 点对点连接”模式。原因很简单:mDNS 在跨网段、多子网环境下基本不可用,边缘端网络环境本来就复杂,别给自己挖坑。

4.2 方法注册与路由设计

在服务端(也就是被调用的设备)上,jerelo 提供了方法注册接口。最基础的做法是:

rpc.register('video.transcode', (params) async { final source = params['source']; final format = params['format']; // 执行转码任务 return {'taskId': '12345'}; });

这个方法处理器接收params,返回一个能被 JSON 序列化的对象,或者抛出一个异常。如果异常是JereloException,它会自动转成 JSON-RPC error 响应;如果抛出的是普通异常,建议在顶层捕获并封装成-32603 Internal error

方法命名最好带上命名空间,比如device.led.setBrightnessdevice.motor.stoptask.queryStatus。边缘端有大量设备,单一扁平命名很容易冲突。

参数校验也很重要。JSON-RPC 2.0 的params可能是数组,也可能是对象。我在每个 handler 里先用一个简单的 schema 校验:

rpc.register('device.led.set', (params) { final on = params is Map ? params['on'] : null; if (on is! bool) { throw JereloException.invalidParams('on must be bool'); } // ... });

这样出问题的时候,错误信息至少是明确的,排查效率高很多。

4.3 心跳保活、断线重连与状态补偿

边缘端连接稳定性再怎么做也不过分。我们采用的策略是:每个节点每隔 15 秒发送一条heartbeatnotification:

{ "jsonrpc": "2.0", "method": "rpc.heartbeat", "params": { "ts": 1735699200000 } }

接收方收到心跳后更新对端状态,如果超过 45 秒没有收到任何消息,就判定对端离线。

断线重连不能直接死循环重试,否则几十台设备同时掉线,恢复网络后会形成“重连风暴”。我们用指数退避加随机抖动:

int retryDelay = 1; while (!connected) { await Future.delayed(Duration(seconds: retryDelay)); retryDelay = min(retryDelay * 2, 30) + Random().nextInt(3); }

这是个很经典但很实用的方案。第一次重连等 1 秒,第二次等 2 秒,第四次就是 8 秒,最高到 30 秒封顶。随机抖动避免所有设备步调一致,同时又不会让用户觉得完全没反应。

除了重连,还要考虑“状态补偿”。边缘端一次操作经常涉及多个设备,假设设备 A 调起设备 B 的转码任务,成功后 B 又需要把结果推送给 C,这时候如果连接断了,整个状态就悬空了。我的做法是在关键请求里加入业务层幂等 ID,接收方记录最近处理过的请求 ID,重复调用直接返回上一次结果。这跟 TCP 的重传机制一个道理,应用层必须自己搞定去重。

5. 常见问题与排查技巧实录

5.1 鸿蒙上连接失败或秒断,先查权限和构建产物

鸿蒙上最常见的表现是:Flutter 端代码在 Android 上跑得好好的,一跑到鸿蒙,rpc.connect()要么直接抛错,要么握手成功但立刻断开。排查下来,大部分情况都在权限和网络配置上。

先确认module.json5里有没有ohos.permission.INTERNET。很多从旧工程复制过来的项目,只改了包名和应用名,忘了迁移权限列表。权限缺失时,连接会静默失败,不会弹提示。

另一个隐藏问题是长连接被系统挂起。鸿蒙对后台网络有限制,如果 App 退到后台,WebSocket 可能被挂起,切回前台才恢复。所以边缘端设备如果需要在锁屏或后台继续提供 RPC 服务,得做成常驻前台应用,或者在鸿蒙侧申请长任务权限。

5.2 RPC 收发消息时 UI 掉帧,问题在 isolate

有一段时间我在鸿蒙开发板上跑应用,设备轮询状态时就感觉列表刷新特别卡。后来用性能分析工具一看,UI isolate 主线程被jsonDecode占了很大比例。

原因是 RPC 消息进来之后,直接在默认的webSocket.onMessage回调里做了 JSON 解析,这个回调跑在主 isolate 上。解决方式就是我前面提到的compute后台解析。这里有个细节:频繁用compute也有成本,更好的方式是长期运行一个后台 isolate,通过ReceivePort接收原始消息,解析完再SendPort回传。如果消息频率不高,用compute就够。

5.3 低端设备上 JSON-RPC 响应慢,可能是序列化对象太大

开发板性能不够时,一个包含大字符串的result从产生到完全序列化,可能耗时几百毫秒。这种情况别硬扛,应该设计成“RPC 传元数据 + 文件通道传数据”。

比如设备 A 请求设备 B 生成一份报表,B 的响应里只要返回报表 ID、大小、存储路径,A 再通过 HTTP 或文件共享协议拉取文件。JSON-RPC 这类方法调用适合控制流,不适合传输大数据块。这是一个架构层面的取舍,而不是协议层面的缺陷。

我踩过最深的坑是试图用 RPC 传一帧 4K 图像,结果内存和序列化时间都爆了,后来改成 RPC 传任务状态、图像走共享存储,才稳定下来。

5.4 问题排查速查表

现象可能原因处理方法
连接建立后无任何响应请求 ID 冲突,或响应与请求未匹配使用带节点前缀的字符串 ID,避免自增整数冲突
调用方法返回 -32601handler 未注册,或方法名拼写错误打印节点上已注册方法列表,确认命名空间
返回 -32603handler 内部抛了未被捕获的异常打开错误日志,把异常连带 stack 打出来
设备频繁断线心跳超时设置过短,或网络本身不稳定调整心跳间隔和超时阈值,增加指数退避重连
鸿蒙设备发热、耗电快心跳/通知过于频繁,或后台未休眠降低心跳频率,非关键通知合并成批量推送
批量请求执行异常batch 里某个请求报错影响后续解析单发逐个调试,确认是协议问题还是业务逻辑问题

这些坑每个单拿出来都不复杂,但组合在一起,足够让人排查好几天。尤其是鸿蒙生态还在快速迭代,官方文档和社区案例都不算多,很多问题只能靠日志和二分法定位。建议在实际项目里做好日志埋点,把 RPC 的收发包原始 JSON 记录成文件,出问题时能直接看到双方交换了什么内容,比瞎猜高效得多。

一点实践后的个人体会

做过这轮适配之后,我对 Flutter 跨端的能力又高看了一眼。jerelo 本身是纯 Dart 组件,鸿蒙上的网络栈和 Android 有差异,但 RPC 的核心调度逻辑完全不用改,改动都收敛在连接层。这反过来验证了一个技术选型原则:底层传输可以替换,应用层协议最好稳定。只要 JSON-RPC 2.0 这套消息语义不变,未来就算鸿蒙 SDK 再怎么升级,迁移成本也都在可控范围。

如果你也在做边缘端 Flutter 开发,我建议先把 RPC 通讯基础设施打稳,再往上堆业务。先把心跳、重连、并发限制、日志这些基本功做好,后面接多少设备都不会慌。

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

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

立即咨询