on(‘open’) 还没回调就 send:WebSocket 的连接状态机、心跳与重连闸门

前言

在鸿蒙上把 WebSocket 跑通,往往只要二十分钟;把它跑稳,常常要花掉两个迭代。

原因不在协议,而在 API 的形态。@ohos.net.webSocket 把「一条长连接」拆成了「一个对象 + 四个事件 + 三个动作」:createWebSocket() 返回一个 WebSocket 对象,connect() 发起建链,send() 发数据,close() 断链;进度与结果则全部通过 on('open')、on('message')、on('close')、on('error') 四个事件回调回来。这套设计很贴合底层实现,但它有一个容易被忽略的前提:对象本身不携带状态,事件才携带状态。

于是就有了最常见的错位:开发者习惯用「对象是否存在」来判断「连接是否可用」。变量 ws 不为空,就认为「我连着」。可 createWebSocket() 的返回值里没有任何网络连接,它只是一张可以发起连接的凭据;connect() 的 boolean 回调也明确只表示「连接请求创建是否成功」,要感知真正的连接成功,必须在 connect() 之前先订阅 on('open')。

再往下,还有一层更隐蔽的成本。WebSocket 是长生命周期对象:实例活着,心跳就一直在跑,事件就一直在回。它和 @ohos.net.http 的 request() 完全是两种生命周期模型——后者一次请求一次响应,拿到结果对象就可以丢;前者则需要一个明确的所有者,在正确的时机被显式销毁。

这篇文章围绕一条真实可复现的排查链展开:从「调了 send 服务端没收到」,一路挖到「重连风暴把服务端连接池打满」,最后落在一个可复用的 WsClient 封装上——状态机管住建链动作的顺序,心跳交给框架的 Ping 帧,重连用单飞加退避,销毁时把订阅和定时器一起收干净。

问题描述

最典型的一组线上日志长这样:用户在地铁里切了几次网络,回到工位后应用还能收到推送,但列表不再自动刷新;杀掉进程重进,一切正常。日志里能看到 open 与 close 交替出现,间隔固定在一两秒,像节拍器。

把这条线索拆开,其实是四个独立缺陷叠在一起:

第一,页面在 aboutToAppear 里 createWebSocket() 后立刻 connect(),紧接着把一份缓存消息 send() 出去。connect 的 boolean 回调返回了 true,日志写着「连接成功」,但服务端一条都没收到。真实情况是:true 只代表建链请求已经创建,握手还在路上,此时 send 的数据没有可用的链路承载。

第二,断网恢复时,on('close') 与 on('error') 在两条路径上各触发了一次重连。两个 connect() 打在同一个实例上,其中一次返回 2302003(Websocket connection already exists);更麻烦的是,两次重连各自又派生了自己的重试定时器,于是「一个断网事件」变成了「两条互相追赶的重连链」。

第三,重试没有任何退避,固定 1 秒一次。服务端对单个客户端有连接数上限(maxConnectionsForOneClient,默认 10),新连接把旧连接挤掉,旧连接又触发 close 再发起重连——这就是「连上又断」的循环。

第四,页面销毁时只把 ws 变量置空,没有 close() 也没有 off()。回调闭包持有页面 this,实例在后台继续按默认节奏发心跳,既耗电又阻止页面对象被回收。

把这类问题的表面现象与根因归拢一下:

现象直接观察根因层次
调了 send,boolean 为 true,服务端收不到发送回调没报错没有等 on('open'),在 connecting 阶段发送
息屏/切网回来「看起来连着」但收不到推送变量非空,无 close 日志把「实例存在」当成「链路可用」,缺少主动判活
一次断网触发多次重连connect 出现两三次,伴随 2302003没有单飞,error 与 close 双路径各排一次
连上又断,循环往复服务端日志同 client 反复建链/踢链无退避 + 服务端连接数上限
on('message') 拿到的不是字符串类型判断缺失,JSON.parse 抛异常二进制帧按文本解析

分支里还有两个值得单独说的:其一是 on('close') 与 on('error') 的到达顺序不稳定,有时 error 先到、有时 close 先到,有时只到其中一个;其二是握手失败时 on('error') 里拿到的错误码与 HTTP 语义相关——WebSocket 本质是 HTTP 协议升级,服务端同意升级会返回 101 并触发 open,拒绝升级或其他异常则返回 200,表示服务器把请求当作普通 HTTP 请求处理,这条链路不会进入 open 状态。

