在鸿蒙应用里做 IM、行情推送、协同编辑这类实时业务,@ohos.net.webSocket 是绕不开的基础能力。但直接裸用官方 API 上线,几乎必然会遇到三类问题:弱网下连接悄悄死掉却收不到 close 事件、重连风暴打爆服务端、断线期间的消息丢失。这篇文章不停留在"怎么建立连接",而是把一个生产可用的 WebSocket 客户端拆成三层:心跳保活、指数退避重连、显式状态机,并给出完整可运行的 ArkTS 实现。
先讲清楚机制,否则后面的设计都是无根之木。
WebSocket 建立在 TCP 之上。TCP 是"沉默协议"——两端不发数据时,链路上没有任何流量。这带来一个致命问题:中间设备(NAT 网关、运营商防火墙、负载均衡器)会回收空闲连接的映射表项,典型超时在 60 秒到 5 分钟之间。映射被回收后:
ESTABLISHED 状态;这就是"半开连接":应用以为自己在线,实际早已失联。webSocket 的 close / error 事件此时不会触发,因为内核层面什么都没发生。
心跳(ping/pong)解决的就是半开问题,其原理是周期性强制产生双向流量:
{"type":"ping"} 或协议层 ping);两个参数的工程取值有讲究:
服务端故障恢复的瞬间,如果 10 万客户端同时发起重连,就是一次自己制造的 DDoS(惊群效应)。指数退避 + 随机抖动是标准解法:
delay = min(baseDelay * 2^attempt, maxDelay) * (0.5 + random() * 0.5)baseDelay 通常 1 秒,maxDelay 封顶 30–60 秒;attempt 计数。很多失败的封装用 isConnected / isReconnecting 一堆布尔值管理状态,很快就会出现"正在重连时用户手动断开,随后重连成功导致幽灵连接"这类竞态 bug。正确做法是显式状态机:
IDLE ──connect()──▶ CONNECTING ──open──▶ CONNECTED
▲ │ error/timeout │ heartbeat timeout / close / error
│ ▼ ▼
└──close()──── RECONNECT_WAIT ◀────────────┘
│ delay到期
└──────▶ CONNECTING (attempt+1)
任意状态 ──close()──▶ CLOSED(终态,不再自动重连)关键约束:
CLOSED 状态下收到迟到的 open 事件必须直接丢弃并主动关闭底层连接;CLOSED 终态,后者进 RECONNECT_WAIT。以下代码基于 API 12+,单文件可直接放进工程使用。
// RobustWebSocket.ets
import{webSocket}from'@kit.NetworkKit';
import{BusinessError}from'@kit.BasicServicesKit';
exportenumWsState{
IDLE='IDLE',
CONNECTING='CONNECTING',
CONNECTED='CONNECTED',
RECONNECT_WAIT='RECONNECT_WAIT',
CLOSED='CLOSED'
}
exportinterfaceWsConfig{
url:string;
heartbeatIntervalMs:number;// 心跳间隔,建议 30000
pongTimeoutMs:number;// pong 超时,建议 10000
baseReconnectDelayMs:number;// 退避基数,建议 1000
maxReconnectDelayMs:number;// 退避封顶,建议 30000
maxRetries:number;// -1 表示无限重连
}exportclassRobustWebSocket{
privatews:webSocket.WebSocket|null=null;
privatestate:WsState=WsState.IDLE;
privategeneration:number=0;// 代际号,防幽灵连接
privateattempt:number=0;// 当前重连次数
privateheartbeatTimer:number=-1;
privatepongTimer:number=-1;
privatereconnectTimer:number=-1;
privatesendQueue:string[]=[];// 断线期间的消息队列
privateconfig:WsConfig;
onMessage?:(data:string)=>void;
onStateChange?:(s:WsState)=>void;
constructor(config:WsConfig){
this.config=config;
}
privatesetState(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();
}
privatedoConnect():void{
constgen=++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;}
consttext=typeofdata==='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);}
});
}
privatehandleDead(gen:number):void{
if(gen!==this.generation){return;}// 旧代际事件,忽略
if(this.state===WsState.CLOSED){return;}// 用户已主动关闭
this.stopHeartbeat();
this.scheduleReconnect();
}
privatescheduleReconnect():void{
if(this.config.maxRetries>=0&&this.attempt>=this.config.maxRetries){
this.close();
return;
}
this.setState(WsState.RECONNECT_WAIT);
// 指数退避 + 0.5~1.0 随机抖动
constraw=Math.min(
this.config.baseReconnectDelayMs*Math.pow(2,this.attempt),
this.config.maxReconnectDelayMs
);
constdelay=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);
}
// —— 心跳 ——
privatestartHeartbeat(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);
}
privateclearPongTimer():void{
if(this.pongTimer!==-1){clearTimeout(this.pongTimer);this.pongTimer=-1;}
}
privatestopHeartbeat():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);
}elseif(this.state!==WsState.CLOSED){
if(this.sendQueue.length>=100){this.sendQueue.shift();}// 有界队列防内存膨胀
this.sendQueue.push(data);
}
}
privateflushQueue():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=[];
}
}// Index.ets
import{RobustWebSocket,WsState}from'./RobustWebSocket';
@Entry
@Component
structIndex{
@StateconnState:string='IDLE';
@StatelastMsg:string='';
privateclient:RobustWebSocket=newRobustWebSocket({
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 声明网络权限:
"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'),后台超过阈值时主动降级为关闭连接,回前台立即重连,比后台硬扛心跳更省电;状态机 + 代际号 + 有界队列,这三件套是所有长连接客户端的通用骨架,不止适用于 WebSocket,蓝牙 GATT、软总线通道同样适用。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。