ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

鸿蒙端Flutter适配ActionCable:WebSocket底层替换与工程实践

鸿蒙端Flutter适配ActionCable:WebSocket底层替换与工程实践

前阵子接了个鸿蒙端的实时通信需求,项目里用的是 Rails 的 ActionCable 做推送,Flutter 侧通过 async_cable 这个三方库来维持长连接、订阅频道、收消息。起初想着 Flutter 代码跨端直接跑,鸿蒙上应该问题不大,结果一测才发现完全不是这么回事:鸿蒙的 Flutter 运行时对 socket、平台通道的处理和 Android/iOS 有差异,async_cable 底层直接依赖的 Dart WebSocket 在鸿蒙上的原生实现走的是另一套网络栈,动不动就连接失败、消息回调延迟,甚至偶发崩溃。

折腾了差不多两个星期,把 async_cable 从 Flutter 标准实现一路拆到鸿蒙原生 WebSocket 接口,才把整条链路跑通。这篇文章把整个适配过程、踩过的坑和最终的工程结构都整理出来,给同样需要在鸿蒙端接 ActionCable 的小伙伴一个可以直接参考的路线。适合已经有 Flutter 基础、了解 WebSocket 基本概念、需要把实时通信能力落到鸿蒙设备上的客户端开发者。

1. 为什么要把 async_cable 搬到鸿蒙生态

1.1 ActionCable 协议到底在做什么

ActionCable 是服务端 WebSocket 协议的一种封装,它在标准 WebSocket 之上定义了一套 JSON 消息格式。客户端连接建立后,服务端会先发送一个welcome消息,紧接着通过ping消息维持心跳。客户端要订阅某个频道时,发送command: "subscribe"的消息,带上identifier标识;服务端推送业务数据时,消息体里会包含identifier和message两个关键字段。

这套协议本身并不复杂,但坑在于实现细节。服务端ping的间隔、订阅标识的编码方式、连接断开后的重连策略,稍有偏差就会导致连接假死或者消息丢失。async_cable 这个三方库做的就是把这套协议封装成 Dart API,对外暴露connect、subscribe、perform等方法,让 Flutter 应用可以像调用普通异步方法一样使用 ActionCable。

注意:ActionCable 协议里的identifier字段必须是 JSON 字符串,比如{"channel":"ChatChannel","room":12}会被序列化成"{\"channel\":\"ChatChannel\",\"room\":12}"放在消息里,这一点在后面适配鸿蒙原生通道时特别容易搞错。

1.2 鸿蒙侧的真实兼容性痛点

理论上,Flutter 应用在鸿蒙上运行的代码逻辑和 Android/iOS 一致,async_cable 也能直接编译。但实际操作中会发现三个层面的问题:

第一,鸿蒙的 Flutter 引擎对 Dart VM 的 socket 实现做了定制,标准dart:io的WebSocket.connect走的是鸿蒙自己的网络协议栈,和 Android 上基于 Java NIO 的实现行为不同。最明显的表现是连接建立慢、偶尔超时,以及服务端主动关闭连接时客户端感知的时机有延迟。

第二,鸿蒙对 TLS 证书校验的策略比 Android 默认策略更严格。如果 ActionCable 服务端用的是自签名证书或者内部 CA,直接用wss://连接会直接被拒,除非在鸿蒙原生层配置信任证书,但 Dart 层没有开箱即用的接口。

第三,鸿蒙原生提供了一套@ohos.net.webSocket的 WebSocket API,它和 Dart WebSocket 的行为不完全一致。如果想自己控制底层的断线重连、心跳、粘包处理,就得走鸿蒙的 API 来实现,这等于把 async_cable 的“网络传输层”从dart:io换成鸿蒙原生能力,再通过方法通道把数据送回 Dart 层。

我的策略是:保留 async_cable 作为协议解析和业务封装层,把底层 WebSocket 传输替换成鸿蒙原生实现,中间用 MethodChannel 做消息透传。这样可以最大化复用 async_cable 已经处理好的订阅、序列化、消息分发逻辑,只替换它最底部的连接通道。