细节解析

一、实例不等于连接:先把语义钉死

三个动作的返回值语义必须分清,这是后面所有设计的基石:

  • createWebSocket() 返回 WebSocket 对象,对象内含 connect、send、close、on、off,此刻没有任何网络连接。
  • connect(url, options?, callback?) 的 boolean 只表示「连接请求是否创建成功」,不代表握手完成。要感知连接成功,必须在调用前订阅 on('open')。
  • send(data) 的 boolean 只表示「发送请求是否创建成功」,同样不代表对端已收到;而 send 的调用前提是已经监听到 open 事件。

把这三句连起来,会得到一个很硬的结论:「可以发送」这件事,不能由任何一次函数返回值的 true 来担保,只能由 open 事件来担保。 而 open 是一个事件,事件是异步到达的,异步到达就意味着存在一个时间窗口——在窗口内,任何「先把消息发出去,断了再说」的做法都是在赌。

这个窗口期正是状态机存在的理由。没有显式状态,代码就只能靠变量是否为空、回调是否到过来间接推断,而推断在并发场景下必然出错。

二、五个状态与一张迁移表

一个够用的连接状态集合只有五个:idle(未建链)、connecting(已发起、未 open)、open(可用)、closing(已发起关闭)、closed(已终止)。状态只由事件驱动迁移,不由业务代码随意赋值。

当前状态触发迁移到必做动作
idle调用 connect()connecting先订阅四个事件再建链;递增世代号
connectingon('open')open重试计数清零,冲刷待发队列
connectingconnect 回调 errclosed记录错误码,进入退避排队
connectingon('close')closed同上;close 是唯一的重连入口
open调用 close()closing置主动关闭标志,取消全部定时器
openon('close')(含心跳超时断开)closed非主动关闭则排重连
closingon('close')closed不再重连,回收实例
closed退避定时器到期connecting通过单飞校验后才允许建链
closed重试次数超上限closed上报放弃信号,等外部显式触发

这张表最有价值的一列是最后一列,因为它把「顺手写一行」变成了「必须按顺序做」。

易错点:connecting 状态下再次调用 connect() 是无效动作。connect 的错误码里包含 2302003(Websocket connection already exists,适用版本 12+),它描述的就是「连接已存在」这一类冲突。把重复建链当成兜底,等于主动制造 2302003 和随之而来的连接互相挤占。

还有一个容易漏的细节:旧实例的回调会晚到。已经 close 掉的实例,它的 on('close') 仍可能在新连接建立之后才被投递,如果回调里无条件写状态,就会把新连接刚设好的 open 覆盖成 closed。解决办法是世代号——每次建链递增一个整数,回调里先比对这个整数,不匹配就直接返回,不产生任何副作用。

三、心跳:协议层 Ping 帧已经在框架里

这是很多人绕远路的地方:为了「保活」,在应用层自己定时发一条 {"type":"ping"},服务端回 {"type":"pong"},再写一套超时判定。这套东西可以做,但要先知道框架已经提供了什么。

WebSocketRequestOptions 里有两个字段专管心跳:

  • pingInterval:自定义心跳检测时间,默认 30s,最大值 30000s,最小值 0s,每 pingInterval 周期发起一次心跳检测,设置为 0 表示关闭心跳检测。API 21 起可用。
  • pongTimeout:自定义发起心跳检测后的超时断开时间,默认 30s,最大值 30000s,最小值 0s;发起心跳检测后若 pongTimeout 时间内未响应则断开连接;pongTimeout 须小于等于 pingInterval。API 21 起可用。

工作机制在开发指南里写得很清楚:连接建立之后,从「连接建立」或「客户端收到 Pong 帧」开始计时,每间隔 pingInterval 秒客户端发送一个 Ping 帧;服务端若支持 WebSocket 协议,收到 Ping 帧后会自动回复 Pong 帧,表示连接正常;若服务端异常或未回复,则在 pongTimeout 秒内收不到 Pong 就会主动断开连接。

这段描述里有三个可以直接拿来用的推论:

第一,心跳是协议层行为,走的是 WebSocket 控制帧,不需要业务协议配合,也不会和你的业务消息抢语义。

第二,计时起点是「上一次连上或上一次收到 Pong」,也就是说它是一个空闲计时器。只要链路能往返,连接就一直活着,不需要业务流量来续命。

