☰
Flutter插件鸿蒙化实战:pubnub双向长链接适配全记录
2026/9/30 3:30:25 网站建设 项目流程

今年我们团队做鸿蒙化适配,任务清单里躺着不少 Flutter 三方库,其中最让我惦记的就是 pubnub。原因很简单:它是靠长链接活着的实时通信 SDK,Android 和 iOS 上跑得很顺,业务方早把消息推送、在线状态、指令下发都挂在上面。结果切到 HarmonyOS NEXT 之后,这个 Flutter 三方库连构建都过不去——Dart 侧倒是还在,底层的 Android 原生实现直接被鸿蒙的编译链挡在门外。实时通信这层要是断了,等于业务的一条大动脉被掐住。

这篇文章就把我们做 pubnub 鸿蒙化的完整过程写出来:为什么不能直接“重新编译一把梭”、双向长链接在鸿蒙侧到底缺什么、怎么用平台通道把 Flutter 和鸿蒙连起来、以及过程中踩过的几个典型坑。如果你也在给 Flutter 项目做鸿蒙适配,或者正在为某个依赖长链接的三方库发愁,这篇应该能给你省下不少排查时间。

1. 当“跨平台”撞上“不兼容”:HarmonyOS NEXT 让 Flutter 插件体系发生了什么

1.1 没有 Android 兼容层之后,第三方 Flutter 插件为什么集体失效

HarmonyOS NEXT 最狠的一条改动,就是把 AOSP 兼容层从系统里拿掉了。这意味着什么呢?原来 Flutter 插件生态里,一大半插件的“移动端实现”都是靠着 Android 原生代码跑起来的,比如底层的 Service、BroadcastReceiver、ContentProvider、各种 .so 库,全是通过 Android 运行时暴露给 Flutter 引擎的。兼容层一撤,这套机制在鸿蒙上直接不存在。

Flutter 的插件系统本身是“联邦制”:一个插件通常分为 Dart 侧接口和端侧实现,Android 实现目录叫 android/,iOS 实现目录叫 ios/。当你在 HarmonyOS NEXT 上构建 Flutter 应用时,Flutter 引擎用的是 OpenHarmony 社区的鸿蒙分支,它认识 ohos/ 这个平台目录,但绝大多数三方包根本没写过 ohos/ 下的代码。于是构建器要么报“找不到插件实现”,要么干脆静默跳过这块原生能力——后者更坑,因为编译能过,跑起来功能却是哑的。

pubnub 就是典型的后者。它作为 Flutter 三方库,在 pub.dev 上有完善的 Android/iOS 实现,但没有鸿蒙实现。我们最初在鸿蒙设备上跑通编译后,发现订阅回调永远不触发,发布消息也查不到记录,整个实时通信链路是单向失联的。

1.2 pubnub 这类实时通信插件在鸿蒙上到底缺什么

先说清楚一个容易被忽视的事实:pubnub 的 Flutter SDK 并不像某些原生推送 SDK 那样,把长连接全部塞在原生侧。它的核心协议层很多是用 Dart 写的,真正依赖平台能力的其实是几块外围功能:

层次涉及内容鸿蒙化要解决的事
Dart API 层发布、订阅、在线状态、历史消息通常不用改,但要验证依赖库在鸿蒙 Flutter 分支上能跑
连接传输层WebSocket/TCP 长链接、HTTP 请求Dart 侧 socket 如果可用就不用动;不可用时需要用鸿蒙原生 WebSocket 接管
原生能力层设备推送、前后台保活、网络状态感知、系统通知必须用鸿蒙原生 API 重新实现

我们在做技术方案时,第一版犯过一个认知错误:以为把 Android 代码翻译成 ArkTS 就完事了。后来发现真正难的不是“翻译代码”,而是“重新设计边界”——pubnub 有三种消息通道(发布订阅通道、在线状态通道、信令通道),每一种都需要把事件从鸿蒙侧实时推给 Flutter 侧。Android 上可以用平台的广播或回调机制,鸿蒙上有自己的一套 TaskDispatcher 和 EventChannel 配合方式,完全照搬 Android 思路会踩坑。

