WebSocket 使用速查
WebSocket 使用速查
项目中 WebSocket 主要用于:
接收云端推送
刷新文件列表
刷新用户信息
刷新系统消息
接收支付通知
监听设备状态
Token 失效提醒
项目主要封装在:
WebSocketUtil.ets
对外统一使用:
WebSocketUtil.connect();
WebSocketUtil.close();
WebSocketUtil.send(message);
WebSocketUtil.getConnectStatus();
1. 最基本的连接方式
import { WebSocketUtil } from '../core/utils/webSocket/WebSocketUtil';
WebSocketUtil.connect();
连接前需要保证用户已经登录:
if (!UserCloudLoginStorage.getIsLogin()) {
return;
}
WebSocketUtil.connect();
连接地址统一放在 WebSocket 封装类中:
private url: string =
'wss://WEBSOCKET_SERVER_URL/ws';
不要在多个页面中各自创建 WebSocket:
// 不建议
const socket1 = webSocket.createWebSocket();
const socket2 = webSocket.createWebSocket();
否则容易出现:
重复接收相同消息
重复刷新数据
重复发送心跳
多个重连定时器同时运行
2. WebSocket 连接流程
检查登录状态
-> 创建 WebSocket
-> 设置 Token 和设备信息
-> 绑定 open/message/close/error
-> 发起连接
-> 连接成功后发送握手消息
-> 启动心跳
-> 启动超时检测
创建连接:
this.socket =
webSocket.createWebSocket();
const options:
webSocket.WebSocketRequestOptions = {
skipServerCertVerification: false,
header: {
tokenId: ACCESS_TOKEN,
userId: 'CURRENT_USER_ID',
deviceId: 'DEVICE_ID',
deviceName: 'DEVICE_NAME'
}
};
await this.socket.connect(
'wss://WEBSOCKET_SERVER_URL/ws',
options
);
正式环境不要开启:
skipServerCertVerification: true
这个配置会跳过服务端证书校验,测试环境可以临时使用,正式环境存在安全风险。
3. 连接成功后发送握手消息
项目连接成功后会发送绑定消息:
const handshake = {
MsgType: 'bind',
Content:
'CURRENT_USER_ID:HELLO_MESSAGE',
userId: 'CURRENT_USER_ID'
};
this.send(
JSON.stringify(handshake)
);
握手的作用:
告诉服务端当前是谁
绑定当前用户
绑定当前设备
让服务端开始推送消息
建议不要把完整 Token、密码等敏感信息写入握手内容或日志。
4. 发送消息
WebSocketUtil.send(
JSON.stringify({
MsgType: 'CUSTOM_MESSAGE',
Content: 'MESSAGE_CONTENT'
})
);
封装内部需要先判断连接状态:
send(data: string): void {
if (
!this.socket ||
!this.isConnected
) {
LogUtils.warn(
TAG,
'当前未连接,发送取消'
);
return;
}
this.socket.send(data)
.then(() => {
LogUtils.info(
TAG,
'消息发送成功'
);
})
.catch((error) => {
LogUtils.error(
TAG,
`消息发送失败: ${
JSON.stringify(error)
}`
);
});
}
发送之前不要只判断:
this.socket !== null
还要判断:
this.isConnected === true
因为 WebSocket 对象存在,不代表连接已经建立。
5. 接收消息
项目监听:
this.socket.on(
'message',
(error, value) => {
if (error) {
return;
}
this.handleMessage(value);
}
);
文本消息:
const text =
value as string;
console.info(
'收到消息: ' + text
);
二进制消息:
if (value instanceof ArrayBuffer) {
const bytes =
new Uint8Array(value);
console.info(
'收到二进制消息长度: ' +
bytes.length
);
}
不要假设所有服务端消息都是 JSON:
// 不安全,非 JSON 会直接抛异常
const message =
JSON.parse(value as string);
推荐安全解析:
function parseMessage(
value: string | ArrayBuffer
): SocketPushMessage | null {
if (typeof value !== 'string') {
return null;
}
try {
return JSON.parse(
value
) as SocketPushMessage;
} catch (error) {
LogUtils.warn(
TAG,
'收到非 JSON 消息,跳过解析'
);
return null;
}
}
项目中可能收到:
JSON 推送
ping
pong
普通文本错误消息
所以必须分别处理。
6. 过滤心跳消息
if (
value === 'ping' ||
value === 'pong'
) {
return;
}
心跳消息不要继续当作业务消息处理:
if (
value !== 'ping' &&
value !== 'pong'
) {
this.handleBusinessMessage(
value
);
}
否则可能出现:
JSON 解析异常
错误日志增加
无意义刷新页面
7. 项目推送消息结构
项目主要使用:
class SocketPushMessage {
MsgType: string = '';
FromUserId: number = 0;
Content: string = '';
SendMsgType: number = 0;
RefreshActions: number = 0;
}
字段含义:
MsgType -> 消息大类型
FromUserId -> 发送者用户 ID
Content -> 消息内容
SendMsgType -> 具体业务消息类型
RefreshActions -> 是否需要刷新数据
只处理用户推送:
if (
message.MsgType === 'pushUser'
) {
handlePushUser(message);
}
消息分发:
switch (message.SendMsgType) {
case WsSendMsgType.GET_USER_INFO:
refreshUserInfo();
break;
case WsSendMsgType.SYSTEM_NOTIFY:
refreshSystemMessage();
break;
case WsSendMsgType.FILE_OPERATION:
refreshFileData();
break;
case WsSendMsgType.PAYMENT_NOTIFY:
refreshPaymentData();
break;
case WsSendMsgType.DEVICE_STATUS:
refreshDeviceStatus();
break;
case WsSendMsgType.LOGIN_EXPIRED:
case WsSendMsgType.PASSWORD_MODIFY:
forceLogout();
break;
default:
LogUtils.info(
TAG,
'未知消息类型'
);
}
8. 收到文件变更通知后不要立即刷新
同一时间可能连续收到多条文件通知:
上传一张图片 -> 一条通知
上传一段视频 -> 一条通知
文件状态变化 -> 一条通知
云空间变化 -> 一条通知
如果每条消息都立即请求接口,会出现:
重复请求
接口压力变大
页面频繁刷新
数据还没有准备好就开始查询
项目使用防抖:
private fileDebounceTimer: number = -1;
private refreshFileDataLater(): void {
if (
this.fileDebounceTimer !== -1
) {
clearTimeout(
this.fileDebounceTimer
);
}
this.fileDebounceTimer =
setTimeout(async () => {
this.fileDebounceTimer = -1;
await CloudRepoApi.auth
.getUserCloudSpace();
AudioMonitorUtils
.initCloudData();
}, 30000) as number;
}
同一个业务使用同一个 key:
private debounce(
key: string,
callback: () => void,
delay: number
): void {
const oldTimer =
this.debounceTimers.get(key);
if (oldTimer) {
clearTimeout(oldTimer);
}
const timer =
setTimeout(() => {
this.debounceTimers.delete(key);
callback();
}, delay) as number;
this.debounceTimers.set(
key,
timer
);
}
调用:
this.debounce(
'fileOperation',
() => {
this.refreshFileData();
},
30000
);
9. 心跳机制
项目每 15 秒发送一次心跳:
private readonly HEARTBEAT_TIME =
15000;
private startHeartbeat(): void {
this.stopHeartbeat();
this.heartbeatTimer =
setInterval(() => {
this.send('heartbeat');
}, this.HEARTBEAT_TIME);
}
启动前先清理旧定时器:
private stopHeartbeat(): void {
if (
this.heartbeatTimer !== null
) {
clearInterval(
this.heartbeatTimer
);
this.heartbeatTimer = null;
}
}
否则重复调用连接时可能出现:
每 15 秒发送多条心跳
服务端误判异常
耗电增加
关闭连接后定时器仍然运行
10. 心跳和数据超时不是一回事
心跳 -> 主动告诉服务端客户端还活着
数据超时 -> 判断服务端长时间没有任何响应
数据超时检测:
private lastReceiveTime: number =
Date.now();
private startDataTimeoutCheck(): void {
this.stopDataTimeoutCheck();
this.dataTimeoutTimer =
setInterval(() => {
const noDataTime =
Date.now() -
this.lastReceiveTime;
if (noDataTime > DATA_TIMEOUT) {
this.socket?.close();
}
}, 5000);
}
每次收到消息都更新时间:
this.lastReceiveTime =
Date.now();
项目当前配置:
const DATA_TIMEOUT = 3000000;
这个值是:
3000000 毫秒 = 3000 秒 = 50 分钟
如果原意是 5 分钟,应改成:
const DATA_TIMEOUT =
5 * 60 * 1000;
如果原意是 30 秒:
const DATA_TIMEOUT =
30 * 1000;
注意注释和实际数值要保持一致。
11. WebSocket 自动重连
连接异常或被动关闭时重连:
private reconnect(): void {
if (
this.isManualClose ||
this.isReconnecting
) {
return;
}
if (
this.reconnectCount >= 3
) {
return;
}
this.isReconnecting = true;
this.reconnectCount++;
const delay =
30000 * this.reconnectCount;
this.reconnectTimer =
setTimeout(() => {
this.reconnectTimer = null;
this.socket = null;
this.isReconnecting = false;
this.createConnection();
}, delay) as number;
}
当前重连间隔类似:
第一次 -> 30 秒
第二次 -> 60 秒
第三次 -> 90 秒
重连时要清理旧定时器:
if (
this.reconnectTimer !== null
) {
clearTimeout(
this.reconnectTimer
);
this.reconnectTimer = null;
}
12. 连接失败和连接关闭要统一处理
连接失败:
this.socket
.connect(url, options)
.catch(() => {
this.socket = null;
this.isConnected = false;
this.reconnect();
});
连接关闭:
this.socket.on(
'close',
(error, result) => {
this.isConnected = false;
this.stopAllTimers();
if (!this.isManualClose) {
this.reconnect();
}
}
);
不要只在 error 回调中重连:
// 不完整
socket.on('error', () => {
reconnect();
});
有些系统会先触发 error,再触发 close。如果两个回调都直接重连,就会出现两次重连。
推荐所有异常统一走一个方法:
private handleSocketLost(): void {
if (this.isConnected) {
this.isConnected = false;
}
this.stopHeartbeat();
this.stopDataTimeoutCheck();
if (!this.isManualClose) {
this.reconnect();
}
}
13. 防止重复连接
连接入口加状态判断:
connect(): void {
if (
this.isConnected ||
this.isReconnecting ||
this.socket
) {
return;
}
this.isManualClose = false;
this.createConnection();
}
如果需要强制重连,不要直接创建新连接:
// 不建议
this.socket = null;
this.createConnection();
应该先关闭旧连接:
async forceReconnect(): Promise<void> {
this.isManualClose = true;
this.stopAllTimers();
try {
this.socket?.close();
} catch (error) {
LogUtils.warn(
TAG,
'关闭旧连接失败'
);
}
this.socket = null;
this.isConnected = false;
this.isManualClose = false;
this.createConnection();
}
14. 手动关闭和自动断开要区分
手动关闭:
WebSocketUtil.close();
手动关闭后不应该自动重连:
close(): void {
this.isManualClose = true;
this.isConnected = false;
this.stopAllTimers();
this.clearReconnect();
this.socket?.close();
this.socket = null;
}
被动断开:
if (!this.isManualClose) {
this.reconnect();
}
常见场景:
退出登录 -> 手动关闭,不重连
页面暂时隐藏 -> 根据业务决定
网络断开 -> 自动重连
服务端主动关闭 -> 自动重连
Token 失效 -> 清理登录,不重连
15. Token 失效处理
收到登录失效消息:
case WsSendMsgType.LOGIN_EXPIRED:
case WsSendMsgType.PASSWORD_MODIFY:
UserCloudLoginStorage.cloudLoginOut();
WebSocketUtil.close();
break;
HTTP 请求返回 Token 失效时:
401 -> 清理登录状态
401001 -> 刷新 Token 后重试原请求
WebSocket 重连时重新读取最新 Token:
const token =
UserCloudLoginStorage
.getTokenInfo()
.accessToken;
const userId =
UserCloudLoginStorage
.getUserInfo()
.userId;
不要把旧 Token 缓存在类成员中长期使用。
16. 网络切换时重新连接
相机 Wi-Fi、普通 Wi-Fi 和蜂窝网络切换时,WebSocket 可能绑定在旧网络上。
相机 Wi-Fi 下不要直接请求云 WebSocket:
相机 Wi-Fi -> 一般只能访问相机
普通 Wi-Fi -> 可以访问云服务
蜂窝网络 -> 可以访问云服务
网络恢复后:
WifiManagerUtils
.getInstance()
.bindToCellular();
WebSocketUtil.connectSocket();
普通 Wi-Fi 恢复后:
WifiManagerUtils
.getInstance()
.bindToWifi();
WebSocketUtil.connectSocket();
相机 Wi-Fi 需要访问云服务时:
await WifiManagerUtils
.getInstance()
.withInternet(async () => {
WebSocketUtil.connect();
});
如果 WebSocket 是长期连接,不建议每次接口请求都切换网络。应该在网络状态变化时统一处理。
17. 页面生命周期
如果 WebSocket 是全局服务,不建议页面销毁时关闭:
首页退出 -> WebSocket 仍保持
其他页面 -> 继续接收推送
如果 WebSocket 只属于某个页面,则页面退出时关闭:
aboutToDisappear(): void {
WebSocketUtil.close();
}
全局 WebSocket 一般在:
用户登录成功 -> connect
用户退出登录 -> close
Token 失效 -> close
应用退出 -> close
登录成功后连接:
async onLoginSuccess(): Promise<void> {
WebSocketUtil.connect();
}
退出登录:
async onLogout(): Promise<void> {
WebSocketUtil.close();
UserCloudLoginStorage.cloudLoginOut();
}
18. WebSocket 事件回调
项目封装了这些回调:
WebSocketUtil.onOpen = () => {
console.info('WebSocket 已连接');
};
WebSocketUtil.onMessage = (data) => {
console.info('收到 WebSocket 消息');
};
WebSocketUtil.onClose = () => {
console.info('WebSocket 已关闭');
};
WebSocketUtil.onError = () => {
console.info('WebSocket 出错');
};
建议回调中只做通知:
WebSocketUtil.onMessage = () => {
EmitterUtils.post(
MineCloudEnums
.WEBSOCKET_MESSAGE_RECEIVED
);
};
不要让基础 WebSocket 类直接依赖页面:
// 不建议
this.pageViewModel.refresh();
这样会导致:
网络层依赖 UI 层
页面销毁后回调报错
模块耦合严重
19. 普通消息和业务消息分开处理
建议分成两层:
WebSocket 层 -> 负责连接、收发、重连
业务消息层 -> 负责解析 MsgType 和 SendMsgType
WebSocket 层:
this.onMessage(value);
业务层:
function handleBusinessMessage(
value: string
): void {
const message =
parseMessage(value);
if (!message) {
return;
}
if (
message.MsgType !== 'pushUser'
) {
return;
}
switch (
message.SendMsgType
) {
case WsSendMsgType.FILE_OPERATION:
refreshFileList();
break;
case WsSendMsgType.SYSTEM_NOTIFY:
refreshSystemMessage();
break;
}
}
这样以后增加消息类型时,不需要修改连接底层代码。
20. 防止旧消息覆盖新数据
网络断开重连时,可能收到旧连接残留消息。
可以给连接生成唯一标识:
private connectionId: number = 0;
private createConnection(): void {
this.connectionId++;
const currentId =
this.connectionId;
const socket =
webSocket.createWebSocket();
socket.on(
'message',
(error, value) => {
if (
currentId !== this.connectionId
) {
return;
}
this.handleMessage(value);
}
);
}
重连时:
this.connectionId++;
这样旧连接回调不会继续修改新连接状态。
21. 日志怎么打印
可以打印:
LogUtils.info(
TAG,
`连接状态=${this.isConnected}`
);
LogUtils.info(
TAG,
`消息类型=${message.MsgType}`
);
LogUtils.info(
TAG,
`业务类型=${message.SendMsgType}`
);
不要打印:
完整 Token
密码
完整用户信息
完整 WebSocket 地址中的敏感参数
完整设备序列号
完整二维码内容
发送日志可以脱敏:
LogUtils.info(
TAG,
`消息发送成功,长度=${data.length}`
);
22. 常见问题排查
连接失败
1. 用户是否已经登录
2. WebSocket 地址是否正确
3. Token 是否为空或已过期
4. 当前 App 是否走了正确网络
5. 服务端证书是否有效
6. 是否重复创建连接
7. 是否触发了系统网络切换
能连接但收不到消息
1. 是否发送了握手消息
2. 用户 ID 是否正确
3. 服务端是否绑定成功
4. message 回调是否重复 off
5. 是否把非 JSON 消息直接丢弃
6. WebSocket 是否被网络切换断开
收到消息但页面不刷新
1. MsgType 是否为 pushUser
2. SendMsgType 是否匹配
3. 是否被防抖延迟
4. 是否只更新了缓存,没有通知 UI
5. 是否刷新了错误的 ViewModel
重连越来越多
1. error 和 close 是否同时触发重连
2. 是否没有 isReconnecting 锁
3. 是否没有清理旧重连定时器
4. connectSocket 是否反复调用
5. 旧 socket 是否仍然存在
退出登录后仍然收到消息
1. 是否调用 WebSocketUtil.close()
2. 是否清除了心跳定时器
3. 是否清除了数据超时定时器
4. 是否关闭了 socket
5. 是否还存在旧 socket 回调
23. 脱敏后的完整使用案例
import {
WebSocketUtil
} from './WebSocketUtil';
async function startWebSocket(): Promise<void> {
if (
!UserCloudLoginStorage.getIsLogin()
) {
return;
}
WebSocketUtil.onOpen = () => {
console.info(
'WebSocket 连接成功'
);
};
WebSocketUtil.onMessage = (
data
) => {
if (
typeof data !== 'string'
) {
return;
}
try {
const message =
JSON.parse(data);
switch (
message.SendMsgType
) {
case WsSendMsgType.FILE_OPERATION:
refreshFileListLater();
break;
case WsSendMsgType.SYSTEM_NOTIFY:
refreshSystemMessage();
break;
case WsSendMsgType.LOGIN_EXPIRED:
WebSocketUtil.close();
UserCloudLoginStorage
.cloudLoginOut();
break;
default:
console.info(
'收到未处理的消息'
);
}
} catch (error) {
console.info(
'收到非 JSON 消息'
);
}
};
WebSocketUtil.onClose = () => {
console.info(
'WebSocket 已关闭,等待自动重连'
);
};
WebSocketUtil.onError = () => {
console.info(
'WebSocket 发生异常'
);
};
WebSocketUtil.connect();
}
发送业务消息:
WebSocketUtil.send(
JSON.stringify({
MsgType: 'CUSTOM_MESSAGE',
Content: 'MESSAGE_CONTENT'
})
);
退出登录:
function logout(): void {
WebSocketUtil.close();
UserCloudLoginStorage
.cloudLoginOut();
}
隐私字段统一使用:
WEBSOCKET_SERVER_URL
ACCESS_TOKEN
CURRENT_USER_ID
DEVICE_ID
DEVICE_NAME
MESSAGE_CONTENT
DEVICE_SERIAL
注意:
可以,在 WebSocket 文档中补充下面两个重点即可。
如何避免同时发送多个消息:使用发送队列
多个地方同时调用 send() 时,不要直接调用底层 socket.send(),统一先放入队列,再按顺序发送。
private sendQueue: string[] = [];
private isSending: boolean = false;
send(data: string): void {
if (!data) {
return;
}
if (!this.socket || !this.isConnected) {
LogUtils.warn(TAG, 'WebSocket 未连接,取消发送');
return;
}
this.sendQueue.push(data);
this.flushSendQueue();
}
private flushSendQueue(): void {
if (
this.isSending ||
this.sendQueue.length === 0 ||
!this.socket ||
!this.isConnected
) {
return;
}
const data = this.sendQueue.shift();
if (!data) {
return;
}
this.isSending = true;
this.socket.send(data)
.then(() => {
LogUtils.info(
TAG,
`消息发送成功,长度=${data.length}`
);
})
.catch((error) => {
LogUtils.error(
TAG,
`消息发送失败: ${JSON.stringify(error)}`
);
})
.finally(() => {
this.isSending = false;
this.flushSendQueue();
});
}
调用时只使用封装好的方法:
WebSocketUtil.send(message1);
WebSocketUtil.send(message2);
WebSocketUtil.send(message3);
实际发送顺序:
message1 发送完成
-> message2 开始发送
-> message2 发送完成
-> message3 开始发送
不要这样写:
// 不建议绕过队列
this.socket.send(message1);
this.socket.send(message2);
连接关闭时清空队列:
private clearSendQueue(): void {
this.sendQueue.length = 0;
this.isSending = false;
}
this.socket.on('close', () => {
this.isConnected = false;
this.clearSendQueue();
});
旧连接里的消息不建议直接保留到新连接继续发送,因为消息可能已经过期,或者服务端已经处理成功。
WebSocket 粘包怎么处理
WebSocket 和 TCP 不一样。
WebSocket 本身有消息边界,正常情况下:
客户端 send 一次
服务端 message 回调收到一条消息
所以普通 WebSocket 文本消息一般不需要像 TCP 一样手动处理粘包:
WebSocketUtil.send(
JSON.stringify({
type: 'MESSAGE_1',
content: 'CONTENT_1'
})
);
WebSocketUtil.send(
JSON.stringify({
type: 'MESSAGE_2',
content: 'CONTENT_2'
})
);
服务端应该收到两条独立消息,而不是:
MESSAGE_1MESSAGE_2
但是以下情况仍然需要处理:
服务端自己把多条业务数据拼成一条消息
业务数据被拆成多段传输
文件或大数据被分片
设备协议主动返回半包
方式一:每条消息使用 JSON 包装
推荐给每条消息增加唯一 ID:
const message = {
messageId: 'MESSAGE_ID',
type: 'FILE_OPERATION',
data: {
fileId: 'FILE_ID'
}
};
WebSocketUtil.send(
JSON.stringify(message)
);
接收时一次解析一条:
private handleMessage(
value: string | ArrayBuffer
): void {
if (typeof value !== 'string') {
return;
}
try {
const message =
JSON.parse(value);
this.handleBusinessMessage(message);
} catch (error) {
LogUtils.warn(
TAG,
'消息不是合法 JSON'
);
}
}
方式二:服务端返回多条拼接内容时使用分隔符
如果服务端可能返回:
{"type":"A"}\n{"type":"B"}\n
可以使用换行符作为消息分隔:
private receiveBuffer: string = '';
private handleTextMessage(
value: string
): void {
this.receiveBuffer += value;
const messages =
this.receiveBuffer.split('\n');
// 最后一段可能是不完整消息,先保留
this.receiveBuffer =
messages.pop() ?? '';
messages.forEach((item) => {
if (!item.trim()) {
return;
}
try {
const message =
JSON.parse(item);
this.handleBusinessMessage(message);
} catch (error) {
LogUtils.warn(
TAG,
'单条消息解析失败'
);
}
});
}
注意:只有确认服务端使用换行分隔时,才能这样处理。不要无条件对所有 WebSocket 消息使用 split('\n')。
方式三:大数据使用分片字段
大文件或长文本可以定义分片格式:
interface MessageChunk {
messageId: string;
chunkIndex: number;
chunkTotal: number;
content: string;
}
发送:
const chunk = {
messageId: 'MESSAGE_ID',
chunkIndex: 0,
chunkTotal: 3,
content: 'CHUNK_CONTENT'
};
WebSocketUtil.send(
JSON.stringify(chunk)
);
接收时按 messageId 缓存:
private chunkMap:
Map<string, string[]> =
new Map();
private handleChunk(
chunk: MessageChunk
): void {
let list =
this.chunkMap.get(
chunk.messageId
);
if (!list) {
list = [];
this.chunkMap.set(
chunk.messageId,
list
);
}
list[chunk.chunkIndex] =
chunk.content;
if (
list.filter(
(item) => item !== undefined
).length === chunk.chunkTotal
) {
const content =
list.join('');
this.chunkMap.delete(
chunk.messageId
);
this.handleCompleteMessage(
content
);
}
}
重点区分
发送队列:
解决多个业务同时调用 send,保证发送顺序。
WebSocket 消息边界:
普通消息通常由 WebSocket 自动区分,不需要手动拆包。
业务分片:
大数据或服务端主动拆分时,使用 messageId、chunkIndex、chunkTotal 重新组装。
TCP 粘包:
只有自己直接使用 TCP 字节流时,才需要通过长度字段、分隔符等方式处理。
最终建议:
普通 JSON 消息 -> 直接一条一条解析
多个消息同时发送 -> 使用发送队列
服务端拼接消息 -> 使用分隔符或长度字段
大数据拆分传输 -> 使用 messageId + 分片序号
浙公网安备 33010602011771号