第三,心跳失败的表现形式是主动断开连接,即你的 on('close') 会被调用。这意味着心跳超时不会绕过你的关闭逻辑,它只是给你的关闭逻辑又添了一个入口——如果你的 on('close') 里没有重连,那么心跳超时之后就真的是「静默失败」;如果 on('close') 里有重连但没区分主动/被动,那么主动 close() 之后也会被立刻重连回来。

易错点:pingInterval 与 pongTimeout 的单位是秒,不是毫秒。写成 pingInterval: 30000 表示 30000 秒(约 8.3 小时),在这个值上等「心跳超时」等于永远等不到;同时 pongTimeout 必须小于等于 pingInterval,倒过来填在语义上是矛盾的。

那还要不要应用层心跳?这两个不在一个层面上:协议层 Ping/Pong 判定的是「帧还能不能往返」,应用层心跳判定的是「会话还有没有效」。举个具体场景:网关把连接从后端摘掉了,但 TCP 层还没感知,Ping 帧可能被网关直接回 Pong,链路判定为健康,可业务订阅早已失效,推送不会再来。这类问题协议层心跳看不出来,得靠业务层发一条带会话语义的探活请求,看服务端是否按业务规则应答。两者并存不冲突,各管一段。

四、重连:单飞 + 退避

重连是唯一一个「做错方向会放大伤害」的动作。一次断网是小事,一次断网换来二十次建链就是事故。

单飞(single-flight)是第一道闸门:同一时刻只允许存在一个重连任务。实现上就是一个定时器句柄,值为 -1 表示没有待执行的重连;scheduleReconnect 进来先看这个句柄,非 -1 就直接返回。这样即使 on('close') 和 on('error') 都触发,也只会有一次实际建链。

第二道闸门是退避。固定间隔重试在弱网下会形成正反馈:越连不上越频繁连,越频繁连服务端越忙。指数退避把间隔按 2 的幂拉开,再加一点随机抖动,避免多个客户端在同一秒集中苏醒(惊群)。

第三道闸门是上限。maxRetry 用尽后不再自己重连,而是把控制权交回给业务:上报一个「已放弃」信号,由业务决定是提示用户、降级到轮询,还是等下一次前台激活再试。无限重连会让用户在一个永远连不上的网络里持续耗电。

关于「什么时候该重连」,原则可以收得很紧:on('close') 是唯一的重连入口。on('error') 只做记录和兜底——如果 error 之后 close 迟迟不来(这类情况在真实环境里存在),用一个兜底定时器在若干秒后强制判定连接死亡,再走同一条重连入口。这样两条路径最终汇入一个漏斗,不存在「两边各连一次」的可能。

五、错误码定位表

错误码是排查的坐标系。下面这些是 connect、send、close 以及服务端接口涉及的码,逐个对应处置动作:

错误码错误信息适用版本出现位置处置方向
201Permission denied.—各接口缺 ohos.permission.INTERNET,配置里补权限
401Parameter error.—connect/send/close参数不合法;send 超过 5242864 字节也返回 401
2302001Websocket url error.12+connectURL 非法或超长,属于不可重试错误
2302002Websocket certificate file does not exist.12+connect/startcaPath、clientCert 路径不可访问
2302003Websocket connection already exists.12+connect重复建链,查单飞是否失效
2302998It is not allowed to access this domain.12+connect域名不在允许范围,重试无意义
2302999Websocket other unknown error.10+connect/start未知错误,可退避重试
2302004Can’t listen on the given NIC.—服务端 start监听网卡不可用
2302005Can’t listen on the given Port.—服务端 start监听端口不可用
2302006websocket connection does not exist.—服务端 send/close客户端连接已不存在
2302007Websocket port already occupied.24+服务端 start端口被占用,换端口

有了这张表,重连策略就能分级:2302001、2302002、2302998、401 这类属于「配置或输入错误」,重试一万次也不会成功,应该直接失败并暴露给开发者;2302003 属于「状态冲突」,说明闸门有洞,应该退避而不是立刻重连;2302999 与网络抖动相关,按退避重试。

顺带记一个尺寸事实:send 的单个数据最大 5242864 字节(即 5 * 1024 * 1024 - 16),超过会返回 401。长连接上做大文件传输,必须在应用层分片,而不是指望底层自动切。

六、消息类型:文本帧与二进制帧

