做可视化大屏最怕客户来一句“我要实时”。数据指标用定时器轮询还能凑合,可一旦牵扯到视频画面,整个技术选型都会跟着变。我最近做的园区监控大屏项目,就是把 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 状态通道混在一起。原因很简单:信令是“按需建立的短时连接”,状态通道是“全程保持的长连接”,混在一起会导致信令风暴影响业务数据推送。

信令流程基本是这样的:

  1. 前端通过 WebSocket 连接信令服务,发送 { type: 'play', streamId }
  2. 服务端向流媒体网关请求拉流,网关把 RTSP 拉起来之后,服务端通过信令通道返回 SDP offer
  3. 前端收到 offer,调用 setRemoteDescription 设置远端描述
  4. 前端调用 createAnswer 生成应答,再调用 setLocalDescription 设置本地描述
  5. 前端把 answer 回传给服务端
  6. 两端持续交换 ICE candidate
  7. 音视频轨道到达,前端把 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 从一条告警消息到联动切换的完整链路

这套联动逻辑是“状态驱动”这个说法的核心。拿一次设备告警举例,数据流是这样的:

  1. 后端采集服务检测到设备温度异常
  2. 后端调用 StateServerEndpoint.broadcast("PLANT_ALARM", payload) 推送告警消息
  3. 前端 StateChannel 收到消息,分发给注册了 PLANT_ALARM 的处理器
  4. 处理器更新左侧告警列表,弹出告警高亮
  5. 如果告警关联了摄像头 cameraId ,前端更新 activeCameraId ,对应视频墙组件销毁旧流、创建新流
  6. 如果告警需要标记在时间轴上,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 消息协议定义统一、把状态分发做成订阅式,这三件事做好,后面不管接多少路视频、加多少块面板,都能保持结构不乱。项目做完之后再回头看,最值得花时间的还是协议设计和边界条件处理,这两块做扎实了,上线后的半夜电话会少很多。

Logo

邀请您加入社区

更多推荐