
前阵子接了个鸿蒙端的实时通信需求项目里用的是 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生命周期里注册了 MethodChannelimport { 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: Recordstring, string): Promisevoid { 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 StreamControllerString.broadcast(); NativeWebSocketTransport() { EventChannel(async_cable/events).receiveBroadcastStream().listen((event) { _controller.add(event.toString()); }); } override StreamString 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 上。这样做之后一个长连接就能同时订阅聊天、通知、在线状态等多个频道业务层各收各的互不干扰。消息路由的实现很简单本质上就是一个MapString, 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把连接、订阅、收发、断开重连、批量消息全部跑通一遍再接真实业务排查成本会低很多。