低性能设备下多路WebSocket实时视频卡顿与黑屏闪烁问题
在某个前端项目中需要同时展示多个摄像头的实时画面。前端通过WebSocket接收摄像头推送的JPEG二进制数据再将接收到的数据转换为图片最终渲染到页面上。项目初期采用比较常规的处理方式为每个摄像头创建一条WebSocket连接接收到消息后将二进制数据转换为图片再更新对应的页面内容。在性能较好的电脑上这种方案基本能够满足需求。但是当项目部署到性能较弱的设备上时问题逐渐出现。主要表现为两个方面1. 实时画面卡顿。 同时展示多个摄像头时页面明显掉帧部分画面更新不及时操作页面时也会出现响应迟缓的情况。2. 画面黑屏闪烁。 部分摄像头画面偶尔突然变黑随后又恢复正常。为了排查问题我们首先尝试降低视频分辨率将画面调整到720p但卡顿现象依然存在部分画面甚至仍然出现黑屏闪烁。正常情况下浏览器中的 JavaScript 主要运行在主线程中但当多个摄像头同时推送数据时主线程不仅需要处理 WebSocket 消息还需要执行数据转换、图片解码、页面更新以及其他业务逻辑如果这些操作集中在主线程执行就容易造成任务堆积。首先尝试的是使用Web Worker将部分数据处理任务转移到独立线程中执行。在初始化阶段根据摄像头数量创建对应的Worker并将每条WebSocket的数据处理任务分配给相应的Worker在Worker中处理接收的数据通过postMessage将处理后的数据发送给主线程。但是实际测试后发现虽然引入了 Worker页面仍然存在明显卡顿。继续排查后发现Worker与主线程之间通常通过postMessage进行通信。对于普通对象、数组以及普通二进制缓冲区postMessage会根据数据类型采用结构化克隆或可转移对象等不同机制在高频视频数据处理中频繁传递普通数组、封装后的对象或者需要复制的二进制数据仍然可能产生明显的额外开销。在进一步排查后我们决定使用SharedArrayBuffer作为多路视频数据的公共缓存。SharedArrayBuffer是JavaScript提供的一种共享内存机制这里的内存并不是另外开辟了一种独立于计算机物理内存之外的特殊内存它是浏览器JavaScript环境提供的共享内存对象底层数据仍然由系统内存承载只是允许满足条件的多个执行上下文访问同一份数据。首先在主线程中统一创建公共缓存。const CAMERA_COUNT 8;// 每个摄像头预留两个缓存槽位const SLOT_COUNT 2;// 单个缓存槽位的最大容量512 KiBconst SLOT_SIZE 512 * 1024;// 每个摄像头的元数据使用 4 个 Int32const META_SIZE 4 * Int32Array.BYTES_PER_ELEMENT;// 元数据区域总大小const META_TOTAL_SIZE CAMERA_COUNT * META_SIZE;// 图片数据区域总大小const DATA_TOTAL_SIZE CAMERA_COUNT * SLOT_COUNT * SLOT_SIZE;// 共享内存总大小const TOTAL_SIZE META_TOTAL_SIZE DATA_TOTAL_SIZE;const sharedBuffer new SharedArrayBuffer(TOTAL_SIZE);// 元数据视图const metadata new Int32Array(sharedBuffer,0,CAMERA_COUNT * 4);创建共享内存后将同一个 SharedArrayBuffer 传递给各个 Worker。const workers [];for (let i 0; i CAMERA_COUNT; i) {const worker new Worker(./workers/camera-worker.js);worker.postMessage({type: init,cameraIndex: i,sharedBuffer,slotSize: SLOT_SIZE,slotCount: SLOT_COUNT,metadataSize: META_SIZE,metadataTotalSize: META_TOTAL_SIZE});workers.push(worker);}这里需要注意几个问题。第一所有 Worker 必须使用一致的缓存布局否则就可能出现写入位置和读取位置不一致的问题。第二SharedArrayBuffer 通过 postMessage 传递时不需要像普通 ArrayBuffer 那样转移所有权。接收方获得的是访问同一份底层共享数据的对象。第三传递共享内存对象本身并不意味着后续所有数据操作都没有开销。真正的收益在于图片数据可以直接写入共享内存不需要每次都复制整张图片并通过消息发送。创建好共享内存后下一步就是实现数据写入。这里假设每个Worker负责一条摄像头连接主线程已经将共享内存和对应摄像头的索引传入Worker。Worker收到图片数据后将二进制数据写入对应的缓存槽位。let sharedBuffer;let metadata;let cameraIndex;let slotSize;let slotCount;let metadataTotalSize;self.onmessage (event) {const data event.data;if (data.type ! init) {return;}sharedBuffer data.sharedBuffer;cameraIndex data.cameraIndex;slotSize data.slotSize;slotCount data.slotCount;metadataTotalSize data.metadataTotalSize;metadata new Int32Array(sharedBuffer,0,data.metadataSize / 4 * 0 8 * 4);};function writeFrame(arrayBuffer) {const bytes new Uint8Array(arrayBuffer);if (bytes.byteLength slotSize) {return;}const slotIndex 0;const cameraDataOffset metadataTotalSize (cameraIndex * slotCount slotIndex) * slotSize;const target new Uint8Array(sharedBuffer,cameraDataOffset,slotSize);target.set(bytes);const metaOffset cameraIndex * 4;Atomics.store(metadata, metaOffset, bytes.byteLength);Atomics.store(metadata, metaOffset 1, slotIndex);Atomics.store(metadata, metaOffset 2, 1);}这里根据实际业务进行调整只是作为一个思路。Worker 完成数据写入后主线程需要读取最新的有效图片。function renderLoop() {for (let i 0; i CAMERA_COUNT; i) {// 获取当前摄像头的最新有效数据// 校验缓存状态和图片长度// 读取完整 JPEG 数据// 更新对应摄像头画面}requestAnimationFrame(renderLoop);}requestAnimationFrame(renderLoop);第二个问题为什么摄像头画面会黑屏闪烁解决卡顿问题后还需要处理另一个现象部分摄像头偶尔出现黑屏随后又恢复正常。在排查过程中我们发现某些情况下接收到的二进制JPEG数据缺少结尾标记导致图片无法正常解码。标准 JPEG 文件通常以以下标记开头起始标记 SOIFF D8结束标记 EOIFF D9如果收到的数据以FF D8开头却没有以FF D9结束那么它可能是一张尚未完整接收的JPEG图片。如果直接将这部分数据转换为Blob再交给浏览器解码就可能出现解码失败。当业务逻辑又将解码失败的结果更新到页面时就可能出现黑屏闪烁。最基本的方法是检查数据的起始和结束标记。function isCompleteJpeg(buffer) {if (!buffer || buffer.byteLength 4) {return false;}const bytes new Uint8Array(buffer);const length bytes.length;const hasStart bytes[0] 0xFF bytes[1] 0xD8;const hasEnd bytes[length - 2] 0xFF bytes[length - 1] 0xD9;return hasStart hasEnd;}使用时if (!isCompleteJpeg(buffer)) {console.warn(JPEG 数据不完整跳过当前帧);return;}这可以过滤一部分明显不完整的数据。这只是在实际项目中遇到的排查黑屏闪烁问题时的一个思路实际即使发现接收数据不完整或损坏也不能直接return在f12排查过socket在传递过来时数据就有问题时就应该找后端排查哪里出现问题来解决。还有一些注意事项在浏览器中使用SharedArrayBuffer时页面通常需要运行在安全上下文中也就是必须要在https情况下才可以运行需要设置以下响应头Cross-Origin-Opener-Policy: same-originCross-Origin-Embedder-Policy: require-corp另外在vue项目中Worker文件也需要放在指定位置否则无法生效。项目根目录/├── public/│ ├── index.html│ └── workers/│ ├── socket-worker.js│ └── processor-worker.js├── src/│ ├── components/│ ├── views/│ ├── utils/│ ├── App.vue│ └── main.js├── package.json└── vue.config.js