所以鸿蒙化的本质,是给这个 Flutter 三方库补上一个“鸿蒙原生实现”,然后通过 Flutter 平台通道把 Dart 侧和鸿蒙侧焊牢。双向长链接这个 title 里最核心的词,对应的就是两条通道:上行调用(Dart 调鸿蒙)和下行事件(鸿蒙推 Dart),两者配合起来,才是完整的双通道通信。

2. 先看清 pubnub 的链路结构:实时消息并不是一条 WebSocket 那么直白

2.1 pubnub 的能力面:发布订阅、在线状态与设备推送

pubnub 不是简单的“发消息-收消息”工具,它背后是一套全球分布的消息网络。客户端 SDK 负责和 pubnub 边缘节点保持一条持久连接,业务侧调用 subscribe 订阅某个 channel 之后,所有发布到该 channel 的消息都会通过这条连接下行推送过来;上行则通过 publish 接口发出去。

此外还有几个高频能力:presence(在线状态,比如用户进入/离开频道)、signal(轻量信令,常用于“对方正在输入”这类场景)、history(历史消息拉取)。这些能力全部跑在同一条长链接上,靠 pubnub 协议里的不同事件类型做区分。

鸿蒙化的时候,不该只盯着“能把消息发出去”这一个点,而是要把上面这些能力的使用路径全部盘一遍。我们当时列了一个能力清单,逐个标注“Dart 可独立完成”还是“必须原生参与”,这个动作非常重要——它决定了你后续要桥接多少东西。

2.2 双向长连接在移动端的真实形态:连接层、事件层与保活层

在移动端,一条“长连接”其实是由三层叠起来的:

  • 传输层连接:就是那个真实的 WebSocket(或旧版的长轮询,pubnub 新版以 WebSocket 为主)。这一层只管字节流的建立和维护。
  • 协议层事件:连接之上跑的 pubnub 协议,包括 subscribe 确认、message 推送、presence 变更、signal 信令等。这一层负责把字节流翻译成业务可读的事件。
  • 系统层联动:App 前后台切换、WiFi 与蜂窝网络切换、系统休眠与省电策略、进程被杀后的重连时机。这一层是平台相关的,Dart 代码覆盖不到。

双向长链接的“双向”,在 Flutter 插件体系里落到具体技术上,就是 MethodChannel 和 EventChannel 各司其职:Dart 侧要上报用户操作(订阅频道、发送消息)时走 MethodChannel;鸿蒙侧收到服务端推送、连接状态变化时走 EventChannel。这两条通道合在一起,才称得上“双向”。

搞清楚了这层结构,鸿蒙化的边界就清晰了:能放进 Dart 的逻辑(协议解析、消息序列化、业务回调)尽量留在 Dart,不能放进 Dart 的(系统网络感知、后台保活、系统级推送)交给鸿蒙原生。分清楚这个,后面写代码会少很多反复。

3. 鸿蒙化落地的第一步:把 pubnub 的 Dart 侧“骗”到正确的实现上

3.1 联邦插件结构与 endorsed 实现注册

Flutter 官方推荐的三方插件组织方式是 federated plugin(联邦插件):一个面向业务方的 Dart 包,外加多个端侧实现包。业务方的 pubspec.yaml 里会声明不同平台对应的实现包,Flutter 构建系统自动选择加载。

原生 Flutter 插件在这些平台上是标准的:

flutter: plugin: platforms: android: package: com.pubnub.flutter pluginClass: PubnubFlutterPlugin ios: pluginClass: PubnubFlutterPlugin