2. 适配前先摸清三件事

2.1 依赖树里最需要留意的两层

async_cable 的依赖并不复杂,核心是dart:io里的 WebSocket 实现,外加json_annotation和stream_channel。但它的 API 设计决定了你没法直接注入一个自定义的 WebSocket 客户端。看源码会发现,它的Cable类内部直接调用了WebSocket.connect,没有提供 pluggable 的 transport 接口。

所以适配前必须做一道选择题:要么 fork async_cable 改源码,把传输层抽象出来;要么放弃 async_cable 的连接管理,只借用它的协议解析能力。我当时选了前者,因为 fork 出来改动量不大,而且能把重连、心跳这些逻辑留在库内部,业务侧不需要改。

具体改动点在两个文件:一个是负责建立连接的 core 文件,另一个是负责收发消息的 stream 处理模块。前者需要把WebSocket.connect替换成从 MethodChannel 获取的“原生连接对象”,后者需要调整消息进入 Dart 层的方式。

实操心得:别急着改代码,先把 async_cable 的测试用例跑一遍。这个库自带了一套比较完整的模拟服务端测试,能帮你确认改动传输层后协议解析逻辑有没有被破坏。我就是在改完连接层之后跑测试,才发现订阅标识的编码方式在传输层替换时被重复序列化了一次。

2.2 鸿蒙 WebSocket 能力的边界

鸿蒙原生 WebSocket API 的基本流程很简单:创建WebSocket实例,调用connect方法建立连接,通过onMessage回调接收数据,调用send方法发送消息。

鸿蒙的 WebSocket API 有一个特点:它默认处理了 WebSocket 的帧解析,但不会帮你做业务层的 JSON 编解码。这意味着你可以在原生层直接拿到字符串消息,然后原样传给 Dart 层;反过来,Dart 层发送的字符串也会原样发到服务端。这正好符合 ActionCable 协议的纯文本 JSON 传输方式,不需要做二进制帧的特殊处理。

另一个要注意的点是,鸿蒙 WebSocket 的连接参数里有一个header字段,可以自定义 HTTP 头。ActionCable 通常需要携带Origin、Cookie或者自定义的Authorization头来做鉴权。这些在 Dart 层的WebSocket.connect里也能设置,但在鸿蒙原生层设置更可靠,因为某些版本的 Flutter 引擎不会完整传递自定义头。

还需要确认一下:鸿蒙 WebSocket 是否支持每帧设置ping/pong。ActionCable 依赖服务端的ping消息来维持连接,如果服务端的 ping 间隔较大,且中间没有业务消息,可能会被中间的网络设备判定为空闲连接而断开。鸿蒙原生 WebSocket 提供了主动发送 ping 的接口,我建议在鸿蒙层做一个定时器,每 15 秒主动发一次 ping,保持连接活跃。这个策略在文档里一般不会写,但实战中非常关键。

2.3 一条最小化改动的可行路线

我的最终方案可以拆成四层:

层级职责改动方式
原生鸿蒙层真正的 WebSocket 连接、心跳、收发用 ArkTS 实现一份 WebSocketService
平台通道层连接管理、消息透传、事件回调MethodChannel + EventChannel
传输适配层将 platform channel 包装成 async_cable 需要的 transportfork async_cable 源码,新增 WebSocketTransport 实现
业务层订阅频道、处理消息、类型解析完全不需要改

这个分层的好处是每一层的职责很清晰。原生层只管网络连接,平台通道层只做数据搬运,传输适配层负责把鸿蒙的异步事件转成 Dart 的Stream,业务层完全感受不到底层替换。

实际开发中我把协议解析和业务 API 这部分改动控制在了最小范围,只改了 async_cable 源码里和连接建立、消息读写相关的几个文件。整体代码量大概新增原生层 500 行、修改传输层 300 行,比一开始预想的要少得多。

3. 鸿蒙化适配的核心步骤实操

3.1 工程接入和平台通道注册