on('message') 的 callback 类型是 AsyncCallback<string | ArrayBuffer>:数据可以是字符串(API version 6 起支持)或 ArrayBuffer(API version 8 起支持)。也就是说同一个回调里会来两种类型,而很多封装直接写成 JSON.parse(value)——一旦对端发的是二进制帧,value 就是 ArrayBuffer,解析必然失败,表现是「连接正常、消息丢失」。

正确的做法是在入口处就分流:typeof value === 'string' 走文本通道,否则走二进制通道,两条通道各有自己的业务回调。这样上层拿到的永远是确定类型,不需要再做防御。

另外两个和接收相关的事件值得知道:on('dataEnd')(11+)表示数据接收结束,on('headerReceive')(12+)可拿到服务器返回的 HTTP 响应头。握手阶段如果服务端在响应头里塞了会话标识,headerReceive 是能拿到它的位置。

七、生命周期:和 @ohos.net.http 的根本差别

@ohos.net.http 的心智模型是「一次性交易」:发起 request、拿到响应、连接断开。指南里对这个差别的表述很直接——HTTP 以单向通信为主,客户端发起请求、服务端响应数据,一次传输之后连接会断开;WebSocket 则是在连接上进行全双工通信,连接之后客户端和服务器端可以同时主动发送数据。

这个差别落到工程上,就是三件事:

第一,所有权要明确。 HTTP 请求对象可以在函数内创建、函数结束即弃;WebSocket 实例是一个长期存在的资源,必须有一个明确的所有者。放在页面里,就意味着页面数量等于连接数量;更稳的形态是模块级单例(或在 UIAbility 层持有),页面只订阅通知。

第二,销毁必须成套。 只把变量置空不算销毁。完整的销毁包含四步:取消重连与兜底定时器、把待发队列清空、off() 掉四个事件订阅(off 不传 callback 会清空该事件的全部订阅)、close() 断开链路。少任何一步都会留下残留:定时器会继续跑,回调闭包会继续持有页面 this,链路会继续按周期发心跳。

第三,前后台要区分「暂停」与「销毁」。 页面 aboutToDisappear 对应的语义是「这个页面不再需要这条连接」,因此应当销毁;如果业务希望连接跨页面存活,就把实例提到单例层,页面只在 aboutToDisappear 里取消自己的订阅,而不是销毁连接本身。

易错点:aboutToDisappear 里只写 this.ws = undefined 是最常见的泄漏写法。变量没了,但 on('message') 的闭包还挂在实例上,实例还挂在底层连接上,页面对象被闭包引用着回收不掉。

示例代码

1. 类型与状态定义

把状态、配置、回调契约集中在一个模块里,后续所有代码只依赖这些类型。

import { webSocket } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';

const DOMAIN: number = 0x0000;
const TAG: string = 'WsClient';

/** 连接状态:闸门只认这五个值,任何业务代码不得直接赋值 */
export enum WsState {
  IDLE = 'idle',
  CONNECTING = 'connecting',
  OPEN = 'open',
  CLOSING = 'closing',
  CLOSED = 'closed'
}

/** 对外暴露的四个回调,全部必填,避免上层漏实现导致状态黑洞 */
export interface WsHandlers {
  onStateChange: (state: WsState, detail: string) => void;
  onText: (text: string) => void;
  onBinary: (payload: ArrayBuffer) => void;
  onGiveUp: (code: number, message: string) => void;
}

export interface WsOptions {
  url: string;
  token: string;
  pingIntervalSec: number;
  pongTimeoutSec: number;
  maxRetry: number;
  baseDelayMs: number;
  maxDelayMs: number;
  queueLimit: number;
}

/** 握手请求头:ArkTS 要求对象字面量有明确类型 */
interface WsHeader {
  'X-Client': string;
  'X-Token': string;
}

2. 退避计算:一个纯函数

退避逻辑单独抽出来,便于单测,也便于把「上限」和「抖动」这类策略参数化和代码解耦。

/** 指数退避 + 30% 随机抖动,结果封顶 maxMs */
export function computeBackoffDelay(attempt: number, baseMs: number, maxMs: number): number {
  const exp: number = baseMs * Math.pow(2, attempt);
  const capped: number = Math.min(exp, maxMs);
  const jitter: number = Math.floor(Math.random() * capped * 0.3);
  return Math.floor(capped + jitter);
}

attempt 从 0 开始递增:0 次退避是 baseMs,之后每次翻倍直到 maxMs。抖动是防惊群的——如果没有抖动,同一批断网的客户端会在同一个毫秒点一起重连。

