WebRTC+WebSocket 低延迟可视化大屏实时联动实战
做可视化大屏最怕客户来一句“我要实时”。数据指标用定时器轮询还能凑合,可一旦牵扯到视频画面,整个技术选型都会跟着变。我最近做的园区监控大屏项目,就是把 WebRTC 低延迟视频流和 WebSocket 实时状态通道接在一起,最终把端到端延迟压到了 500ms 以内。这类需求其实非常典型:森林防火、污水处理、园区安防、车间产线,凡是既要看数据又要看画面的可视化大屏,基本都逃不开这两个核心点——视频怎么低延迟上墙,状态怎么实时驱动图表。
今天把整个项目的封装思路和踩坑记录整理出来。内容包括:为什么最终选了 WebRTC 而不是更常见的 HLS 或 HTTP-FLV、播放器如何封装才能在不同大屏项目中复用、WebSocket 状态通道的消息协议怎么设计、视频区和图表区如何联动,以及上线前实测的延迟数据和几个绕不开的坑。适合正在做或者准备做可视化大屏的开发者参考,尤其适合前端为主、需要跟后端和流媒体网关协作的场景。
1. 大屏项目里的“实时”究竟卡在哪个环节
1.1 先看清楚 HLS 和 RTSP 为什么上不了大屏
大多数安防项目里,摄像头信号是 RTSP 流。RTSP 本身是传输控制协议,浏览器不会原生支持,直接给
<video>
塞 RTSP 地址是不现实的。于是很多团队会把 RTSP 先转成 HLS,用 m3u8 切片来播放。
问题就出在 HLS 的切片机制上。HLS 默认把视频切成 2 到 10 秒的 TS/MP4 分片,播放器要拿到切片索引、下载分片、再经过一小段缓冲才开始播放。实测下来端到端延迟最低也有 3 秒,通常都在 5 到 10 秒之间。大屏上显示的人和真正到场的人不同步,在门禁、出入口、生产线上这种场景基本不可用。
有人会说 HTTP-FLV 延迟不是只有一两秒吗?确实,HTTP-FLV 走的是 HTTP 流式传输,配合 flv.js 在浏览器里播放,延迟可以压到 1 到 3 秒。但 flv.js 依赖 MSE 能力,iOS Safari 对 MSE 的支持一直不理想,大屏项目一旦遇到 iPad 或者 iPhone 做辅助显示就会出问题。
1.2 多路视频并发下的选型对比
我权衡过的几个方案放在一起看会更直观:
| 方案 | 端到端延迟 | 浏览器兼容 | 落地成本 | 适合场景 |
|---|---|---|---|---|
| HLS | 3~10s | 全兼容 | 低 | 直播、监控回看 |
| HTTP-FLV + flv.js | 1~3s | iOS 不友好 | 中 | 低延迟直播 |
| RTSP 原生 | 原始流延迟 | 不支持 | 高 | 仅后端处理 |
| WebRTC | 0.3~0.7s | 现代浏览器原生支持 | 中高 | 实时监控、互动 |
WebRTC 在这个对比里几乎是唯一兼顾“浏览器原生支持”和“500ms 内低延迟”的方案。它的底层走 UDP,天然适合实时音视频传输,另外还内置了 ICE/STUN/TURN 机制,可以处理内外网穿透,在园区这种复杂网络环境下优势很明显。
选型的时候还有一条要注意:WebRTC 网关必须支持把 RTSP 转成 WebRTC 流。目前常用的开源方案有 ZLMediaKit、SRS、Janus,我在这个项目里用的是 ZLMediaKit,因为它的 WebRTC 支持比较成熟,直接拉取 RTSP 再推给前端,对 H.264 编码的摄像头兼容性很好,不需要自己维护转码服务。如果摄像头是 H.265 编码,这里会有一个明显的大坑,后面专门讲。
2. WebRTC 播放器封装:信令流程、ICE 候选积压和可复用组件
2.1 信令流程设计
WebRTC 本身只管音视频传输,但建立连接之前需要交换 SDP 和 ICE candidate,这个交换动作就叫信令。项目中我会把信令服务单独拆出来,不要和后面要讲的 WebSocket 状态通道混在一起。原因很简单:信令是“按需建立的短时连接”,状态通道是“全程保持的长连接”,混在一起会导致信令风暴影响业务数据推送。
信令流程基本是这样的:
-
前端通过 WebSocket 连接信令服务,发送
{ type: 'play', streamId } - 服务端向流媒体网关请求拉流,网关把 RTSP 拉起来之后,服务端通过信令通道返回 SDP offer
-
前端收到 offer,调用
setRemoteDescription设置远端描述 -
前端调用
createAnswer生成应答,再调用setLocalDescription设置本地描述 - 前端把 answer 回传给服务端
- 两端持续交换 ICE candidate
-
音视频轨道到达,前端把
stream绑定到<video>元素上
有一个细节需要注意:有些信令实现是前端自己创建 offer 发给服务端,服务端返回 answer;有些是服务端直接下发 offer。两种模式都存在,封装时要兼容,至少不要写死。
2.2 播放器类的封装实现
我把播放器封装成一个独立的类,大屏上每一个摄像头画面都是这个类的一个实例。这样做的好处是:视频流的创建和销毁逻辑统一管理,断线重连策略可以集中控制,后续如果要从 4 路视频扩展到 16 路,只需要在 UI 层多渲染几个组件。
type WebRTCState = 'idle' | 'connecting' | 'live' | 'reconnecting' | 'error';
type RTCSignalMessage =
| { type: 'offer'; sdp: RTCSessionDescription }
| { type: 'candidate'; candidate: RTCIceCandidateInit }
| { type: 'stream-ready'; streamId: string }
| { type: 'error'; code: number; message: string };
export interface WebRTCPlayerOptions {
video: HTMLVideoElement;
signalUrl: string;
streamId: string;
onStatus?: (state: WebRTCState) => void;
}
export class WebRTCPlayer {
private pc: RTCPeerConnection | null = null;
private ws: WebSocket | null = null;
private candidates: RTCIceCandidateInit[] = [];
private state: WebRTCState = 'idle';
private reconnectAttempts = 0;
private readonly maxReconnect = 5;
private destroyed = false;
constructor(private options: WebRTCPlayerOptions) {}
play() {
this.destroyed = false;
this.connectSignal();
}
destroy() {
this.destroyed = true;
this.ws?.close();
this.closePeer();
this.options.video.srcObject = null;
}
private connectSignal() {
const ws = new WebSocket(this.options.signalUrl);
this.ws = ws;
ws.onopen = () => {
this.setState('connecting');
this.send({ type: 'play', streamId: this.options.streamId });
};
ws.onmessage = (ev) => {
let msg: RTCSignalMessage;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
this.handleSignal(msg).catch((err) => {
console.error('[WebRTCPlayer] handle signal error', err);
this.setState('error');
});
};
ws.onclose = () => {
this.ws = null;
if (!this.destroyed) this.handleDisconnect();
};
}
private async handleSignal(msg: RTCSignalMessage) {
switch (msg.type) {
case 'offer': {
if (!this.pc) this.createPeer();
await this.pc!.setRemoteDescription(msg.sdp);
const answer = await this.pc!.createAnswer();
await this.pc!.setLocalDescription(answer);
this.send({ type: 'answer', sdp: this.pc!.localDescription });
for (const candidate of this.candidates) {
await this.pc!.addIceCandidate(candidate);
}
this.candidates = [];
break;
}
case 'candidate': {
if (!this.pc || !this.pc.remoteDescription) {
this.candidates.push(msg.candidate);
} else {
await this.pc.addIceCandidate(msg.candidate);
}
break;
}
case 'stream-ready': {
this.setState('live');
break;
}
case 'error': {
this.setState('error');
break;
}
}
}
private createPeer() {
const pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
this.pc = pc;
pc.ontrack = (ev) => {
const [stream] = ev.streams;
if (stream) {
this.options.video.srcObject = stream;
this.options.video.play().catch(() => {});
this.setState('live');
}
};
pc.onconnectionstatechange = () => {
if (pc.connectionState === 'failed') {
this.handleDisconnect();
}
};
}
private closePeer() {
if (!this.pc) return;
this.pc.ontrack = null;
this.pc.onconnectionstatechange = null;
this.pc.close();
this.pc = null;
}
private handleDisconnect() {
if (this.destroyed) return;
this.closePeer();
this.ws?.close();
if (this.reconnectAttempts >= this.maxReconnect) {
this.setState('error');
return;
}
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 15000);
this.reconnectAttempts += 1;
this.setState('reconnecting');
setTimeout(() => this.connectSignal(), delay);
}
private setState(state: WebRTCState) {
if (this.state !== state) {
this.state = state;
this.options.onStatus?.(state);
}
}
private send(data: unknown) {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(data));
}
}
}
这段代码里有一个很容易踩的坑:
candidates
的积压处理。WebRTC 规范要求在
setRemoteDescription
之后才能调用
addIceCandidate
,但实际信令服务可能会在 offer 之前或者 setRemoteDescription 尚未完成时就把 candidate 发过来。如果直接
addIceCandidate
,前端控制台经常报
InvalidStateError
。我在代码里做了判断,
pc.remoteDescription
不存在时先把 candidate 缓存起来,等 offer 处理完再统一补发。
2.3 Vue 组件封装和销毁时机
类封装好之后,Vue 组件就可以非常薄了。组件只负责渲染状态层和暴露
<video>
元素,具体连接逻辑全部交给
WebRTCPlayer
:
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { WebRTCPlayer } from '../lib/web-rtc-player';
const props = defineProps<{
signalUrl: string;
streamId: string;
}>();
const videoRef = ref<HTMLVideoElement>();
let player: WebRTCPlayer | null = null;
const status = ref<'idle' | 'connecting' | 'live' | 'reconnecting' | 'error'>('idle');
onMounted(() => {
player = new WebRTCPlayer({
video: videoRef.value!,
signalUrl: props.signalUrl,
streamId: props.streamId,
onStatus: (s) => (status.value = s)
});
player.play();
});
onBeforeUnmount(() => {
player?.destroy();
player = null;
});
</script>
<template>
<div class="rtsp-player" :class="`status-${status}`">
<video ref="videoRef" autoplay muted playsinline></video>
<div v-if="status !== 'live'" class="mask">
<span v-if="status === 'connecting' || status === 'reconnecting'">正在连接...</span>
<span v-else-if="status === 'error'">画面异常</span>
</div>
</div>
</template>
<style scoped>
.rtsp-player {
position: relative;
width: 100%;
height: 100%;
background: #000;
}
video {
width: 100%;
height: 100%;
object-fit: contain;
}
.mask {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
color: #fff;
}
</style>
组件里一定要带上
muted
和
playsinline
。大屏场景通常不需要声音,而且这两个属性可以绕开浏览器自动播放策略,后面踩坑部分会细说。
销毁时机也值得强调:大屏项目经常有“切换板块”或“路由跳转”的场景,如果组件销毁时没有调用
player.destroy()
,
RTCPeerConnection
和
WebSocket
会一直挂在后台。看网络面板时全是 CLOSE_WAIT 或 ESTABLISHED 状态的连接,数量多了之后网关会被拖垮。我在
destroy
里显式关闭了信令 WebSocket、关闭了 PeerConnection、清空了
srcObject
,这套清理逻辑在 4 路视频上不明显,但到 16 路的时候能很明显感觉出内存和连接数的差别。
3. WebSocket 状态通道:消息协议和推送会话管理
3.1 为什么状态通道不能用轮询替代
视频流本身解决的是“画面实时”,但大屏上还有另一类数据:设备状态、告警事件、能耗指标、环境监测数据。如果这些数据还靠前端定时器轮询接口,会有两个问题:一是轮询间隔太长,突发告警可能几十秒后才显示;二是轮询间隔太短,服务端压力大,尤其是多个大屏同时在线时会非常浪费。
WebSocket 的优势在服务端主动推送。只要有一个长连接,服务端可以在事件发生的第一时间把数据推到前端。这个能力对于大屏可视化来说是刚需,尤其是告警联动、设备离线检测、视频切换指令这类需要“事件驱动”的场景。
3.2 消息协议设计:让每条消息都知道自己该驱动什么
很多人做 WebSocket 推送会直接发一段裸数据,比如
{"value": 32}
。前端收到之后完全不知道这段数据是哪个设备的、要更新哪个图表、需不需要弹窗。等到页面模块多起来,消息分发逻辑就会变成一团乱麻。
我的做法是约定一个统一协议信封,所有消息都按这个结构走:
{
"type": "PLANT_ALARM",
"payload": {
"alarmId": "A-20240512-001",
"deviceId": "D-001",
"level": "critical",
"cameraId": "CAM-03",
"value": 88.5
},
"timestamp": 1715558400000,
"msgId": "2a86bc9e-a2dc-4a48-9358-89f6e1d5b9a1"
}
-
type决定前端行为:PLANT_ALARM触发告警弹窗和视频联动,ENERGY_DATA更新能耗图表,DEVICE_STATUS更新设备列表状态,VIDEO_PLAY下发视频切换指令 -
payload携带业务数据 -
timestamp是服务端时间戳,前端可以据此判断数据是否过期,以及做时间轴对齐 -
msgId用于幂等去重,防止消息重发导致重复处理
前端收到消息后,不直接操作业务模块,而是做一个简单的分发层,让每个模块注册自己关心的
type
:
type StateHandler = (payload: Record<string, unknown>, meta: { timestamp?: number; msgId?: string }) => void;
export class StateChannel {
private ws: WebSocket | null = null;
private heartbeatTimer: number | null = null;
private reconnectAttempts = 0;
private readonly maxReconnect = 8;
private handlers = new Map<string, Set<StateHandler>>();
constructor(private baseUrl: string) {}
start() {
this.connect();
}
on(type: string, handler: StateHandler) {
if (!this.handlers.has(type)) {
this.handlers.set(type, new Set());
}
this.handlers.get(type)!.add(handler);
}
private connect() {
const ws = new WebSocket(this.baseUrl, 'vis-screen-v1');
this.ws = ws;
ws.onopen = () => {
this.reconnectAttempts = 0;
this.startHeartbeat();
};
ws.onmessage = (ev) => {
let msg: StateMessage;
try {
msg = JSON.parse(ev.data);
} catch {
return;
}
this.dispatch(msg);
};
ws.onclose = () => this.handleClose();
ws.onerror = () => {};
}
private dispatch(msg: StateMessage) {
const handlers = this.handlers.get(msg.type);
if (!handlers) return;
handlers.forEach((handler) => {
handler(msg.payload, { timestamp: msg.timestamp, msgId: msg.msgId });
});
}
private startHeartbeat() {
this.stopHeartbeat();
this.heartbeatTimer = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify({ type: 'PING' }));
}
}, 30000);
}
private stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
private handleClose() {
this.stopHeartbeat();
if (this.reconnectAttempts >= this.maxReconnect) return;
const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 20000);
this.reconnectAttempts += 1;
setTimeout(() => this.connect(), delay);
}
send(type: string, payload: Record<string, unknown>) {
this.ws?.readyState === WebSocket.OPEN &&
this.ws.send(JSON.stringify({ type, payload }));
}
}
注意这里
new WebSocket(this.baseUrl, 'vis-screen-v1')
的第二个参数是 subprotocol。它可以在握手阶段就声明“我这个连接用的是 v1 协议”,服务端如果只支持 v1,就能在握手时就拒绝不兼容的客户端。做多版本大屏迭代时,这个机制很有用。
3.3 Spring Boot 服务端会话管理和常见并发问题
服务端我用的是 Spring Boot 内嵌的
@ServerEndpoint
。它的用法比较简单,但对并发处理有一些要求:
@Slf4j
@Component
@ServerEndpoint("/ws/state")
public class StateServerEndpoint {
private static final ConcurrentMap<String, Session> SESSIONS = new ConcurrentHashMap<>();
@OnOpen
public void onOpen(Session session) {
SESSIONS.put(session.getId(), session);
log.info("ws open, total={}", SESSIONS.size());
}
@OnClose
public void onClose(Session session) {
SESSIONS.remove(session.getId());
}
@OnError
public void onError(Session session, Throwable t) {
SESSIONS.remove(session.getId());
log.error("ws error", t);
}
@OnMessage
public void onMessage(String text, Session session) throws IOException {
JSONObject json = JSON.parseObject(text);
if ("PING".equals(json.getString("type"))) {
session.getBasicRemote().sendText("{\"type\":\"PONG\"}");
}
}
public static void broadcast(String type, Object payload) {
JSONObject msg = new JSONObject();
msg.put("type", type);
msg.put("payload", payload);
msg.put("timestamp", System.currentTimeMillis());
msg.put("msgId", UUID.randomUUID().toString());
for (Session session : SESSIONS.values()) {
try {
if (session.isOpen()) {
session.getBasicRemote().sendText(msg.toJSONString());
}
} catch (IOException e) {
log.warn("send failed, sessionId={}", session.getId());
SESSIONS.remove(session.getId());
}
}
}
}
这里有两个很关键的约束。第一,
@ServerEndpoint
类不是 Spring 管理的单例,多个连接会创建多个实例,如果直接把 Spring Bean 注入到字段里会拿不到。我这边是把会话列表放在
static ConcurrentHashMap
里,并且
broadcast
方法也是静态的,这样可以从任何地方调用,比如告警服务检测到异常时直接广播。
第二,
session.getBasicRemote().sendText()
在多个线程同时向同一个 session 发送时会爆
IllegalStateException
。所以遍历发送时必须逐条 try-catch,或者用带队列的
AsyncRemote
。我目前为了稳定性,优先遍历 + 失败移除,等消息量大了再考虑改异步发送。
另外建议在开发阶段直接用 Postman 的 WebSocket Request 调试这个接口。新建一个 WS 请求,地址填
ws://localhost:8080/ws/state
,subprotocol 填
vis-screen-v1
,然后手动发一条
{"type":"PING"}
,能很快验证服务端是否正常回包,不用每次启动整个前端项目。
4. 大屏联动编排:视频区与图表区如何共享同一套状态
4.1 三栏式布局下视频墙的位置
大屏的布局通常不用特别花哨,最稳定的是三栏式:左右两侧放指标数据和图表,中间区域留给视频墙。这样既可以利用超宽屏的横向空间,又不会让视频画面过度拉伸。
以 1920x1080 的三分屏为例,我会用 CSS Grid 来排:
.screen {
display: grid;
grid-template-columns: 360px 1fr 360px;
grid-template-rows: 64px 1fr 96px;
gap: 12px;
padding: 12px;
height: 100vh;
background: #07111f;
color: #d6e4ff;
}
.video-wall {
grid-column: 2;
grid-row: 2;
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-template-rows: repeat(2, 1fr);
gap: 12px;
}
左侧放能耗、产量、设备状态等指标卡,右侧放 ECharts 图表,中间放视频墙。视频墙内部再根据摄像头数量做 2x2、3x3 的网格。如果摄像头只有 1 路且需要重点突出,也可以让视频区跨越两行三行,这个按实际场景调。
4.2 从一条告警消息到联动切换的完整链路
这套联动逻辑是“状态驱动”这个说法的核心。拿一次设备告警举例,数据流是这样的:
- 后端采集服务检测到设备温度异常
-
后端调用
StateServerEndpoint.broadcast("PLANT_ALARM", payload)推送告警消息 -
前端
StateChannel收到消息,分发给注册了PLANT_ALARM的处理器 - 处理器更新左侧告警列表,弹出告警高亮
-
如果告警关联了摄像头
cameraId,前端更新activeCameraId,对应视频墙组件销毁旧流、创建新流 - 如果告警需要标记在时间轴上,ECharts 在对应时间点打上 markPoint
前端这里的联动代码很像一个“总控台”:
stateChannel.on('PLANT_ALARM', (payload, meta) => {
alarmList.value.unshift(payload);
if (payload.cameraId && payload.cameraId !== currentCameraId.value) {
currentCameraId.value = payload.cameraId;
}
chart.setOption({
series: [{
markPoint: {
data: [{
coord: [meta.timestamp, payload.value],
itemStyle: { color: '#ff4d4f' }
}]
}
}]
}, { lazyUpdate: true });
});
这套逻辑的关键点在于: 前端不主动询问“该切哪个摄像头”,而是完全由服务端的告警消息驱动 。告警来了,前端被动响应。这比前端轮询告警接口再决定切不切摄像头,响应速度快了一个量级,而且代码结构更干净。
4.3 ECharts 增量更新的性能处理
大屏图表最容易出现的问题是“消息一多就卡”。要知道 WebSocket 推一条数据,前端就
setOption
一次,如果一秒推几十条,ECharts 会频繁重绘整个图表,帧率直接掉下去。
我的做法是做两级缓冲。第一级是数据层合并,把同一类型的数据按时间戳缓存进数组,用
requestAnimationFrame
统一更新;第二级是 ECharts 本身的
lazyUpdate
参数,它会把多次
setOption
合并到下一帧渲染,避免同步重绘:
let pendingData: Array<[number, number]> = [];
let chartUpdating = false;
function pushChartData(point: [number, number]) {
pendingData.push(point);
if (!chartUpdating) {
chartUpdating = true;
requestAnimationFrame(() => {
chart.setOption({ series: [{ data: pendingData }] }, { lazyUpdate: true });
pendingData = [];
chartUpdating = false;
});
}
}
这里最重要的认知是:图表更新不需要“每条消息都立即上屏”,人眼感知不到 30ms 内的差别,但帧率从 60fps 掉到 20fps 一眼就能看出来。所以在高频率推送场景下,牺牲一点点实时性,换稳定的帧率,是划算的。
5. 延迟实测与工程落地绕不开的六个坑
5.1 端到端延迟到底花在了哪里
项目上线前我做了一轮针对性的延迟测试,统计路径是:摄像头采集 -> 编码 -> RTSP 传输 -> ZLMediaKit 网关转 WebRTC -> 浏览器解码渲染,对照大屏画面和现场时钟。
| 环节 | 参考耗时 | 说明 |
|---|---|---|
| 摄像头采集编码 | 40~120ms | H.264 硬编码延迟较低 |
| RTSP 拉流到网关 | 20~80ms | 内网直连较稳定 |
| WebRTC 网关转发 | 30~80ms | 受并发路数和 CPU 影响 |
| 浏览器 jitter buffer | 80~250ms | 默认值偏保守,可调 |
| 解码 + 渲染 | 10~30ms | 硬解延迟低 |
合计下来,整条链路实测在 300 到 700ms 之间。这个数据在园区监控大屏场景是可以接受的,肉眼几乎感觉不到画面和实际的错位。如果觉得延迟还偏高,优先排查两处:一是有没有经过转码,二是浏览器 jitter buffer 是不是默认值过大。WebRTC 内部播放缓冲有一些参数可以调,但调太低会导致网络抖动时花屏,需要权衡。
5.2 坑一:自动播放策略导致画面一直黑的真实原因
上线第一天就遇到一个诡异问题:所有视频画面加载出来但始终是黑屏,控制台没有任何报错。排查到最后发现是浏览器自动播放策略拦截了
video.play()
。
Chrome 要求网页必须先有用户交互,之后才能播放有声媒体。但我们的 video 标签已经设置了
muted
,理论上属于自动播放允许范围。问题出在
srcObject
赋值之后的
play()
调用时机:有时候
ontrack
触发时页面还没有任何用户手势,Chrome 会返回一个被拒绝的 Promise,而我们代码里只是
.catch(() => {})
吞掉了,没有在 UI 上提示。
解决方式有两个层面。第一,video 标签固定加
muted
和
playsinline
;第二,大屏启动时设计一个“进入大屏”的交互按钮,用户点击之后才创建视频播放器实例,这一步点击就是用户手势,之后的
video.play()
都能顺利执行。如果是领导直接站在大屏前要自动化演示,可以在页面加载后主动播放一个完全静音的流来“激活”音频上下文,但这个方案在 Safari 上不够稳,我还是推荐保留一个启动交互。
5.3 坑二:ICE candidate 早于 answer 到达导致连接中断
这个坑在信令联调阶段就碰到了。现象是:偶尔能出画面,偶尔一直卡在连接中,看控制台报
InvalidStateError
,定位后发现是 candidate 消息在
setRemoteDescription
之前就到达了。
原因在于信令服务端的发送顺序并不保证严格串行,尤其在网关繁忙时,offer 处理和 candidate 上报之间会出现交错。我在前面播放器封装里已经处理了这个问题:
pc.remoteDescription
不存在时,把 candidate 放进数组缓存,等 offer 处理完再补发。这个逻辑看着简单,但如果没有提前设计,故障出现时会非常迷惑。
5.4 坑三:connectionState 为 disconnected 不等于挂了
WebRTC 的
connectionState
有多个状态,容易误判的是
disconnected
。有段时间我把
disconnected
当成故障触发重连,结果网络一抖动,页面疯狂重建 PeerConnection,画面反而一直出不来。
后来调整了策略:只有
failed
才立刻重连,
disconnected
延迟 30 秒检查一次,如果超过 30 秒还没恢复再重连。因为 WebRTC 底层有自己的 ICE restart 逻辑,很多时候
disconnected
是网络抖动引起的,几秒后会自动恢复。过度干预反而会打断 ICE 的自动恢复流程。
5.5 坑四:H.265 编码摄像头在 Chrome 上无法播放
这个坑项目上线前差点没堵住。园区新增了一批 H.265 编码的摄像头,接进 ZLMediaKit 之后,播放器一直处于“正在连接”,服务端日志显示流已经推上去了,但前端始终没有 track 到达。
原因很直接:Chrome 不支持 H.265 硬解播放。WebRTC 协商时,前端只声明了自己支持的编码格式,服务端发现两者没有交集,多轮协商失败,连接就挂了。
解决方向有三种。第一种是让网关把 H.265 转码成 H.264 再推 WebRTC,最省事,但实时转码非常吃 CPU,16 路视频转码至少要上独立显卡或者专用转码服务器;第二种是更换支持 H.265 的播放内核,但这个方案在 WebRTC 场景下可选方案少,需要大量改造;第三种是采购摄像头时统一选 H.264 编码,这是最经济的做法。我最后的建议是:新项目从源头锁定 H.264,存量设备通过网关转码,同时监控 CPU 占用,超过阈值只能减少并发路数。
5.6 坑五:多路并发视频流导致内存和带宽失控
大屏同时开 16 路视频时,内存和带宽会肉眼可见地增长。每路 WebRTC 播放器都有自己的 PeerConnection,内部要维持 jitter buffer、解码器等资源。实测 16 路 720p H.264 在普通 i7 工作站上,Chrome 内存稳定在 1.2GB 左右,CPU 在 40% 上下,如果再加图表动画,机器风扇会一直高速运转。
针对这个问题我做了三件事。第一,非关键区域的视频降低分辨率,网关支持在拉流时指定码率和分辨率,不需要前端处理;第二,页面隐藏的播放器直接暂停或者销毁,通过
IntersectionObserver
判断视频区是否在可视范围内;第三,视频墙切成“多画面轮巡”模式,一次只播 4 路,每隔 30 秒滚动切换一批,这样既保住了监控的覆盖范围,又不会让浏览器同时扛 16 路解码。监控大屏的设计思路应该是“该全时显示的才全时显示”,而不是“所有摄像头都塞进一个页面”。
5.7 坑六:WebSocket 洪峰让大屏直接掉帧
告警级联发生时,服务端可能一秒推送二三十条消息。如果前端每条消息都立即操作 DOM 和图表,必然掉帧。我前面提到的
requestAnimationFrame
合并更新就是为此准备的。另外告警弹窗也不能做到无上限堆叠,我会在页面上只展示最近的 6 条告警,更早的合并成“N 条历史告警”的折叠项。这样既保证关键信息不丢,又不至于让整个页面被消息淹没。
服务端这边也要考虑削峰。如果告警本身是批量产生的,可以做一个简单的聚合推送:把 10 秒内的同类告警合并成一条,带上
count
字段,前端拿到之后更新计数而不是逐条渲染。实时性和流畅度的平衡,工程上通常靠“合并 + 限流”实现。
最后再分享两个小技巧
一个是视频流和状态通道的“动静分离”。视频流走 WebRTC,状态数据走 WebSocket,信令也走 WebSocket,但信令服务一定要单独部署或者单独路径。三个通道混在一起,延迟和稳定性都很难排查。另一个是字体和配色的经验:大屏背景用深色渐变,数据字体用工程感较强的无衬线体,预警色集中在红、橙两档,不要做太多颜色层次,否则告警高亮就不明显了。
我个人的体会是,大屏可视化项目最难的地方从来不是某个单一技术有多深,而是视频、状态、图表、交互这四条线能否在自己的节奏里协同。把 WebRTC 播放器封装成可复用组件、把 WebSocket 消息协议定义统一、把状态分发做成订阅式,这三件事做好,后面不管接多少路视频、加多少块面板,都能保持结构不乱。项目做完之后再回头看,最值得花时间的还是协议设计和边界条件处理,这两块做扎实了,上线后的半夜电话会少很多。
更多推荐

所有评论(0)