鸿蒙要插进来,就要显式声明 ohos 平台的 mapping。如果 pubnub 官方没提供,常见做法是 fork 一份,或者用一个本地 override 把 ohos 实现挂进去。我们用的是后者——在自己 Flutter 工程里建一个本地插件包,然后通过dependency_overrides指向本地路径,这样不用等 pub.dev 发布,完全本地联调。

dependency_overrides: pubnub_flutter: path: ./third_party/pubnub_flutter_ohos

还有一个更轻量的机制:dartPluginClass。如果 pubnub 的 Dart 侧实现是插件的入口,你可以不写任何原生代码,直接在 Dart 层提供一套鸿蒙专用实现,通过条件导出让 Dart 侧走不同逻辑。比如 Flutter SDK 判断当前平台是 ohos 时,加载你的鸿蒙 Dart 实现。这个方案跑“纯 Dart 的实时通信”足够,但如果要接原生推送和后台保活,还是得配一个 ohos 原生插件。

3.2 最小 Darwin 侧改动:MethodChannel 去调用鸿蒙原生

我们的做法是双管齐下:先给 pubnub 的 Dart 包打一个 ohos platform 的实现入口,再在鸿蒙侧写一个原生插件来承载连接管理。Dart 侧改动很小,核心就是这样一段:

class PubnubOhosConnection { static const _channel = MethodChannel('pubnub_ohos/connection'); Future<void> connect({ required String publishKey, required String subscribeKey, required List<String> channels, }) async { await _channel.invokeMethod('connect', { 'publishKey': publishKey, 'subscribeKey': subscribeKey, 'channels': channels, }); } Future<void> publish({ required String channel, required String message, }) async { await _channel.invokeMethod('publish', { 'channel': channel, 'message': message, }); } }

为什么不直接调 Dart 层的 pubnub API,而要绕一圈鸿蒙原生?因为我们要把“长链接的持久性”交给鸿蒙侧管理。Dart 侧只发指令,连接的生命周期(重连时机、前后台挂起恢复)由鸿蒙原生掌握。这样 App 切后台再回前台时,连接是否还活着、需要不需要补拉消息,都是由和系统深度绑定的原生侧说了算,而不是让 Dart 层靠猜。

3.3 事件上行的关键通道:EventChannel 的设计

MethodChannel 解决的是“Dart 发指令给鸿蒙”,反过来“鸿蒙把消息和状态推给 Dart”要用 EventChannel。pubnub 下行的数据种类比上行丰富得多:普通 channel 消息、presence 事件、signal 信令、连接状态变更。我们在 Dart 侧按事件类型分别暴露 Stream:

class PubnubOhosEvents { static const _events = EventChannel('pubnub_ohos/events'); static Stream<Map<String, dynamic>> messages() { return _events .receiveBroadcastStream('message') .map((event) => Map<String, dynamic>.from(event as Map)); } static Stream<Map<String, dynamic>> presence() { return _events .receiveBroadcastStream('presence') .map((event) => Map<String, dynamic>.from(event as Map)); } }

这里有个细节:EventChannel 不能按任意主题无限拆分,它是“一个 channel 对应一个事件流”。要么用多个 EventChannel 实例,要么在一个 channel 的载荷里带上事件类型字段,由 Dart 侧做分发。我们选了后者,因为鸿蒙原生侧维护多个 EventChannel 的 streamHandler 生命周期会比较繁琐,不如一个入口、一个分发器来得干净。

不过,这个“一个入口”的设计在后面给我们埋了一个不小的坑,这个放到第 5 节专门说。

4. 鸿蒙侧双向长链接的完整实现:建连、心跳、重连与消息分发

4.1 WebSocket 连接管理与频道订阅映射

鸿蒙侧长连接的底座,用的是系统网络模块的 WebSocket 能力。在 ArkTS 里的基本形态大致是这个样子:

