最近在给一个 Flutter 项目做鸿蒙化改造,需求说大不大,但战线真的长。页面迁移还好说,最折磨人的是一堆第三方库在鸿蒙端没有现成的原生实现。这次要搞的是 pusher_channels,一个在 Android/iOS 上很成熟的 Pusher 实时通讯客户端。它依赖的原生 SDK 在鸿蒙这边根本不认账,想直接在 HarmonyOS NEXT 上跑起来,唯一的出路就是自己动手做鸿蒙化适配——用 ArkTS 重写原生侧,然后通过 Flutter 的 EventChannel 和 MethodChannel 把 Dart 层和鸿蒙 WebSocket 能力打通。
这篇文章不打算空谈概念,重点是把我在鸿蒙端把 pusher_channels 跑通的全过程写清楚,包括整体方案怎么拆、Dart 层怎么改、ArkTS 侧怎么实现 Pusher 的 WebSocket 握手协议和心跳机制、以及实际踩过的权限、数据序列化、订阅时序这些坑。如果你正在集成本文要讲的实时通讯能力,或者正准备把其他 Flutter 三方插件迁移到鸿蒙,这篇应该能帮你省不少事。
1. 先拆清楚:这个适配任务到底难在哪
很多同学觉得适配一个 Flutter 插件,就是把源码拷过来改改就行,这是最大的误解。你要先搞清楚 pusher_channels 原本是怎么工作的,才知道鸿蒙化到底要动哪一层。
1.1 pusher_channels 的“原生依赖”问题
pusher_channels 本质上是 Flutter 对 Pusher 官方客户端能力的一层封装。Dart 层暴露给业务方的是一套看起来人畜无害的 API,比如连接、订阅频道、监听事件等。但背后的真实情况是:在 Android 端,它拉起的是 Pusher 的 Java/Android 客户端;在 iOS 端,它内部依赖的是 Pusher 的 Swift 客户端。所以你对上层暴露的 Dart 接口,本质上全部是由原生能力在支撑。
这就带出一个很直接的问题——鸿蒙没有这些原生 SDK,就算你把 pusher_channels 的 Dart 源码拖进来,编译到鸿蒙包时,原生侧直接缺胳膊少腿,根本跑不起来。
所以适配的本质不是“修改源码”,而是“复刻原生行为”。你要在鸿蒙的 ArkTS 环境里,把 Pusher 协议栈重新实现一遍,包括建立 WebSocket 连接、完成 Pusher 握手、发送频道订阅命令、接收服务端推送事件、处理 ping/pong 心跳等。同时,你还要在鸿蒙 Flutter 插件框架里,把原本 Android/iOS 的通道逻辑替换成鸿蒙的实现。
1.2 鸿蒙 Flutter 插件机制就是把钥匙
鸿蒙端跑 Flutter,用的通信机制和 Android 系出同门,核心就是 MethodChannel 和 EventChannel。这两个词在热词里经常被问到,其实就是 Flutter 和原生之间的两条桥。
MethodChannel 是“一问一答”的控制通道,适合 connect、disconnect、subscribe 这种需要返回结果的命令。EventChannel 是“持续流水”的数据通道,适合把 WebSocket 推过来的事件、状态变化源源不断吐给 Dart 层。适配 pusher_channels 的机会就在这:原生侧你完全可以用鸿蒙自己的 WebSocket 模块写一套业务逻辑,然后按照 Dart 层原本需要的接口语义,通过这两种通道重新建立连接。Dart 业务代码不需要大改,我们只需要在底层替换了一块“零件”。
这其实是 Flutter 组件通信的本质——不同平台的差异被抽象成统一的通道契约,适配者只需要保证契约两边都能理解对方。
1.3 同类案例:okta 鸿蒙适配给的经验
在 Flutter 生态里,okta 的鸿蒙适配流程和 pusher_channels 非常像。两者都是依赖海外官方 SDK、不提供鸿蒙版本的三方插件。社区里比较靠谱的做法是:第一步,锁定 Dart 层公开 API,把它们当作接口契约;第二步,用鸿蒙的系统能力实现同等行为;第三步,通过 EventChannel/MethodChannel 把原生行为桥接回去。okta 适配时,开发者把认证操作全部改成了鸿蒙的鉴权服务接口,上层 Flutter 代码几乎没动。
这套流程完全可以套用到 pusher_channels 的适配中。我建议你先把“契约”想清楚,不要一头扎进 ArkTS 代码里。契约稳了,后面的事都是体力活。
2. 方案设计:控制面和数据面分开想
在写代码之前,我把 pusher_channels 的鸿蒙化方案拆成了两条线:控制面和数据面。控制面管的是连接生命周期和订阅动作,数据面管的是事件流。两条线分开设计,逻辑会清爽很多,后面排查问题也方便。
2.1 Dart 层如何“动手术”
最直接的做法是直接改 pub 包源码,但我不推荐。更好的方式是做一个本地包,把 pusher_channels 的源码拷到工程里,通过 dependency_overrides 指向本地路径,然后基于原 API 做兼容扩展。这样不会污染线上依赖,改起来也敢大胆。
改造后的 Dart 层应该尽量保持原来的调用方式。设计一个抽象接口 PusherClientInterface,原 Android/iOS 走原有实现,鸿蒙走新的 OHOS 实现。底层用条件导入,根据 Platform.isOhOS(或者编译宏)选择不同的客户端实现。业务侧的 create、subscribe、onEvent 等调用方式保持不变。
这种做法的好处很直接:如果以后官方支持了鸿蒙,你只需要把本地包换回官方依赖,业务代码零改动。
2.2 控制面(MethodChannel)设计
控制面我统一定义了一组方法,名字尽量贴近原插件的能力,方便对齐:
- connect:传入 appKey、cluster、authEndpoint 等参数
- disconnect:断开连接并清理资源
- subscribe:订阅频道
- unsubscribe:取消订阅
- trigger:向频道触发事件(比如客户端事件)
这里的参数统一用 Map 传入,原生侧解析后再决定怎么组装 WebSocket 消息。MethodChannel 的方法名建议带上插件标识,避免和别的插件冲突。比如我用的是 pusher_channels_ohos/methods。频道名保持小写,和 Pusher 的语义一致。
为什么 MethodChannel 适合做这些事?因为它的调用是 Future 返回的,connect 是否成功、subscribe 是否已响应,都能清晰地同步到 Dart 层。反过来,事件和状态变化不能用它广播,因为那是一次性的响应,所以必须走 EventChannel。
2.3 数据面(EventChannel)设计
数据面我用 EventChannel 传输四类信息:连接状态变化、订阅成功事件、业务事件消息、错误信息。EventChannel 的名字定为 pusher_channels_ohos/events。
这里有个非常关键的经验:原生侧往 Dart 层传数据,尽量传 JSON 字符串,不要直接传 Map。很多人觉得 EventChannel 能传 Map 很方便,但 Map 经过平台通道序列化以后,在某些鸿蒙版本上会出现类型错乱,尤其是嵌套 Map 和数组混用的时候。你也不想半夜被线上「收到个类型错误」的日志叫醒吧?所以我的做法是,所有事件统一在原生侧组装成 JSON 字符串,Dart 层再自己解。格式统一,排查也容易。
我在 Dart 层做了这么几个事件回调:onConnectionStateChange、onEvent、onError、onSubscriptionSucceeded。其中 onEvent 接收的是带 channel 和 data 的完整模型,里面的事件名、频道名、负载靠解析 JSON 字符串获得。
3. 实操实录:ArkTS 侧把 Pusher 协议跑起来
方案定了,后面的活就聚焦在鸿蒙原生侧了。下面这段是我的实际操作过程,代码是基于当前版本的 ArkTS 写的,如果你用的 SDK 版本不同,接口签名可能会有一点偏差,但整体思路是通用的。
3.1 工程准备与权限配置
首先,在鸿蒙 Flutter 工程的 ohos 目录里,创建一个 Flutter 插件模块(或者直接手工注册插件,取决于你工程的脚手架)。无论哪种方式,最后都要保证 Flutter 引擎能加载到这个原生插件。
然后是最容易漏的一步——网络权限。如果 module.json5 里不显式声明 ohos.permission.INTERNET,WebSocket 会直接连不上,而且错误日志不直观,就像“超时”。我一开始就是在这里卡了半小时,后来在module.json5的 requestPermissions 里加了权限声明:
{ "module": { "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ] } }提一句:在鸿蒙开发中,网络权限属于敏感权限,应用市场审核时也会看。适配第三方插件不要省这一步。
3.2 在 ArkTS 里创建 WebSocket 连接
鸿蒙提供的 WebSocket 模块,API 12 之后的推荐写法是通过 @kit.NetworkKit 引入。部分旧版本 SDK 还支持import webSocket from '@ohos.net.webSocket',我用新写法,同时保留了兼容性判断的注释,方便团队在不同机型上验证。
import { webSocket } from '@kit.NetworkKit'; import { BusinessError } from '@kit.BasicServicesKit'; export class PusherWebSocket { private ws: webSocket.WebSocket | undefined; private socketId: string = ''; connect(appKey: string, cluster: string): Promise<void> { const url = `wss://ws-${cluster}.pusher.com/app/${appKey}?protocol=7&client=ohos-flutter&version=1.0.0`; this.ws = webSocket.createWebSocket(); return new Promise<void>((resolve, reject) => { this.ws!.on('open', (err: BusinessError | undefined) => { if (err) { reject(err); return; } // 连接已打开,等待 connection_established 事件 }); this.ws!.on('message', (err: BusinessError | undefined, value: string) => { if (err) { this.handleError(err); return; } this.handleServerMessage(value); }); this.ws!.connect(url, (err: BusinessError | undefined) => { if (err) { reject(err); return; } resolve(); }); }); } }这段代码的要点是:连接地址由 appKey 和 cluster 确定,这是 Pusher 的固定 WebSocket 地址规则。protocol=7是经典的 Pusher 协议版本号,大部分客户端都用这个。不要小看这个参数,之前有人用错协议号,服务和客户端反复握手失败,浪费一个下午。
3.3 完成 Pusher 握手和订阅流程
WebSocket 连接建立后,Pusher 不会马上让你收业务消息。服务端会先下发一条connection_established事件,里面携带了一个socket_id,这个东西非常重要。你要先把它存下来,后续订阅私有频道或进行鉴权时都要用到。
我在 handleServerMessage 里做了这样的分发判断。先把收到的字符串解析成对象,再根据 event 字段决定走哪个分支:
private handleServerMessage(raw: string) { const msg = JSON.parse(raw); switch (msg.event) { case 'pusher:connection_established': this.socketId = msg.data.socket_id; this.onConnectionStateChange('connected', this.socketId); // 此时再执行积压的 subscribe 请求 this.flushSubscribeQueue(); break; case 'pusher:subscribe_succeeded': this.onSubscriptionSucceeded(msg.channel); break; case 'pusher:pong': // 心跳回复,不需要额外处理 break; default: // 普通业务事件,透传给 Flutter this.onEvtReceived(msg.channel, msg.event, msg.data); } }发送订阅请求时要遵循 Pusher 的消息格式。第一次写很容易把 subscribe 包装成乱七八糟的结构,Pusher 会直接无视你。正确的格式是:
{ "event": "pusher:subscribe", "data": { "channel": "my-channel" } }对应 ArkTS 里直接构造字符串发送:
public subscribe(channel: string) { if (!this.socketId) { // 握手还没完成,先放到队列里,等 connection_established 再发 this.subscribeQueue.push(channel); return; } this.sendMessage(JSON.stringify({ event: 'pusher:subscribe', data: { channel } })); }这里有一个非常容易踩的坑:如果在 connection_established 之前就调用 subscribe,服务端会直接把这条消息丢弃,而且不会报错。所以我们做了一个 pending 队列,等 socket_id 拿到之后再统一 flush。这个设计救了很多次场。
3.4 心跳机制的具体实现
WebSocket 本身也有 TCP 层的心跳,但在移动端,尤其是鸿蒙这种系统,网络切换、后台休眠非常频繁,光靠 TCP 心跳远远不够。Pusher 服务端会定期发送pusher:ping,客户端需要响应对应的pusher:pong,否则服务端会认为连接死了,关闭连接。
在 handleServerMessage 里要处理这个 ping 事件:
case 'pusher:ping': this.sendMessage(JSON.stringify({ event: 'pusher:pong' })); break;不过我的实测经验是:应用长时间退到后台后,部分网络环境会把这条连接静默杀掉,也就是 TCP 还挂着,但数据已经不通了。这时候客户端要自救,在 Dart 层或原生层加一个应用级心跳,每 15 到 20 秒主动发送一次 ping,并监听回调来判断连接是否存活。如果连续两次没有收到任何响应,就主动 close 后重连。这个策略能显著提升推送到达率,尤其是弱网场景下。
Dart 侧可以起一个 Timer,调用原生侧发送 ping:
Timer.periodic(Duration(seconds: 15), (timer) { _channel.invokeMethod('ping'); });原生侧收到 ping 动作时,如果真的还连着,就直接发一个pusher:ping字符串。同时维护一个 lastReceived 时间戳,用于判断超时。
4. 实战中的常见问题与排障笔记
整个适配过程中踩的坑不少,有些是鸿蒙特性造成的,有些是 Pusher 协议本身的细节。我整理成一份速查表,希望你能绕过去。
4.1 权限缺失:设备上一直超时
排查思路:先确认 WebSocket 地址能不能用浏览器连上,排除服务端问题。接着看鸿蒙端日志里有没有 Permission denied 相关报错。如果有,基本是 module.json5 缺了 INTERNET 权限。这个问题的坑在于,有些设备上错误日志不明显,表现为连接超时,很容易误判成网络问题。
4.2 消息全乱:EventChannel 序列化问题
如果用 MethodChannel 或 EventChannel 直接传 Map,一旦数据里有嵌套 Map 或者包含大量 JSON 数据,鸿蒙端在序列化时偶尔会发生字段丢失。最稳的方案是原生侧直接传 JSON 字符串,Dart 层用 jsonDecode 自己解。我最后把所有事件统一改成 String 传输,数据格式问题再没出现过。
4.3 后台切回后收不到消息
看到热词里有人在问 flutter navigator 切换页面后状态会不会丢,这里要分清楚:路由切换不会丢连接,但如果鸿蒙的 Ability 被系统回收了,Flutter 引擎和原生插件都要重建,WebSocket 连接自然就没了。这时候不能只在原生层被动等断线回调,建议在 Dart 层的生命周期里监听应用从后台恢复的事件,主动检查连接状态,断了就重连。
4.4 订阅时序:connection_established 还没到
这个前面已经提过,属于必须处理的经典场景。我再强调一下:不要在连接打开那一刻就发 subscribe。Pusher 要求先收到 connection_established 事件、拿到 socket_id 之后,才能订阅频道。准备一个订阅队列,等握手完成再批量下发,是最简单的解决方式。
4.5 类型错误集中在私有频道鉴权
如果业务用了私有频道,订阅前要走 authEndpoint 进行鉴权,需要在 subscribe 时把 socket_id 带上。这个时机也很讲究。不能太早,socket_id 还没生成;也不能太晚,服务端会拒绝。建议把私有频道的 presence 和 private 前缀单独做个判断,在鉴权成功后再触发原先的 subscribe 逻辑。
我把排障经验整理成了表,方便你对照:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 连接一直超时 | 缺少 INTERNET 权限 | module.json5 增加权限声明 |
| 握手能完成但订阅不成功 | 没有等 connection_established | 维护订阅队列,拿到 socket_id 再发 |
| 收到事件但不是 JSON 对象 | EventChannel 传 Map 被序列化破坏 | 统一通过字符串传输 JSON |
| 退后台一段时间后收不到推送 | 底层连接被静默断开 | 应用级 ping 心跳 + 超时重连 |
| 私有频道订阅失败 | socket_id 未正确参与鉴权 | 在鉴权载荷里带上 socket_id |
还有一点需要单独提醒:如果你同时在做 Flutter 原生视图相关的适配,比如 PlatformView,一定不要把它和插件通道混在一个模块里。PlatformView 是另一套渲染桥接逻辑,把两者混着排查会把问题搞得很乱。我一开始就是因为把 EventChannel 和原生视图的代码放在了一起,导致事件回调迟迟不触发,排查时走了很多弯路。
最后再分享一个小技巧:鸿蒙适配过程中,日志是最重要的朋友。WebSocket 的 on('message') 回调里尽量把原始字符串打出来,尤其是握手阶段。不要只打“收到消息”这种没营养的日志,把 event 类型和 socket_id 完整打印出来,只要看到 connection_established 后面的 socket_id 和业务请求里的 socket_id 对不上,你就能立刻定位到是握手解析问题,而不是网络问题。这套调试习惯救了我很多次,希望你用的时候也能少走弯路。