鸿蒙 Flutter 工程的目录结构和标准 Flutter 工程不一样,原生代码放在entry/src/main/ets/下,需要通过plugin的方式注册方法通道。我在EntryAbility的onCreate生命周期里注册了 MethodChannel:

import { MethodChannel } from '@kit.BasicServicesKit'; import { webSocket } from '@kit.NetworkKit'; const CHANNEL_NAME = 'async_cable/websocket'; export default class EntryAbility extends Ability { onCreate(want, launchParam) { const channel = new MethodChannel(CHANNEL_NAME); channel.setMethodHandler((methodName, args) => { return this.handleNativeMethod(methodName, args); }); } }

这里有几个容易踩的坑:

第一,MethodChannel 的 handler 必须返回Promise,因为原生层的 WebSocket 连接是异步的,如果你同步返回结果,Dart 层会收到null或者直接抛异常。

第二,一个 MethodChannel 最好是单例复用。整个生命周期里只会有一个async_cable实例在跑,所以不需要每个连接都注册新的 channel,否则会造成 handler 冲突。

第三,EventChannel 需要单独注册,用来把原生层收到服务端消息主动推送回 Dart。这个和 MethodChannel 不一样,它是一个持续的单向数据流通道。

3.2 底层 WebSocket 通道替换实现

在鸿蒙原生层写一个WebSocketService,内部维护一个webSocket.WebSocket实例:

import { webSocket } from '@kit.NetworkKit'; export class WebSocketService { private ws: webSocket.WebSocket | null = null; private active: boolean = false; connect(url: string, headers: Record<string, string>): Promise<void> { if (this.ws) { this.ws.close(); } this.ws = webSocket.createWebSocket(); return this.ws.connect(url, { header: headers }).then(() => { this.active = true; this.setupListeners(); }); } private setupListeners(): void { this.ws.on('open', () => { this.eventChannel.emit('connected'); }); this.ws.on('message', (data) => { this.eventChannel.emit('message', data); }); this.ws.on('close', () => { this.active = false; this.eventChannel.emit('disconnected'); }); this.ws.on('error', (err) => { this.eventChannel.emit('error', err); }); } send(data: string): void { if (this.ws && this.active) { this.ws.send(data); } } desireHeartbeat(intervalMs: number): void { setInterval(() => { if (this.active) { // 发送 WebSocket ping 帧,防止中间网络设备断开空闲连接 this.ws.ping(); } }, intervalMs); } }

核心逻辑在这片setupListeners里:open、message、close、error四个事件,全部通过 EventChannel 透传给 Dart 层。

这里有个很重要的细节:鸿蒙的message事件返回的数据类型可能是string也可能是ArrayBuffer。如果是二进制帧,在WebSocketService这一层做类型判断,统一转成字符串再向上传,避免 Dart 层处理二进制数据时出现编解码问题。

连接关闭时,需要在close事件里把this.ws = null清掉,同时停止心跳定时器。我一度漏了这一步,导致重连时原连接占用的资源没释放,新连接建立后出现双连接并发收消息的情况。

3.3 订阅标识与消息分发改造

async_cable 连接建立之后,所有订阅请求都是通过同一个 WebSocket 连接发送的。在鸿蒙这个场景下,如果只是把传输层换成原生 channel,那订阅和消息分发逻辑可以原封不动地在 Dart 层跑。

但有一个地方必须改:subscribe方法里的 identifier 解析。async_cable 发送订阅请求时会把这个字符串直接放到 WebSocket 消息里,而 ActionCable 服务端要求 identifier 必须是合法的 JSON 字符串。在标准 Dart WebSocket 环境下不会出问题,但鸿蒙原生层对 WebSocket 发送的数据要求比较严格,如果传进去的是一个非 JSON 格式的字符串,连接虽然不会断,但服务端会忽略这条订阅消息。

我的处理方式:在 Dart 层判断 identifier 是否已经是 JSON 字符串,如果不是就先JSON.encode再发送。这个改动只在传输层做了,业务侧传入的还是原来的 channel 名称和参数。

消息分发上,EventChannel 把原生收到的消息发给 Dart 层后,要转成 async_cable 内部能识别的Stream数据流。async_cable 原本是直接监听 WebSocket 的Stream,现在改成一个StreamController,把方法通道的每次回调add进去:

class NativeWebSocketTransport implements WebSocketChannel { final _controller = StreamController<String>.broadcast(); NativeWebSocketTransport() { EventChannel('async_cable/events').receiveBroadcastStream().listen((event) { _controller.add(event.toString()); }); } @override Stream<String> get stream => _controller.stream; @override void send(data) { MethodChannel('async_cable/websocket').invokeMethod('send', data); } }

这个方法的关键在于,EventChannel 的回调线程和 Dart 的Stream监听线程默认都是主线程。如果消息量大,Dart 层会出现消息堆积。最好在listen里指定cancelOnError为 false,同时在业务层做好消息分发到 isolate 的处理。

3.4 心跳、重连和连接状态机

async_cable 原本的重连逻辑比较简单:连接断开后,按固定间隔重试。但在鸿蒙实网环境下,这种粗暴的重连策略会导致两个问题:一是频繁重连打爆服务端,二是连接断开和重连之间的状态没有被业务层感知到。

我的做法是维护一个五态状态机:DISCONNECTED、CONNECTING、CONNECTED、RECONNECTING、CLOSED。每次状态变化都通过回调通知业务层。重连间隔用指数退避,初始 1 秒,最大 30 秒,每次重连间隔翻倍,连续成功连接后重置。

enum CableConnectionState { disconnected, connecting, connected, reconnecting, closed, }

心跳方面,除了鸿蒙原生层的 ping 帧,我还在 Dart 层维护了一个看门狗:每 30 秒检查一次最近收到服务端消息的时间,如果超时 40 秒没有收到任何消息,主动断开当前连接并进入重连流程。

这个看门狗逻辑是有必要的,因为某些代理服务只转发数据不转发 ping/pong 帧,原生层的 ping 能保证 WebSocket 连接不空闲,但不能保证服务端响应。看门狗实际上是在验证业务层的可见消息流,比原生层的心跳更可靠。

4. 实测中容易踩的坑与排查思路

4.1 连接建立后立刻被断开

第一个遇到的问题是连接成功后,不到 5 秒就被服务端断开。排查时先看鸿蒙原生层的close回调,非要错误码是 1006。原因不是鸿蒙的问题,而是 ActionCable 在握手阶段需要一定时间完成鉴权,如果客户端没有及时发送订阅请求,服务端会认为客户端不活跃而主动断开。

解决方式有两个:一是在连接建立后立即发送一个空的订阅请求来“占位”;二是在原生层把connect调用后的确认消息延迟到 Dart 层收到welcome之后再触发。我用了第二个方案,因为第一个方案会导致服务端多发一条无意义的广播消息。

排查这个问题的过程中也验证了 EventChannel 的数据传递机制。鸿蒙原生层必须在connect的 Promise resolve 之后再往 EventChannel 发送数据,否则 Dart 层可能还没注册好监听,消息就丢了。

4.2 大量消息涌入导致的乱序与阻塞

ActionCable 的实时同步场景下,服务端经常会在短时间内推送大量消息。在鸿蒙原生层,message事件是一个接一个触发的,如果直接在回调里向 Dart 层发消息,Dart 主线程会被消息回调占满,导致 UI 卡顿。

我在原生层加了一个简单的消息队列,把收到的消息放入队列,然后批量发送给 Dart 层。这里用到了 ArkTS 的异步机制:当队列长度累计超过 50 条或者距离上次发送超过 100 毫秒时,触发一次批量发送。

还有一个容易忽略的坑:EventChannel 的receiveBroadcastStream默认不保证消息顺序。虽然在实际测试中消息顺序通常是稳定的,但如果你在个别鸿蒙版本上跑得不多,不排除有顺序异常的可能性。保险起见,我在消息体里加了一个自增序号,在 Dart 层解析时校验,如果发现乱序就缓存并排序。

4.3 多实例连接与资源释放不彻底

开发时遇到一个隐蔽的问题:App 退到后台再回到前台,原来的 WebSocket 连接没有断,但新的连接又建立了,导致两个连接同时存活,服务端广播消息被重复接收。

原因是在鸿蒙原生的生命周期处理上,后台时应用被挂起,EventChannel 的事件流没有主动关闭,Dart 层也不知道连接已经失效。重新创建连接时,旧的WebSocketService实例没有被销毁。

解决方法是把 WebSocketService 改成单例,并在onStop生命周期里主动调用disconnect方法,同时把 EventChannel 的事件流关闭,让 Dart 层收到disconnected状态后自动清理旧连接。

注意:不要依赖系统帮你释放 WebSocket 资源。在鸿蒙上,WebSocket 连接需要手动 close,而且 close 之后还需要等待close事件回调确认,才能认为资源真正释放完毕。中途退出会把连接残留,下次建立新连接时旧连接还可能占用底层网络资源。

5. 适配完成之后还能怎么继续扩展

5.1 从单一频道到多频道订阅管理

async_cable 原始 API 支持多频道订阅,但默认的实现是一个连接上按顺序处理所有频道的消息,服务端推送消息时通过identifier字段来区分不同频道。鸿蒙适配完成后,我在 Dart 层加了一个消息路由器,解析消息中的identifier,按频道名派发到不同的 Stream 上。

这样做之后,一个长连接就能同时订阅聊天、通知、在线状态等多个频道,业务层各收各的,互不干扰。消息路由的实现很简单,本质上就是一个Map<String, StreamController>的哈希表,收到消息后按 key 分发。

多频道订阅要特别注意频道参数的编码一致性。如果订阅时传的identifier是{"channel":"RoomChannel","id":12},服务端返回消息里的 identifier 字段也必须是同样的字符串,否则路由匹配失败。

5.2 与本地状态缓存联动

实时通信的数据到达客户端后,通常会触发 UI 更新。但如果用户处于弱网环境,连接反复断开重连,中间的消息可能会丢失。我建议把收到的消息先写入本地缓存,再通知 UI,这样即使断线重连后,也能保证界面上展示的是一个持续一致的状态。

实现上,我借助了鸿蒙的PreferencesAPI 做轻量级键值存储,以 channel 名加消息 id 作为 key,把最新消息存下来。重连成功后先拉取一次全量数据,再恢复增量消息,就能有效避免断线期间的数据空洞。

这个扩展方向在 ActionCable 场景下尤其有价值,因为 ActionCable 服务端默认没有消息重放机制,客户端断线期间的消息基本上是拿不回来的,本地缓存成了唯一的补救手段。

5.3 性能观测和线上问题定位

适配完成后我才发现,如果没有可观测性,线上出了问题只能靠用户反馈,排查成本很高。我在鸿蒙原生层加了一套简单的日志系统,记录连接建立、断开、重连、心跳发送、消息收发次数和耗时等关键指标,输出到本地日志文件。

同时,在 Dart 层暴露了一个诊断接口,可以查询当前连接状态、最近重连次数、消息积压情况。线上问题来的时候,可以先看诊断接口的输出,再决定是否需要拉取日志文件。

日志的实现不复杂,关键是埋点要选对位置。重点观察三个点:连接建立耗时、心跳响应耗时、消息从原生层到 Dart 层的传递延迟。这三个指标基本能还原一条实时链路的所有常见故障。


把 async_cable 适配到鸿蒙的整个过程,表面上是在改 WebSocket 通道,实际上是对协议适配、平台差异、消息可靠性做了一次全面梳理。我在实际测试中发现,很多坑并不是 async_cable 的 bug,而是鸿蒙对 WebSocket 的生命周期管理和事件回调机制与 Android/iOS 不同,适配的关键是理解这套差异并迁移到鸿蒙原生的实现方式上。最后再分享一个小技巧:适配完成后不要急着塞进老业务,先单独做一个纯鸿蒙的 Demo,把连接、订阅、收发、断开重连、批量消息全部跑通一遍再接真实业务,排查成本会低很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进