3. 完整可复用的 WsClient 封装

这是全文的核心:状态机、世代号、单飞重连、退避、待发队列、消息分流、成套销毁,全部落在一个类里。

export class WsClient {
  private ws: webSocket.WebSocket | undefined = undefined;
  private state: WsState = WsState.IDLE;
  /** 世代号:每次建链递增,旧实例的迟到回调靠它失效 */
  private generation: number = 0;
  private retryCount: number = 0;
  private retryTimer: number = -1;
  private fallbackTimer: number = -1;
  private manualClosed: boolean = false;
  private pending: string[] = [];
  private readonly options: WsOptions;
  private readonly handlers: WsHandlers;

  constructor(options: WsOptions, handlers: WsHandlers) {
    this.options = options;
    this.handlers = handlers;
  }

  public getState(): WsState {
    return this.state;
  }

  public connect(): void {
    if (this.state === WsState.CONNECTING || this.state === WsState.OPEN || this.state === WsState.CLOSING) {
      hilog.warn(DOMAIN, TAG, 'connect ignored, state=%{public}s', this.state);
      return;
    }
    this.manualClosed = false;
    this.clearRetryTimer();
    this.clearFallbackTimer();
    this.generation = this.generation + 1;
    const current: number = this.generation;
    this.state = WsState.CONNECTING;
    this.handlers.onStateChange(this.state, 'connect-request');

    const ws: webSocket.WebSocket = webSocket.createWebSocket();
    this.ws = ws;

    // 订阅必须早于 connect,否则 open 可能先到而无处投递
    ws.on('open', (err: BusinessError, value: Object) => {
      if (current !== this.generation) {
        return;
      }
      if (err) {
        hilog.error(DOMAIN, TAG, 'open with err, code=%{public}d', err.code);
        return;
      }
      this.state = WsState.OPEN;
      this.retryCount = 0;
      this.handlers.onStateChange(this.state, 'open');
      this.flushPending();
    });

    ws.on('message', (err: BusinessError, value: string | ArrayBuffer) => {
      if (current !== this.generation) {
        return;
      }
      if (typeof value === 'string') {
        this.handlers.onText(value);
      } else {
        const buffer: ArrayBuffer = value as ArrayBuffer;
        this.handlers.onBinary(buffer);
      }
    });

    ws.on('close', (err: BusinessError, result: webSocket.CloseResult) => {
      if (current !== this.generation) {
        return;
      }
      hilog.info(DOMAIN, TAG, 'close code=%{public}d reason=%{public}s', result.code, result.reason);
      this.clearFallbackTimer();
      this.ws = undefined;
      this.state = WsState.CLOSED;
      this.handlers.onStateChange(this.state, 'close');
      if (!this.manualClosed) {
        this.scheduleReconnect(result.code);
      }
    });

    ws.on('error', (err: BusinessError) => {
      if (current !== this.generation) {
        return;
      }
      hilog.error(DOMAIN, TAG, 'error code=%{public}d message=%{public}s', err.code, err.message);
      // error 不直接重连,只记录并挂兜底定时器;重连统一由 close 或兜底触发
      this.handlers.onStateChange(this.state, 'error');
      this.armFallback();
    });

    const header: WsHeader = { 'X-Client': 'harmony', 'X-Token': this.options.token };
    // pingInterval / pongTimeout 自 API 21 起可用,单位秒;低版本工程删掉这两项即可沿用默认 30s / 30s
    const requestOptions: webSocket.WebSocketRequestOptions = {
      header: header,
      pingInterval: this.options.pingIntervalSec,
      pongTimeout: this.options.pongTimeoutSec
    };

    ws.connect(this.options.url, requestOptions, (err: BusinessError, ok: boolean) => {
      if (current !== this.generation) {
        return;
      }
      if (err) {
        hilog.error(DOMAIN, TAG, 'connect fail code=%{public}d', err.code);
        this.ws = undefined;
        this.state = WsState.CLOSED;
        this.handlers.onStateChange(this.state, 'connect-fail');
        this.scheduleReconnect(err.code);
        return;
      }
      // ok 为 true 只代表请求创建成功,真正可用要等 open
      hilog.info(DOMAIN, TAG, 'connect request created=%{public}s', String(ok));
    });
  }

