WebSocket 很容易写出一个“演示正常”的版本:监听 open、message、close、error 再 connect()。真实问题出现在前后台切换和弱网里。

Socket Resume Lab 的最初版本能重新连接,却出现两个问题:同一 topic 收到重复消息,离线期间的消息也没有补回来。页面显示“已连接”,业务数据却已经断层。

所以这篇不把“socket 再次 open”当恢复成功,而是把重连、重新订阅和消息游标放到一条状态链里。

一、先定义什么叫恢复成功

本轮验收数据:

  • Session:ws_resume_20261001_10
  • Foreground:true
  • State:LIVE
  • Attempt:2
  • Backoff:2000 ms
  • Subscriptions:3
  • Duplicate Subscriptions:0
  • Cursor:msg_10428
  • Recovered Messages:7
  • Last RTT:54 ms

WebSocket 流程很直接:创建对象,监听 open/message/close/error,建立连接,不再需要时关闭。工程化关键是“谁持有连接”。

项目结构:

entry/src/main/ets/
├── pages/SocketResumePage.ets
├── network/SocketSession.ets
├── network/SubscriptionRegistry.ets
├── data/CursorStore.ets
└── ability/EntryAbility.ets

页面只展示状态,连接由 SocketSession 统一管理。

二、第一段代码:连接对象不能跟着页面反复创建

最早我把 WebSocket 写在页面里,回前台后页面重建,旧 socket 还没退出,新 socket 已开始工作,重复订阅就从这里产生了。因此连接对象必须有唯一主人。

import { webSocket } from '@kit.NetworkKit'

export class SocketSession {
  private socket: webSocket.WebSocket | null = null
  private connecting: boolean = false
  private foreground: boolean = true

  connect(): void {
    if (!this.foreground || this.connecting || this.socket) {
      return
    }

    this.connecting = true
    const ws = webSocket.createWebSocket()
    this.socket = ws

    ws.on('open', () => {
      this.connecting = false
      this.recoverAfterOpen()
    })

    ws.on('message', (_err, message) => {
      this.onMessage(message)
    })

    ws.on('close', () => {
      this.socket = null
      this.connecting = false
      this.scheduleReconnect()
    })

    ws.on('error', () => this.scheduleReconnect())
    ws.connect(this.url)
  }
}

页面生命周期不再决定 socket 生命周期。账号切换、应用退出或业务不再需要实时连接时,应主动 close 并释放引用。

三、第二段代码:失败后不要固定 1 秒暴力重试

弱网下最危险的写法是 setTimeout(connect, 1000)。服务端故障一分钟,客户端就会制造大量无意义连接。

Demo 使用指数退避:1 秒、2 秒、4 秒、8 秒,上限 30 秒。本轮第二次尝试,因此 Backoff = 2000 ms。

private attempt: number = 0
private reconnectTimer: number = -1
private backoffMs: number = 1000

private scheduleReconnect(): void {
  if (!this.foreground || this.reconnectTimer >= 0) {
    return
  }

  this.attempt += 1
  this.backoffMs = Math.min(
    1000 * Math.pow(2, this.attempt - 1),
    30000
  )

  this.state = 'RECONNECTING'
  this.reconnectTimer = setTimeout(() => {
    this.reconnectTimer = -1
    this.connect()
  }, this.backoffMs)
}

正式项目还可以加入随机抖动。我不会在 open 回调第一行就把 attempt 清零,只有订阅和消息恢复完成后才清。

四、第三段代码:订阅要分“期望状态”和“连接状态”

重复消息的根源不是 WebSocket 本身,而是我把历史 subscribe 操作机械重放。

更稳定的做法是维护两组集合:desired 表示业务想要的 topic,active 表示本次连接已经确认过的 topic。断线只清 active,不清 desired。

class SubscriptionRegistry {
  private desired: Set<string> = new Set()
  private active: Set<string> = new Set()

  want(topic: string): void {
    this.desired.add(topic)
  }

  resetConnectionState(): void {
    this.active.clear()
  }

  async resubscribe(
    send: (topic: string) => Promise<void>
  ): Promise<void> {
    for (const topic of this.desired) {
      if (this.active.has(topic)) {
        continue
      }
      await send(topic)
      this.active.add(topic)
    }
  }
}

本轮三个 topic 是 topic:news、topic:chat、topic:system,最终 Duplicate Subscriptions = 0。

这证明恢复逻辑是从当前业务状态推导,而不是重放历史操作。

DevEco 里我主要盯:

onForeground
connect attempt=2
backoff=2000ms
subscriptions=3 duplicate=0
cursor=msg_10428 recovered=7
State: RECONNECTING -> LIVE

五、第四段代码:游标必须在业务确认后推进

离线消息能否补齐,取决于游标怎么保存。

如果刚收到消息就把 cursor 写进 Preferences,但业务处理还没完成,此时应用异常退出,下次会从“已经写过但还没真正处理”的位置继续,消息就丢了。

所以 Demo 只有业务提交完成后才更新游标。

private async commitMessage(message: PushMessage): Promise<void> {
  await this.messageStore.apply(message)
  await this.cursorStore.save(message.id)
  this.cursor = message.id
}

private async recoverAfterOpen(): Promise<void> {
  this.state = 'RESUBSCRIBING'
  await this.registry.resubscribe(
    (topic) => this.sendSubscribe(topic)
  )

  this.state = 'RECOVERING'
  const cursor = await this.cursorStore.load()
  const missed = await this.fetchMissedMessages(cursor)

  for (const message of missed) {
    await this.commitMessage(message)
  }

  this.recoveredMessages = missed.length
  this.attempt = 0
  this.state = 'LIVE'
}

Preferences 只保存轻量 cursor;完整离线消息和发送队列应交给 RDB。本轮最终游标为 msg_10428,共恢复 7 条消息。

六、前后台切换只改变策略,不改变业务订阅

进入后台后,Demo 停止心跳和新的重连计时器;回前台时,如果没有有效连接才重新 connect。订阅集合始终保留。

onBackground(): void {
  this.foreground = false
  this.clearReconnectTimer()
  this.stopHeartbeat()
}

onForeground(): void {
  this.foreground = true
  if (!this.socket) {
    this.connect()
  } else {
    this.startHeartbeat()
  }
}

后台时不要清空业务订阅,否则前台重建时很容易制造重复订阅。

七、运行结果要看数字,不只看 LIVE

最终手机图里显示:

  • Attempt:2
  • Backoff:2000 ms
  • Subscriptions:3
  • Duplicate Subscriptions:0
  • Recovered Messages:7
  • Cursor:msg_10428
  • Last RTT:54 ms

我还做了两个反例:连续前后台切换但不断网,socket 不应重复创建;网络断开后立即退后台,reconnect timer 应被取消,回前台再重新计算。

八、上线前还要补三件事

鉴权失败和网络失败要分开;完全离线时应减少无效 connect;还要明确服务端游标保留窗口,过旧游标应切换全量同步或业务快照。

我现在对实时连接的验收标准也变了:socket 再次 open 只是第一层;订阅不重复、断线数据能补齐、生命周期不制造第二条连接,才算真正恢复。

九、参考资料

  • HarmonyOS Network Kit:WebSocket 连接、open/message/close/error 与关闭连接。
  • HarmonyOS Ability Kit:UIAbility 前后台生命周期。
  • HarmonyOS ArkData:Preferences 轻量持久化。
Logo

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

更多推荐