import { webSocket } from '@kit.NetworkKit'; class ConnectionManager { private ws: webSocket.WebSocket | null = null; private eventSink: EventSink | null = null; connect(url: string) { this.ws = webSocket.createWebSocket(); this.ws.connect(url, (err, value) => { if (!err) { // 连接建立后,还要发送 pubnub 的 subscribe 指令 this.ws?.send(JSON.stringify({ action: 'subscribe', channels: this.subscribedChannels, }), (sendErr) => { // handle send result }); } }); this.ws.on('message', (err, data) => { if (err) return; const text = typeof data === 'string' ? data : JSON.stringify(data); // 解析 pubnub 协议帧 const parsed = JSON.parse(text); this.dispatchEvent(parsed); }); this.ws.on('close', (err) => { this.scheduleReconnect(); }); } private dispatchEvent(parsed: any) { // 按事件类型分类,通过 EventChannel sink 推给 Dart if (parsed.type === 'message') { this.eventSink?.success({ kind: 'message', channel: parsed.channel, payload: parsed.message, }); } // presence、signal 同理 } }

频道订阅映射是这个模块里比较重要的设计。pubnub 支持一个连接上订阅多个 channel,用户可能在运行中动态增加或减少订阅。鸿蒙侧要维护一张表:Map<string, boolean>,每次收到下行帧,先判断消息属于哪个 channel,再决定是否分发给 Dart。这个过滤逻辑放在原生侧而不是 Dart 侧,可以有效减少跨通道通信量——不是所有频道的事件业务方都关心。

我们用 getSubscribedChannels 这类方法从 Dart 侧实时同步订阅列表,配合增量变更指令,确保连接层的订阅状态和业务侧的预期一致。

4.2 心跳保活与断线重连策略

现实网络环境里,一条长链接不会永远稳定。WiFi 切换、地铁隧道、系统省电策略,都会让连接悄悄断掉。pubnub 自身有应用层心跳机制,但鸿蒙作为主机平台,还得多加一道保险。

我们的做法是两层心跳:

  • 应用层心跳:鸿蒙侧每 30 秒向 pubnub 发一个空协议帧(或一个 ping 帧),确认连接可用。
  • 系统层网络检测:监听鸿蒙的系统网络状态,发现网络断开立即标记连接为“可能失效”,而不是等 TCP 超时。

断线重连我们采用了“指数退避 + 随机抖动”策略:第一次断线后立即重连,失败后等 1 秒、2 秒、4 秒……封顶 30 秒,每次加上一个 0~500ms 的随机抖动。为什么要抖动?因为如果大量客户端同时断线,同一秒内一起重连,服务端会被连接风暴打爆。抖动可以把重连请求打散。

重连还有一个重要细节:重连成功后,鸿蒙侧要重新发送 subscribe 指令,把当前所有活跃频道重新订阅一遍,否则连接虽然在,但服务端已经忘了你要收哪些频道的消息。这个坑我们踩过一次,现象是“重连成功了但消息不再来了”,后来在重连流程里补上了完整的频道重订阅,问题才消失。

4.3 前后台切换与网络切换的联动处理

鸿蒙原生侧接管连接管理之后,App 前后台切换是关键场景。Flutter 引擎在 App 进入后台后可能进入挂起状态,Dart 侧代码不跑,但鸿蒙原生侧的事件分发并没有停。这时如果 pubnub 把消息推下来,原生侧如果直接往 EventChannel sink 里塞,消息会堆积在通道缓冲区,等工作进程恢复后再一股脑冲进 Dart 侧——轻则卡顿,重则因为消息量太大把 Flutter 引擎打崩。

我们在鸿蒙侧做了一个简单的“积压-补投”机制:

  • App 切后台时,原生侧继续接收 pubnub 下行消息,但打上时间戳,暂存到内存队列。
  • App 切回前台时,先把积压消息批量发给 Dart 侧,再继续走实时通道。
  • 对超时过久(比如超过 5 分钟)的消息直接丢弃,避免业务侧收到一堆过期状态。

这样即使用户离开页面很久,回来时也能保证“从断点继续”,而不是“从断线那一刻补所有历史”——后者在聊天类场景里会导致重复消息。

5. 踩坑实录:从 EventChannel 丢事件到连接句柄泄漏

5.1 EventChannel 在鸿蒙侧的 sink 生命周期坑

这是我们踩得最深的一个坑。上线灰度第二天,有用户反馈“消息时有时无,重进页面后概率性收不到消息”。一开始以为是网络问题,后来我们发现不是——新增订阅之后,只有第一两条消息能到 Dart 侧,后面就断了。

排查链路是这样的:先在鸿蒙侧给 dispatchEvent 加日志,发现消息明明收到了,sink 也非空;再去 Dart 侧监听日志,发现根本没触发。问题锁定在 EventChannel 的 sink 没有持续持有。

原因说穿了很简单:我们在插件里用setStreamHandler注册 EventChannel 时,onListen 回调里把 sink 存到一个成员变量,但页面退出时 Flutter 侧会触发 onCancel,把 sink 置空。如果业务方重新进入页面时,之前的 StreamSubscription 没有正确释放、新的订阅又没走到 onListen,就会出现“sink 已被置空,但事件还在分发”的状态。换句话说,鸿蒙侧以为有消费者,实际上消费者已经跑路了。

解决方式有两个:一是每个页面建立独立的 EventChannel 实例,避免共享 sink;二是在插件侧做引用计数,只有当所有订阅者都取消时才置空 sink。我们选了后者,因为 pubnub 是全局单例服务,不应该让某个页面的生命周期影响整个连接的事件分发。

5.2 线程序号与主线程阻塞:偶现的 ANR 类问题

第二个坑和性能有关。鸿蒙的 WebSocket 回调如果直接在回调线程里做大量 JSON 解析、结构化、再调用 EventChannel sink,短时间内高并发消息时,会把“处理线程”顶满。我们刚开始没有做任何线程收敛,结果压测时出现偶发的主线程卡顿——Dart 侧表现为页面掉帧,鸿蒙侧表现为系统弹“应用无响应”。

排查后定位到问题:我们在 ArkTS 里把JSON.parse和eventSink.success全放在了同一个 TaskDispatcher 串行队列里,一旦某条消息处理慢(比如 payload 特别大、或者一次带了 500 条历史消息),后面的队列全部堵住。

反过来的教训是:不要盲目地把所有事都丢到后台线程。我们把事件分发拆成了两级:

  • WebSocket 回调只做“原始字节接收”,直接放入队列。
  • 队列由独立 TaskDispatcher 消费,完成 JSON 解析和事件分类。
  • EventChannel sink 调用放在另一个高优队列,避免和 UI 主线程互相挤占。

这样改完之后,同样压力下主线程卡顿消失。如果你在鸿蒙适配中遇到类似问题,建议先看看是不是所有逻辑都挤在同一个线程队列里。

5.3 系统网络回调与重连风暴的平衡

第三个坑在网络切换场景。鸿蒙在 WiFi 和蜂窝网络切换时,会先后抛出一系列“网络断开/网络可用”的系统回调。我们的第一版实现是:每个回调都当作断线处理,立刻触发重连。结果一次切换 WiFi 到 5G,触发了四次重连,pubnub 服务端直接把我们的连接踢出去——因为短时间内同一个客户端反复建立连接,会被判为异常行为。

解决思路:把系统回调做一个短时间窗口的“去抖”(debounce)。网络状态变化后,等 3 秒,如果状态稳定了再决定是否重连;同时把“重连决策权”统一收归到原生侧,Dart 侧传来的重连信号一律忽略。这个改动让网络切换期间的重连次数从平均 4 次降到了 1 次,连接稳定性反而更高。

6. 从 pubnub 扩散开去:Flutter 鸿蒙化插件的通用打法

6.1 常规步骤:梳理依赖、拆解平台通道、逐能力替换

做完 pubnub,再回头看团队里其他 Flutter 三方库,鸿蒙化的路径其实是可以模式化的:

  1. 判断 Dart 层是否依赖原生:纯 Dart 包(比如只是用了 http、crypto 这些跨平台能力)一般直接在鸿蒙 Flutter 分支上就能跑。先把这类包从适配清单里划掉,能省大量时间。
  2. 识别插件里的原生能力点:把插件源码里if (Platform.isAndroid)、MethodChannel调用等标记全部列出来,逐个对照鸿蒙有没有对应 API。
  3. 按“MethodChannel 下上游、EventChannel 上推送”的思路设计替代实现:Dart 侧发起动作的走方法调用,原生侧主动推送事件的走事件流,这条边界是最稳的。
  4. 用 dependency_overrides 本地联调:不要一上来就想着发包,先本地指向 fork 出来的代码,在真机上反复跑通。
  5. 回归验证时重点测试系统级场景:后台切前台、网络切换、低电量、进程被杀后的恢复,这些场景在普通功能测试里极难被发现,但恰恰是鸿蒙化最容易翻车的地方。

我们在做其他插件适配时也发现,很多原生服务在鸿蒙上的 API 命名和 Android 非常相似,但不能依赖这种“相似性”直接抄代码。鸿蒙有自己的一套生命周期管理(比如 UIAbility 与 ExtensionAbility 的职责边界)和后台任务限制,这直接决定了长链接该怎么挂载——挂错地方,应用到了后台就被系统掐掉。

6.2 什么情况需要看 Flutter 引擎分支的源码

绝大多数插件鸿蒙化不需要碰引擎层,实时通信类更是如此。但有一个例外:如果你用了和渲染、手势、平台视图强相关的能力,比如 Flutter 的 PlatformView 嵌入原生地图、或者依赖 Impeller 渲染的特殊视觉效果,那么鸿蒙化难度会陡增。Flutter 的渲染引擎在鸿蒙分支上跑的是不同的宿主集成层,Impeller 的适配程度直接影响帧率和某些绘制 API 的可用性。

pubnub 这种纯网络型插件基本碰不到渲染层,所以我们全程没有改 Flutter 引擎分支。如果你的插件有非常规的原生视图嵌入,我建议先做一个最小 Demo 验证 PlatformView 在鸿蒙侧的可用性,再决定是走官方分支还是自己 fork 引擎修。

6.3 兼容性收尾:条件导出与模拟平台

最后分享一个我们在收尾阶段用的小技巧:在 Dart 侧加一个编译开关,让 pubnub 在“模拟器/测试环境”下直接走一套本地 Mock 实现,而不是真的连鸿蒙原生代码。这样做的价值在于:没有鸿蒙真机的开发同学也能跑通 UI 流程,CI 环境里做单元测试也不用依赖真机长链接。

const bool kUseOhosNative = bool.fromEnvironment('USE_OHOS_NATIVE', defaultValue: true); class PubnubConnectionFactory { static PubnubConnection create() { if (kUseOhosNative) { return PubnubOhosConnection(); } return PubnubMockConnection(); } }

这个开关成本极低,但能让团队里的前端、测试、QA 全部解除设备依赖。真实项目里,我把这个技巧也用在了另外两个插件的鸿蒙适配里,效果都不错。

这次做 pubnub 鸿蒙化,最大的体会是:跨平台框架的“跨”是有明确边界的。Flutter 帮你跨了 UI 和部分业务逻辑,但长链接的保活、系统网络感知这类能力,永远要回到宿主平台去解决。把这条边界理清楚,鸿蒙化就不是什么玄学,而是一套可以复制的方法论。如果你也在做类似的事,欢迎按这个流程试一遍,大概率能少走我走过的弯路。

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

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

立即咨询