  public sendText(text: string): boolean {
    if (this.state === WsState.OPEN && this.ws !== undefined) {
      this.ws.send(text).then((ok: boolean) => {
        if (!ok) {
          hilog.warn(DOMAIN, TAG, 'send request not created');
        }
      }).catch((err: BusinessError) => {
        hilog.error(DOMAIN, TAG, 'send fail code=%{public}d', err.code);
      });
      return true;
    }
    if (this.state === WsState.CONNECTING && this.pending.length < this.options.queueLimit) {
      this.pending.push(text);
      hilog.info(DOMAIN, TAG, 'queued while connecting, size=%{public}d', this.pending.length);
      return false;
    }
    hilog.warn(DOMAIN, TAG, 'dropped, state=%{public}s', this.state);
    return false;
  }

  public sendBinary(payload: ArrayBuffer): boolean {
    if (this.state !== WsState.OPEN || this.ws === undefined) {
      hilog.warn(DOMAIN, TAG, 'binary dropped, state=%{public}s', this.state);
      return false;
    }
    this.ws.send(payload).then((ok: boolean) => {
      hilog.info(DOMAIN, TAG, 'binary sent=%{public}s', String(ok));
    }).catch((err: BusinessError) => {
      hilog.error(DOMAIN, TAG, 'binary send fail code=%{public}d', err.code);
    });
    return true;
  }

  /** 主动关闭:1000 为正常关闭,取值需在 [1000,1015] 内 */
  public close(code: number, reason: string): void {
    this.manualClosed = true;
    this.clearRetryTimer();
    this.clearFallbackTimer();
    if (this.ws === undefined) {
      this.state = WsState.CLOSED;
      return;
    }
    this.state = WsState.CLOSING;
    this.handlers.onStateChange(this.state, 'close-request');
    const closeOptions: webSocket.WebSocketCloseOptions = { code: code, reason: reason };
    this.ws.close(closeOptions).then((ok: boolean) => {
      hilog.info(DOMAIN, TAG, 'close request=%{public}s', String(ok));
    }).catch((err: BusinessError) => {
      hilog.error(DOMAIN, TAG, 'close fail code=%{public}d', err.code);
    });
  }

  /** 销毁:定时器、队列、订阅、链路,四件事一起做 */
  public destroy(): void {
    this.manualClosed = true;
    this.clearRetryTimer();
    this.clearFallbackTimer();
    this.pending = [];
    const ws: webSocket.WebSocket | undefined = this.ws;
    this.ws = undefined;
    this.generation = this.generation + 1;
    this.state = WsState.CLOSED;
    if (ws === undefined) {
      return;
    }
    ws.off('open');
    ws.off('message');
    ws.off('close');
    ws.off('error');
    ws.close().then(() => {
      hilog.info(DOMAIN, TAG, 'destroyed');
    }).catch((err: BusinessError) => {
      hilog.warn(DOMAIN, TAG, 'destroy close fail code=%{public}d', err.code);
    });
  }

  private flushPending(): void {
    const items: string[] = this.pending;
    this.pending = [];
    for (let i: number = 0; i < items.length; i++) {
      this.sendText(items[i]);
    }
  }

  /** 单飞:retryTimer 非 -1 说明已有重连在排,直接返回 */
  private scheduleReconnect(code: number): void {
    if (this.manualClosed) {
      return;
    }
    if (this.retryTimer !== -1) {
      hilog.info(DOMAIN, TAG, 'reconnect already scheduled, skip');
      return;
    }
    if (this.retryCount >= this.options.maxRetry) {
      hilog.warn(DOMAIN, TAG, 'retry exhausted, last code=%{public}d', code);
      this.handlers.onGiveUp(code, 'retry exhausted');
      return;
    }
    const delay: number = computeBackoffDelay(this.retryCount, this.options.baseDelayMs, this.options.maxDelayMs);
    this.retryCount = this.retryCount + 1;
    hilog.info(DOMAIN, TAG, 'retry #%{public}d in %{public}dms', this.retryCount, delay);
    this.retryTimer = setTimeout(() => {
      this.retryTimer = -1;
      this.connect();
    }, delay);
  }

