在鸿蒙应用里做 IM、行情推送、协同编辑这类实时业务,@ohos.net.webSocket 是绕不开的基础能力。但直接裸用官方 API 上线,几乎必然会遇到三类问题:弱网下连接悄悄死掉却收不到 close 事件 、重连风暴打爆服务端 、断线期间的消息丢失。这篇文章不停留在"怎么建立连接",而是把一个生产可用的 WebSocket 客户端拆成三层:心跳保活、指数退避重连、显式状态机,并给出完整可运行的 ArkTS 实现。
一、原理:为什么 TCP 存活 ≠ 连接可用
先讲清楚机制,否则后面的设计都是无根之木。
1.1 半开连接(Half-Open)问题
WebSocket 建立在 TCP 之上。TCP 是"沉默协议"------两端不发数据时,链路上没有任何流量。这带来一个致命问题:中间设备(NAT 网关、运营商防火墙、负载均衡器)会回收空闲连接的映射表项,典型超时在 60 秒到 5 分钟之间。映射被回收后:
- 客户端内核里的 socket 依然是
ESTABLISHED状态; - 客户端发数据会失败(或被静默丢弃),但在下一次真正写数据之前,应用层完全感知不到;
- 服务端可能早就把这条连接判死并清理了。
这就是"半开连接":应用以为自己在线,实际早已失联。webSocket 的 close / error 事件此时不会触发,因为内核层面什么都没发生。
1.2 心跳的本质:主动制造流量以探测链路
心跳(ping/pong)解决的就是半开问题,其原理是周期性强制产生双向流量:
- 客户端每隔 T 秒发一个轻量帧(应用层约定的
{"type":"ping"}或协议层 ping); - 服务端收到后必须回 pong;
- 客户端若在超时窗口 W 内没收到 pong,即可断定链路已死,主动关闭并进入重连流程。
两个参数的工程取值有讲究:
- 心跳间隔 T :必须小于链路上最短的 NAT 超时。移动网络下经验值 25--50 秒(微信长连接早期用 4.5 分钟被大量运营商掐死,后来动态探测收敛到几十秒量级);
- pong 超时 W :太短会在网络抖动时误判,太长则死连接存活过久。经验值 T 的 1/3 到 1/2,如 T=30s、W=10s。
1.3 重连为什么必须指数退避
服务端故障恢复的瞬间,如果 10 万客户端同时发起重连,就是一次自己制造的 DDoS(惊群效应)。指数退避 + 随机抖动是标准解法:
scss
delay = min(baseDelay * 2^attempt, maxDelay) * (0.5 + random() * 0.5)
baseDelay通常 1 秒,maxDelay封顶 30--60 秒;- 随机因子把所有客户端的重连时间打散,避免同步冲击;
- 连接成功并稳定一段时间后(如 30 秒),重置
attempt计数。
二、设计:显式状态机取代布尔标志
很多失败的封装用 isConnected / isReconnecting 一堆布尔值管理状态,很快就会出现"正在重连时用户手动断开,随后重连成功导致幽灵连接"这类竞态 bug。正确做法是显式状态机:
lua
IDLE ──connect()──▶ CONNECTING ──open──▶ CONNECTED
▲ │ error/timeout │ heartbeat timeout / close / error
│ ▼ ▼
└──close()──── RECONNECT_WAIT ◀────────────┘
│ delay到期
└──────▶ CONNECTING (attempt+1)
任意状态 ──close()──▶ CLOSED(终态,不再自动重连)
关键约束:
- 所有事件先过状态检查 :
CLOSED状态下收到迟到的open事件必须直接丢弃并主动关闭底层连接; - 每次连接尝试携带代际号(generation):旧代际的回调一律忽略,从根上消灭幽灵连接;
- 用户主动 close 与异常 close 走不同路径 :前者进
CLOSED终态,后者进RECONNECT_WAIT。
三、实现:完整 ArkTS 代码
以下代码基于 API 12+,单文件可直接放进工程使用。
3.1 状态与配置定义
typescript
// RobustWebSocket.ets
import { webSocket } from '@kit.NetworkKit';
import { BusinessError } from '@kit.BasicServicesKit';
export enum WsState {
IDLE = 'IDLE',
CONNECTING = 'CONNECTING',
CONNECTED = 'CONNECTED',
RECONNECT_WAIT = 'RECONNECT_WAIT',
CLOSED = 'CLOSED'
}
export interface WsConfig {
url: string;
heartbeatIntervalMs: number; // 心跳间隔,建议 30000
pongTimeoutMs: number; // pong 超时,建议 10000
baseReconnectDelayMs: number; // 退避基数,建议 1000
maxReconnectDelayMs: number; // 退避封顶,建议 30000
maxRetries: number; // -1 表示无限重连
}
3.2 核心类:状态机 + 心跳 + 退避
typescript
export class RobustWebSocket {
private ws: webSocket.WebSocket | null = null;
private state: WsState = WsState.IDLE;
private generation: number = 0; // 代际号,防幽灵连接
private attempt: number = 0; // 当前重连次数
private heartbeatTimer: number = -1;
private pongTimer: number = -1;
private reconnectTimer: number = -1;
private sendQueue: string[] = []; // 断线期间的消息队列
private config: WsConfig;
onMessage?: (data: string) => void;
onStateChange?: (s: WsState) => void;
constructor(config: WsConfig) {
this.config = config;
}
private setState(s: WsState): void {
if (this.state === s) { return; }
console.info(`[WS] ${this.state} -> ${s}`);
this.state = s;
this.onStateChange?.(s);
}
connect(): void {
if (this.state !== WsState.IDLE && this.state !== WsState.RECONNECT_WAIT) {
return; // 状态机拒绝非法迁移
}
this.doConnect();
}
private doConnect(): void {
const gen = ++this.generation; // 本次尝试的代际号
this.setState(WsState.CONNECTING);
this.ws = webSocket.createWebSocket();
this.ws.on('open', () => {
if (gen !== this.generation || this.state === WsState.CLOSED) {
this.ws?.close(); // 迟到的旧代际回调,直接丢弃
return;
}
this.attempt = 0;
this.setState(WsState.CONNECTED);
this.startHeartbeat(gen);
this.flushQueue();
});
this.ws.on('message', (err: BusinessError, data: string | ArrayBuffer) => {
if (gen !== this.generation) { return; }
const text = typeof data === 'string' ? data : '';
if (text === '{"type":"pong"}') {
this.clearPongTimer(); // 收到 pong,链路确认存活
return;
}
this.onMessage?.(text);
});
this.ws.on('close', () => this.handleDead(gen));
this.ws.on('error', () => this.handleDead(gen));
this.ws.connect(this.config.url, (err: BusinessError) => {
if (err && gen === this.generation) { this.handleDead(gen); }
});
}
private handleDead(gen: number): void {
if (gen !== this.generation) { return; } // 旧代际事件,忽略
if (this.state === WsState.CLOSED) { return; } // 用户已主动关闭
this.stopHeartbeat();
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.config.maxRetries >= 0 && this.attempt >= this.config.maxRetries) {
this.close();
return;
}
this.setState(WsState.RECONNECT_WAIT);
// 指数退避 + 0.5~1.0 随机抖动
const raw = Math.min(
this.config.baseReconnectDelayMs * Math.pow(2, this.attempt),
this.config.maxReconnectDelayMs
);
const delay = raw * (0.5 + Math.random() * 0.5);
this.attempt++;
console.info(`[WS] reconnect #${this.attempt} in ${Math.round(delay)}ms`);
this.reconnectTimer = setTimeout(() => this.doConnect(), delay);
}
// ------ 心跳 ------
private startHeartbeat(gen: number): void {
this.heartbeatTimer = setInterval(() => {
if (gen !== this.generation || this.state !== WsState.CONNECTED) { return; }
this.ws?.send('{"type":"ping"}');
this.pongTimer = setTimeout(() => {
console.warn('[WS] pong timeout, connection is half-open');
this.ws?.close(); // 主动关掉死连接
this.handleDead(gen); // close 事件可能不来,直接驱动状态机
}, this.config.pongTimeoutMs);
}, this.config.heartbeatIntervalMs);
}
private clearPongTimer(): void {
if (this.pongTimer !== -1) { clearTimeout(this.pongTimer); this.pongTimer = -1; }
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== -1) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = -1; }
this.clearPongTimer();
}
// ------ 发送与队列 ------
send(data: string): void {
if (this.state === WsState.CONNECTED) {
this.ws?.send(data);
} else if (this.state !== WsState.CLOSED) {
if (this.sendQueue.length >= 100) { this.sendQueue.shift(); } // 有界队列防内存膨胀
this.sendQueue.push(data);
}
}
private flushQueue(): void {
while (this.sendQueue.length > 0 && this.state === WsState.CONNECTED) {
this.ws?.send(this.sendQueue.shift()!);
}
}
// ------ 用户主动关闭:终态,不再重连 ------
close(): void {
this.generation++; // 使所有在途回调失效
this.setState(WsState.CLOSED);
this.stopHeartbeat();
if (this.reconnectTimer !== -1) { clearTimeout(this.reconnectTimer); this.reconnectTimer = -1; }
this.ws?.close();
this.ws = null;
this.sendQueue = [];
}
}
3.3 页面接入示例
typescript
// Index.ets
import { RobustWebSocket, WsState } from './RobustWebSocket';
@Entry
@Component
struct Index {
@State connState: string = 'IDLE';
@State lastMsg: string = '';
private client: RobustWebSocket = new RobustWebSocket({
url: 'wss://echo.websocket.events',
heartbeatIntervalMs: 30000,
pongTimeoutMs: 10000,
baseReconnectDelayMs: 1000,
maxReconnectDelayMs: 30000,
maxRetries: -1
});
aboutToAppear(): void {
this.client.onStateChange = (s: WsState) => { this.connState = s; };
this.client.onMessage = (msg: string) => { this.lastMsg = msg; };
this.client.connect();
}
aboutToDisappear(): void {
this.client.close(); // 页面销毁必须走终态,否则定时器泄漏
}
build() {
Column({ space: 12 }) {
Text(`连接状态:${this.connState}`).fontSize(18)
Text(`最近消息:${this.lastMsg}`).fontSize(14).fontColor('#666')
Button('发送测试消息')
.onClick(() => this.client.send(JSON.stringify({ type: 'chat', body: 'hello' })))
}
.width('100%').padding(16)
}
}
别忘了在 module.json5 声明网络权限:
json
"requestPermissions": [
{ "name": "ohos.permission.INTERNET" }
]
四、验证:三个必测场景
封装完成后,用下面三个场景验证(真机 + DevEco Studio 日志观察状态迁移):
| 场景 | 操作 | 预期行为 |
|---|---|---|
| 半开探测 | 连接后开飞行模式 30 秒再关闭 | 心跳 pong 超时 → 主动 close → 退避重连成功 |
| 重连风暴抑制 | 关闭测试服务端 2 分钟再启动 | 重连间隔依次约 1s→2s→4s→...→30s 封顶,且带随机抖动 |
| 竞态防御 | 在 RECONNECT_WAIT 时调用 close(),随后等待 | 状态停在 CLOSED,不出现任何幽灵连接日志 |
实测数据(Mate 60,API 12,模拟弱网):心跳 T=30s / W=10s 配置下,半开连接的最大检测延迟为 40 秒(一个心跳周期 + 超时窗口);对检测时效要求更高的行情类业务可压到 T=15s / W=5s,代价是每天每连接多约 5KB 心跳流量,可接受。
五、工程延伸
- 前后台联动 :结合
on('applicationStateChange'),后台超过阈值时主动降级为关闭连接,回前台立即重连,比后台硬扛心跳更省电; - 消息可靠性:本文的发送队列只保证"断线不丢、恢复即发",若要端到端可靠还需要业务层 ACK + 消息去重(客户端生成幂等 ID);
- 多连接复用:一个应用维护一条 WebSocket、以事件总线分发给各页面,比每个页面各建连接节省得多。
状态机 + 代际号 + 有界队列,这三件套是所有长连接客户端的通用骨架,不止适用于 WebSocket,蓝牙 GATT、软总线通道同样适用。