接了个挺头疼的需求:要把现有 App 里用的pusher_channels实时通讯功能搬到鸿蒙端。刚拿到手的时候我以为是文档问题,翻一遍发现渠道路子全通,但就是连不上,后来才弄明白,这压根不是普通 bug,是 Flutter 插件在鸿蒙上没有原生实现的问题。这篇文章就是我从零开始做pusher_channels鸿蒙化适配的完整记录,包括思路选型、ArkTS 里的 WebSocket 对接、Pusher 协议事件映射、以及那些不跑一遍根本不会知道的小坑。适合正在做鸿蒙生态迁移、或者想在鸿蒙 App 里接 Pusher 实时通道的 Flutter 开发者看。
1. 先搞清楚 pusher_channels 到底依赖了什么
1.1 它是什么级别的插件
很多人以为pusher_channels就是个纯 Dart 包,其实它是一套标准的 Flutter federated plugin。外层的PusherChannels类提供 Dart API,真正干活的是底层的pusher_channels_platform_interface,再往下是各端实现,Android 有PusherChannelsAndroid,iOS 有PusherChannelsIos,它们通过 Platform Channel 去调原生 SDK。在鸿蒙之前,这套链路不存在,Flutter 引擎找不到pusher_channels的鸿蒙实现,于是一切入口直接报 MissingPluginException。
理解这个层次很关键。因为鸿蒙化适配本质上不是让你重写 Pusher 客户端逻辑,而是补上 "鸿蒙这一层原生的实现"。
1.2 完整连接链路拆开看
一次最普通的 Pusher 连接,从 Dart 侧看只是pusher.connect(),但实际跑起来是这样:
- Dart 端
PusherChannels.connect()调用平台接口; - Android 或 iOS 原生 SDK 建立到
ws-{cluster}.pusher.com的 WebSocket; - 连接成功后原生 SDK 订阅声明的 channel,持续监听事件;
- 收到消息后通过 MethodChannel 或 EventChannel 把事件回传给 Dart;
- Dart 端触发
onEvent回调。
在第 2 步你会发现,整个插件最核心的依赖就是WebSocket。Pusher 的服务端本身不关心你用什么客户端,只要你按协议发出 JSON 帧,它就能回事件。这给鸿蒙化留了一条非常宽的路。
1.3 所以鸿蒙化要改的其实只有一段
Dart 层不用动,PusherChannels的对外 API 不用动,pusher_channels_platform_interface定义的接口不用动,真正要做的只有一件事:提供一个可以运行在鸿蒙上的原生实现,让它负责建立 WebSocket、管理生命周期、收发协议消息,并且把事件回传给 Dart。
这句话说出来简单,做起来要命。因为原生实现里你要处理的不只是 "建立连接" 一个动作,还有订阅、取消订阅、事件分发、心跳、断线重连、连接状态变更通知。任何一个环节漏了,Dart 层就会出现事件收不到、状态一直连接中、或者无缘无故断开的诡异表现。
2. 鸿蒙端 WebSocket 适配的三种路线与选型
动手之前我先在纸上列了三条路线,每个都分析了一遍利弊。
2.1 路线 A:纯 Dart 层替换 WebSocket 客户端
pusher_channels在 Dart 内部实际上是通过一个注入的 WebSocket 工厂来创建连接的。如果鸿蒙上的 Flutter 引擎已经支持dart:io的WebSocket,那理论上我可以 fork 一份pusher_channels,在 Dart 层把它的默认 WebSocket 实现换掉,或者干脆不依赖任何原生代码,直接把整个 Pusher 协议用 Dart 写一遍。
这条路的好处是工程量看起来最小,所有代码都在 Dart 层,跨端复用。但风险在于,鸿蒙上的 Flutter 引擎对dart:io底层 Socket 的支持成熟度还没有完全对齐 Android。遇到特定网络环境下的 WebSocket 握手失败、代理失效、IPv6 连接异常时,你很难判断是引擎问题还是自己的代码问题。而且 fork 第三方包意味着以后上游更新都要手动同步,维护成本很高。
2.2 路线 B:新增鸿蒙平台通道实现
这是最正统的 Flutter 插件迁移姿势。我保留pusher_channels的 Dart API 和平台接口,新建一个名为pusher_channels_harmony的插件包,在这个包里用 ArkTS 调用鸿蒙系统的 WebSocket API,完整实现平台接口的所有方法,然后通过 Flutter MethodChannel 和 EventChannel 与 Dart 通信。
这条路的工作量集中在 ArkTS 侧,但收益也很直接:不动原有 Dart 层,不影响其他平台,所有网络逻辑都走鸿蒙系统 API,稳定性和系统兼容性由官方保证。对长期维护来说,这是最干净的一版。
2.3 路线 C:用 System API 直接桥接
如果在鸿蒙原生层不想用系统 WebSocket,也可以直接通过@ohos.net.webSocket封装一个客户端,然后暴露给 Flutter。实际上路线 B 和 C 在实现层面是重叠的,区别只在于有没有中间层。如果未来 Pusher 官方出了鸿蒙 SDK,我只需要把内部实现换成官方 SDK,对外契约不用变。所以我把 B 和 C 合并成了一种:在鸿蒙原生实现里优先选择系统 WebSocket,后续可以无缝切换原生 SDK。
2.4 我的最终选型
最终我选的是 B 为主、A 为辅的组合方案:
- 主体采用新增
pusher_channels_harmony插件的方式; - 在 ArkTS 内使用
@ohos.net.webSocket实现 WebSocket 连接; - 如果某些场景下 ArkTS 层跑不通,留一个 Dart 层的可替换 WebSocket 客户端作为降级开关。
这个组合的好处是我不需要为 "某个极端环境里系统 WebSocket 连不上" 卡死整个项目,Dart 层也能临时顶上。虽然看起来多了一条路径,但代码层面做了一个工厂抽象,不会有太多冗余。
3. 实操:新建 pusher_channels_harmony 插件
这一趴是纯干活的内容。你不需要照抄我全部代码,核心是把结构和方法看清楚。
3.1 创建插件工程与目录结构
在 Flutter 工程里,我习惯用源码依赖的方式来做。先在项目根目录建一个plugins/pusher_channels_harmony目录,然后发布到 pub 前先通过 path 依赖引入:
dependencies: pusher_channels: ^2.2.0 pusher_channels_platform_interface: ^2.2.0 pusher_channels_harmony: path: plugins/pusher_channels_harmony插件包的目录结构大概是:
pusher_channels_harmony/ pubspec.yaml lib/ pusher_channels_harmony.dart harmony/ entry/src/main/ ets/ PushChannelsHarmonyPlugin.ets PusherWebSocketClient.ets PusherEventMapper.ets module.json5pubspec.yaml里要声明这是pusher_channels_platform_interface的一个注册实现:
name: pusher_channels_harmony description: HarmonyOS implementation for pusher_channels. version: 1.0.0 environment: sdk: '>=3.0.0 <4.0.0' dependencies: flutter: sdk: flutter pusher_channels_platform_interface: ^2.2.0 flutter: plugin: platforms: ohos: default_package: pusher_channels_harmony package: dev.pusher.pusher_channels_harmony这里的ohos平台标识是鸿蒙插件注册的关键,别写错了。
3.2 配置 HarmonyOS 权限与网络声明
鸿蒙应用访问网络,必须在module.json5里声明ohos.permission.INTERNET权限。不声明的话,WebSocket 连接会直接报 Permission denied。
{ "module": { "name": "entry", "type": "entry", "requestPermissions": [ { "name": "ohos.permission.INTERNET" } ], "abilities": [ { "name": "EntryAbility", "srcEntry": "./ets/entryability/EntryAbility.ets", "exported": true } ] } }另外,Pusher 服务通常走的是 TCP 443 端口,属于明文网络吗?不,它是 WSS,所以不需要额外配置明文传输允许。但如果你本地的测试环境是ws://非加密连接,鸿蒙默认还会禁止非加密流量,这时候就要在网络安全配置里放开。我建议直接测试环境也走 WSS,省掉这个坑。
3.3 在 ArkTS 中封装一个 WebSocket 客户端
鸿蒙的@ohos.net.webSocket提供了一个比较完整的客户端能力。第一步是创建一个PusherWebSocketClient.ets,把系统 WebSocket 的创建、连接、收消息、发送、关闭封装成一个可复用的类。
先导入模块并创建实例:
import { webSocket } from '@kit.NetworkKit'; export class PusherWebSocketClient { private ws: webSocket.WebSocket | null = null; private url: string = ''; private isConnected: boolean = false; connect(url: string, options?: webSocket.WebSocketRequestOptions): void { this.url = url; this.ws = webSocket.createWebSocket(); this.ws.on('open', (err, value) => { if (err) { this.onError(err); return; } this.isConnected = true; this.onOpen(); }); this.ws.on('message', (err, data) => { if (err) { this.onError(err); return; } // 注意:data 可能是 string 也可能是 ArrayBuffer const message = typeof data === 'string' ? data : (data as ArrayBuffer).toString(); this.onMessage(message); }); this.ws.on('close', (err, value) => { this.isConnected = false; this.onClose(value?.code, value?.reason); }); this.ws.on('error', (err) => { this.isConnected = false; this.onError(err); }); this.ws.connect(url, options); } send(data: string): void { if (this.isConnected && this.ws) { this.ws.send(data); } } close(): void { if (this.ws) { this.ws.close(); } } }这几个事件就是整个监听器的骨架。onOpen、onMessage、onClose、onError需要由插件层注册进来,这样插件才能把它们转换成 Flutter 能识别的通道消息。
3.4 通过 MethodChannel 暴露连接、订阅、发事件
鸿蒙端实现PusherChannelsHarmonyPlugin时,我用MethodChannel处理 Dart 侧的调用。Dart 侧要能执行四类操作:初始化、连接、订阅、发送事件。
import { MethodCall, MethodChannel } from 'flutter-sdk'; export class PusherChannelsHarmonyPlugin implements FlutterPlugin, MethodCallHandler { private channel: MethodChannel | null = null; private client: PusherWebSocketClient | null = null; onAttachedToEngine(binding: FlutterPluginBinding): void { this.channel = new MethodChannel(binding.getBinaryMessenger(), 'pusher_channels_harmony'); this.channel.setMethodCallHandler(this); } onMethodCall(call: MethodCall, result: MethodResult): void { switch (call.method) { case 'connect': const url = call.arguments['url'] as string; this.client = new PusherWebSocketClient(); this.client.onMessage = (msg: string) => this.handleSocketMessage(msg); this.client.connect(url); result.success(null); break; case 'subscribe': const channel = call.arguments['channel'] as string; this.sendSubscribe(channel); result.success(null); break; case 'sendEvent': const event = call.arguments['event'] as string; const data = call.arguments['data'] as string; this.client?.send(JSON.stringify({ event, data })); result.success(null); break; case 'disconnect': this.client?.close(); result.success(null); break; default: result.notImplemented(); } } }这里要注意:Pusher 的订阅操作并不是直接掉一个 native 方法,而是要发一个 JSON 帧到 WebSocket 里,所以我subscribe方法内部的sendSubscribe本质上也是走send。
3.5 通过 EventChannel 把消息推送到 Dart
MethodChannel 只能解决 "Dart 调原生" 的问题,反过来 "原生主动给 Dart 推事件" 要用 EventChannel。
鸿蒙端持有一个 EventSink,当 WebSocket 收到消息时,解析成标准结构,然后sink.success(data)把它推到 Dart 流里:
export class PusherChannelsHarmonyPlugin implements EventStreamHandler { private eventSink: EventSink | null = null; onListen(parameters: string, sink: EventSink): void { this.eventSink = sink; } onCancel(parameters: string): void { this.eventSink = null; } private handleSocketMessage(message: string): void { try { const json = JSON.parse(message); // 转换为统一的 event payload const payload = this.mapper.map(json); this.eventSink?.success(payload); } catch (e) { // 解析失败不能 crash 原生层,记录一下即可 } } }对应 Dart 侧,我会把 EventChannel 的 Stream 暴露给pusher_channels_platform_interface,这样上层PusherChannels.onEvent就能收到数据了。
到这里,最基础的连通链路已经打通。但真的跑起来,你会发现还差一大截,因为 Pusher 协议本身要比 "连上了、能发字符串" 复杂得多。
4. 核心协议细节:Pusher 事件与订阅状态机
4.1 先看懂 Pusher 的 WebSocket 消息格式
Pusher 的协议层并不神秘,本质就是 WebSocket 上来回传 JSON 字符串。从服务端到客户端的消息格式是:
{ "event": "pusher:subscription_succeeded", "channel": "my-channel", "data": "{\"myData\":\"value\"}" }从客户端到服务端的格式是:
{ "event": "pusher:subscribe", "data": { "channel": "my-channel" } }注意data字段有时是字符串,有时是对象。Pusher 官方很多消息里data都是 JSON 字符串,需要二次解析。这一个点如果不处理干净,后面所有业务数据处理都会出问题。
我在鸿蒙端专门写了一个PusherEventMapper,统一把所有消息标准化,不再向下游传原始字符串:
export class PusherEventMapper { map(raw: object): object { const event = raw['event'] as string; const channel = raw['channel'] as string; let data = raw['data']; if (typeof data === 'string') { try { data = JSON.parse(data); } catch (e) { // 保留原字符串 } } return { event, channel, data }; } }4.2 连接建立:别漏掉 socket_id
当 WebSocket 建立成功后,Pusher 服务端会先发一条pusher:connection_established事件,它的data里带一个socket_id。这个socket_id是当前连接的唯一标识,用途很多:
- 服务端鉴权时校验客户端身份;
- 控制台排查问题时定位到具体连接;
- 某些应用需要把
socket_id传给业务后端来生成签名。
如果适配的时候没有把socket_id保存下来,后续做私密 channel 鉴权时会发现签名一直对不上。我在 Dart 侧的 abstraction 里把socket_id作为连接成功的信令,原生端必须把这个事件翻译成 "连接成功" 状态。
4.3 订阅:三个状态缺一不可
调用订阅后,客户端发一个pusher:subscribe,服务端会回pusher:subscription_succeeded。但在失败场景下,还会收到pusher:subscription_error。我建议在适配层把订阅状态机做全,至少包括:订阅中、订阅成功、订阅失败。
很多初版适配只处理了 "发送 subscribe 帧" 和 "收到 success 帧",忽略了 error,结果线上出现订阅失败后,Dart 层永远无回调,只能等超时。正确做法是在handleSocketMessage里优先判断事件名字是这三类中的哪一种,然后分别触发对应回调。
4.4 心跳与断线重连
Pusher 服务端默认会通过 WebSocket 层的 ping/pong 做连接健康检查。但实际在鸿蒙上,我发现如果一段时间没有消息往来,某些网络环境(尤其带 NAT 超时的运营商网络)会自动断开连接,而客户端还没有感知。
所以在鸿蒙适配里,我在原生侧加了一个应用层心跳:每 30 秒发一次{"event":"pusher:ping"},收到服务端的pusher:pong就认为连接活跃。
如果连续 3 次没有收到 pong,主动触发重连:
private heartbeatTimer: number | null = null; startHeartbeat(): void { this.stopHeartbeat(); this.heartbeatTimer = setInterval(() => { if (this.isConnected && this.missedPongCount < 3) { this.client?.send(JSON.stringify({ event: 'pusher:ping' })); this.missedPongCount++; } else { this.reconnect(); } }, 30000); } stopHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } }重连策略我做了指数退避:第 1 次 1 秒、第 2 次 2 秒、第 3 次 4 秒,最多 30 秒。同时要确保 Dart 层能收到pusher:connection_state_change之类的状态事件,否则上层 UI 一直卡在 "连接中"。
5. 我踩过的坑:常见问题与排查实录
这一节应当是全文最有价值的部分,全是跑真机时遇到的真实情况。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
Dart 层一直报MissingPluginException | 插件没有在鸿蒙侧注册 | 确认pubspec.yaml中 plugin platforms 里有ohos,并执行flutter pub get后重新构建 |
| 连接直接 Permission denied | ohos.permission.INTERNET没配置 | 在module.json5的requestPermissions里补全权限 |
WebSocket open 成功但收不到pusher:connection_established | 消息事件回调没接到 message | 检查ws.on('message')注册时机,必须写在connect()之前 |
| 订阅发送成功但没有回调 | 忘了解析pusher:subscription_succeeded | 在 mapper 中把subscription_succeeded单独映射为订阅成功状态 |
业务事件收到后data是字符串而不是对象 | 未做二次 JSON 解析 | 统一在PusherEventMapper里二次JSON.parse |
| 一段时间后 WebSocket 静默断开 | NAT 超时或心跳丢失 | 开启应用层 ping/pong,并做指数退避重连 |
| 连接成功后 socket_id 拿不到 | 没有存储初始事件 | 收到pusher:connection_established时立即缓存 socket_id 存到插件字段 |
| 多 channel 订阅时事件串 channel | 分发时没有按 channel 过滤 | Dart 层收到事件后先按 channel 匹配,原生只做透传 |
5.1 最坑的:on('message')注册顺序
鸿蒙 WebSocket 的open事件触发很快,我之前先写了connect()再注册on('message'),结果 open 之后服务端立刻发来的第一条pusher:connection_established就丢了。后面才意识到,所有on注册都要在connect()之前完成。
这个顺序问题在文档里根本不会特别提示,但实际影响非常大。如果你发现 "WebSocket 明明显示已连接,但 Dart 端永远等不到连接成功",优先查这个。
5.2 进程重建后连接状态残留
鸿蒙上应用切到后台再回前台,如果系统回收了 WebSocket 但 Flutter 引擎还活着,就会出现 Dart 端认为 "已连接" 但实际网络层已经断开的情况。我的处理方案是在插件里监听on('close')事件,只要 close 就立即向 Dart 推一个断开事件,同时清理心跳计时器。这样上层可以在收到 close 后主动重连,而不是等着下次发消息时才发现已经断了。
5.3 多 Dart entry 初始化冲突
Flutter 应用一旦用了多引擎或者混合开发,鸿蒙插件可能会被初始化多次。如果每次初始化都 new 一个 WebSocketClient,旧实例没有释放,就会出现多个 socket 同时连接 Pusher,产生重复订阅。我的做法是插件类持有单例,onAttachedToEngine时判断是否已有 client,有就复用,否则才创建。
6. 这次适配做完,我对 Flutter 插件鸿蒙化的几点体会
很多人问,是不是所有 Flutter 三方库都能直接照这个方案搬到鸿蒙?其实分情况。像pusher_channels这种底层依赖协议相对简单的插件,鸿蒙化成本并不高,核心就一个 WebSocket 连接。但如果插件依赖了 Android/iOS 的复杂原生服务(比如推送厂商 SDK、安全芯片、本地数据库),那就不是写几百行 ArkTS 能解决的了,得等官方或社区出对应鸿蒙 SDK。
我在这次适配里比较大的收获是:鸿蒙化适配的关键不是把 API 对着抄一遍,而是先把平台接口的边界切清楚。Dart 层按平台接口定义好契约,鸿蒙端只关心通信和事件分发,这两者解耦后,未来不管底层换成鸿蒙官方 SDK 还是第三方 SDK,都不影响上层调用。
另外一个小技巧:调试鸿蒙 WebSocket 时,不要一上来就直连 Pusher 正式服务,我用了一个本地 echo websocket server 验证收发链路,确认 ping/pong、连接断开事件都正常后才切换到 Pusher。这样能把插件自身的问题和网络环境的问题快速隔离开。
最后提醒一下,鸿蒙侧 WebSocket 的二进制帧和文本帧处理方式不同,Pusher 的协议数据都是 UTF-8 文本,所以我在on('message')里优先按 string 处理,对ArrayBuffer的兼容只是兜底。如果后续要支持 Pusher 的二进制消息(比如传给 channel 的 ArrayBuffer),这里还要再单独扩展,不过常见的消息结构和事件通知都是 JSON,够用了。
这次适配做完后,我又把工程里其他几个 Flutter 实时类组件全部盘了一遍,发现只要协议层是 WebSocket + JSON 这套标准玩法,鸿蒙化思路几乎可以复用。如果你正卡在类似的三方库适配里,建议先把协议抓包看明白,再动手改原生层,你会发现省掉很多没必要踩的坑。