  /** error 之后若 close 迟迟不来,兜底判定死亡,走同一条重连入口 */
  private armFallback(): void {
    if (this.fallbackTimer !== -1) {
      return;
    }
    this.fallbackTimer = setTimeout(() => {
      this.fallbackTimer = -1;
      if (this.state === WsState.CONNECTING || this.state === WsState.OPEN) {
        hilog.warn(DOMAIN, TAG, 'error without close, force teardown');
        this.generation = this.generation + 1;
        this.ws = undefined;
        this.state = WsState.CLOSED;
        this.handlers.onStateChange(this.state, 'error-fallback');
        this.scheduleReconnect(0);
      }
    }, 3000);
  }

  private clearRetryTimer(): void {
    if (this.retryTimer !== -1) {
      clearTimeout(this.retryTimer);
      this.retryTimer = -1;
    }
  }

  private clearFallbackTimer(): void {
    if (this.fallbackTimer !== -1) {
      clearTimeout(this.fallbackTimer);
      this.fallbackTimer = -1;
    }
  }
}

几个设计取舍值得点明:sendText 在 connecting 阶段不是静默丢弃,而是进一个有界队列(queueLimit),open 后按序冲刷——这解决了「页面一进来就要发首帧」的时序问题;但队列有上限,超过就丢弃并打日志,避免断网期间内存被无界撑大。close() 与 destroy() 都会置 manualClosed,这是区分主动关闭与被动断开的关键标志,on('close') 里靠它决定要不要重连。

4. 页面侧调用与 aboutToDisappear 清理

页面只负责订阅状态、驱动 UI,不直接触碰 WebSocket 对象。

import { WsClient, WsOptions, WsState } from '../common/WsClient';

@Entry
@Component
struct ChatPage {
  @State stateText: string = 'idle';
  @State lastText: string = '';
  private client: WsClient | undefined = undefined;

  aboutToAppear(): void {
    const options: WsOptions = {
      url: 'wss://echo.websocket.org',
      token: 'demo-token',
      pingIntervalSec: 20,
      pongTimeoutSec: 12,
      maxRetry: 6,
      baseDelayMs: 1000,
      maxDelayMs: 30000,
      queueLimit: 20
    };
    const client: WsClient = new WsClient(options, {
      onStateChange: (state: WsState, detail: string) => {
        this.stateText = state + '/' + detail;
      },
      onText: (text: string) => {
        this.lastText = text;
      },
      onBinary: (payload: ArrayBuffer) => {
        this.lastText = 'binary:' + payload.byteLength.toString();
      },
      onGiveUp: (code: number, message: string) => {
        this.stateText = 'gave-up:' + code.toString();
      }
    });
    this.client = client;
    client.connect();
  }

  aboutToDisappear(): void {
    if (this.client !== undefined) {
      this.client.destroy();
      this.client = undefined;
    }
  }

  build() {
    Column({ space: 12 }) {
      Text('state: ' + this.stateText).fontSize(14)
      Text('last: ' + this.lastText).fontSize(14)
      Button('send').onClick(() => {
        if (this.client !== undefined) {
          this.client.sendText('hello from ui');
        }
      })
    }.width('100%').padding(16)
  }
}

aboutToDisappear 里只调 destroy(),四步清理被封装在类内部,调用方不可能漏做——这是把「易错点」变成「不可能出错」的常用手法。

5. 错误码到处置动作的映射

把错误码判级做成一个纯函数,重连策略就能按级分流,而不是所有错误一律重试。

export enum WsAction {
  FAIL_FAST = 'fail-fast',
  RETRY_BACKOFF = 'retry-backoff'
}

/** 配置类错误重试无意义,网络类错误按退避重试 */
export function resolveAction(code: number): WsAction {
  switch (code) {
    case 401:
      return WsAction.FAIL_FAST;
    case 201:
      return WsAction.FAIL_FAST;
    case 2302001:
      return WsAction.FAIL_FAST;
    case 2302002:
      return WsAction.FAIL_FAST;
    case 2302998:
      return WsAction.FAIL_FAST;
    case 2302003:
      return WsAction.RETRY_BACKOFF;
    case 2302999:
      return WsAction.RETRY_BACKOFF;
    default:
      return WsAction.RETRY_BACKOFF;
  }
}

接进 connect 的失败分支后,FAIL_FAST 直接走到 onGiveUp,RETRY_BACKOFF 才进 scheduleReconnect,这样 URL 写错、证书路径配错这类问题会在第一次就暴露,而不是被六次无意义重试掩盖。

6. 文本帧解析的防御写法

on('message') 的字符串分支同样不能裸调 JSON.parse,对端一发包体不完整的脏帧就会抛异常。

interface Envelope {
  type: string;
  payload: string;
  seq: number;
}

function parseEnvelope(text: string): Envelope | undefined {
  try {
    const parsed: Envelope = JSON.parse(text) as Envelope;
    if (parsed.type === undefined) {
      return undefined;
    }
    return parsed;
  } catch (error) {
    const err: BusinessError = error as BusinessError;
    hilog.error(DOMAIN, TAG, 'bad frame code=%{public}d message=%{public}s', err.code, err.message);
    return undefined;
  }
}

7. 全局单例:让连接跨页面存活

如果业务要求「切换页面不断链」,把所有者从页面提到模块级,页面退化为纯订阅者。

let gClient: WsClient | undefined = undefined;

function buildOptions(): WsOptions {
  return {
    url: 'wss://echo.websocket.org',
    token: 'demo-token',
    pingIntervalSec: 30,
    pongTimeoutSec: 30,
    maxRetry: 8,
    baseDelayMs: 1000,
    maxDelayMs: 60000,
    queueLimit: 50
  };
}

export function getWsClient(handlers: WsHandlers): WsClient {
  if (gClient === undefined) {
    gClient = new WsClient(buildOptions(), handlers);
    gClient.connect();
  }
  return gClient;
}

export function releaseWsClient(): void {
  if (gClient !== undefined) {
    gClient.destroy();
    gClient = undefined;
  }
}

单例形态下,handlers 里的回调应当只做「分发到当前活跃页面」,而不是直接持有某个页面的 this;页面在 aboutToDisappear 里注销自己的监听,releaseWsClient 则留给 UIAbility 的退出时机。

总结

这条排查链的终点,其实是三句话:

第一,连接状态只能由事件推导,不能由返回值担保。 createWebSocket() 只给凭据,connect() 的 boolean 只说请求创建成功,send() 的 boolean 只说发送请求创建成功;唯一能证明链路可用的是 on('open'),而 send 必须在 open 之后调用。把这句话变成五个状态和一张迁移表,时序问题就从「偶发」变成了「不可能」。

第二,心跳先看框架给了什么,再决定自己写什么。 pingInterval 与 pongTimeout 负责协议层 Ping/Pong,默认都是 30s,0 表示关闭心跳,且 pongTimeout 须小于等于 pingInterval;心跳失败的落地形式是主动断开连接,因此它必然会走进 on('close')。应用层心跳要解决的是另一个问题——会话是否还有效,两者是叠加关系,不是替代关系。

第三,重连要有闸门,销毁要有清单。 单飞保证同一时刻只有一个重连任务,退避压住风暴,上限把控制权交回业务;close 是唯一的重连入口,error 只做记录与兜底。销毁时定时器、队列、订阅、链路四件事缺一不可。

可以直接当 checklist 用:

  1. connect() 之前是否已经订阅 open、message、close、error?
  2. 是否存在「不看状态就 send」的代码路径?send 是否只在 open 后调用?
  3. connecting 阶段的发送是入有界队列还是丢弃?队列上限是多少?
  4. connect 的 boolean 是否被误当成「连接成功」写进日志或业务分支?
  5. 状态迁移是否只由事件驱动,业务代码有没有直接改状态?
  6. 旧实例的迟到回调有没有用世代号屏蔽?
  7. pingInterval / pongTimeout 的单位是否确认为秒?pongTimeout 是否小于等于 pingInterval?
  8. 弱网场景下是否还需要应用层业务探活?探活失败如何降级?
  9. 重连是否有单飞?on('error') 与 on('close') 会不会各排一次?
  10. 退避是否有上限封顶与随机抖动?重试次数用尽后业务如何表现?
  11. 2302001 / 2302002 / 2302998 / 401 这类不可重试错误是否会被无意义重试?
  12. close 与 error 是否都通过同一个漏斗进入重连?
  13. on('message') 是否对 string 与 ArrayBuffer 做了分流?JSON.parse 是否有 try/catch?
  14. 单帧数据是否控制在 5242864 字节以内?超长数据是否在应用层分片?
  15. aboutToDisappear 里是否执行了定时器取消 + off() + close() 的完整销毁?
  16. 连接实例的所有者是页面还是单例?跨页面存活时,页面是否只注销监听而不销毁连接?
  17. 主动 close() 与被动断开的区分标志是否设置?会不会「自己关掉又自己连回来」?

把这份清单过一遍,WebSocket 的坑基本就只剩服务端那一半了。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