Skip to content

RTC 推流后台稳定性优化:从 rAF 画布采集到帧驱动镜像轨道

更新: 7/28/2026 字数: 0 字 时长: 0 分钟

一次真实的踩坑复盘:切换标签页再切回来,RTC 推流画面出现明显闪动。本文记录问题定位、原理剖析、方案取舍与最终落地。

一、问题现象

Tech 组件进入页面后会采集摄像头或本地视频源,通过 RTC 推流给后端处理。前台运行一切正常,但只要用户切到别的标签页停留一会儿,再切回原标签页,推流画面就会明显闪动——对端看到的画面出现卡顿、跳帧,甚至短暂黑屏后恢复。

问题只在"切走再切回"时复现,纯前台常驻不出现。这是一个非常典型的**后台标签页节流(background tab throttling)**引发的连锁反应。

浏览器后台标签页节流导致 RTC 推流断帧

二、原有实现:为什么会闪

原方案的镜像推流链路是这样的:

摄像头路径

  1. getUserMedia 拿到摄像头画面渲染到 <video>
  2. requestAnimationFrame 持续把 <video> 画到 <canvas>,绘制时做水平翻转实现镜像。
  3. canvas.captureStream() 得到视频轨道,推给 RTC。

本地视频源路径

  • 同样用 requestAnimationFrame<video> 元素持续绘制到 <canvas>,再采集 canvas 流。

这个链路的致命依赖在于:推流轨道的"产帧"完全绑定在 requestAnimationFrame 这个前台渲染循环上

而浏览器对后台标签页的策略是明确的:

  • 后台标签页的 requestAnimationFrame 回调会被降频甚至完全暂停[1]
  • rAF 一停,canvas 就不再产出新帧,canvas.captureStream() 随之停帧或掉帧。
  • 切回前台时,rAF 恢复、积压的状态被冲刷、编码器重新追帧——这个"停帧→恢复"的过程在对端表现出来就是闪动

三、思考过程:这不是"阻止休眠"问题

第一反应可能是"想办法别让页面进入后台休眠",但这个思路是错的。浏览器对后台标签页的限制是平台级行为,前端无法彻底阻止。

真正要做的不是对抗平台,而是换个思路:不要把推流轨道绑定在容易被节流的前台渲染循环上。 把"产帧"这件事从 rAF 里解耦出去,让它由更稳定的机制驱动。

同时还有一条硬性业务约束不能丢:推给 RTC 的画面本身必须是镜像的,而不是只在本地预览 <video> 上用 CSS 做镜像——CSS 镜像只影响本地显示,推出去的流仍是非镜像的。

四个方案的取舍

方案能否解决后台断帧能否保留镜像推流结论
① 只加 Wake LockWake Lock 只防屏幕休眠,不解决后台 rAF 被节流
② 直接发布摄像头原始轨道绕开了 rAF,但丢了镜像要求
③ 继续用 canvas + rAF保留镜像,但后台仍会停帧
④ 摄像头帧驱动生成镜像轨道最终采用

方案 ④ 的核心:通过 MediaStreamTrackProcessor 读取摄像头原始帧,每一帧做水平翻转后写入 MediaStreamTrackGenerator。这样镜像转换是由"摄像头帧到达"驱动的,而不是由前台 rAF 驱动,天然更贴合后台稳定推流的目标。

最终决定:方案 ④ 作为摄像头主路径,保留原 canvas+rAF 作为 WebCodecs 不可用时的兜底。

四、解决方案:摄像头与本地源分开处理

新的数据管线把摄像头和本地视频源拆成两条路径。

RTC 摄像头镜像推流的帧驱动新管线

摄像头路径(主路径:帧驱动镜像)

  1. 继续通过 getUserMedia 获取原始摄像头和麦克风轨道。
  2. 原始摄像头轨道不直接推流,而是作为镜像转换的输入。
  3. MediaStreamTrackProcessor 读取原始 VideoFrame
  4. 每帧绘制到离屏 canvas,用 ctx.translate + ctx.scale(-1, 1) 做水平翻转。
  5. new VideoFrame(canvas) 创建镜像后的新帧。
  6. 写入 MediaStreamTrackGenerator,得到一条新的镜像视频轨道。
  7. 把这条镜像轨道交给 RTC 的 setExternalVideoTrack 发布。
  8. 停止采集或销毁 RTC 时,清理 reader、writer 和生成出的镜像 track。

本地视频源路径

  • 本地文件不需要镜像
  • 优先用 <video>.captureStream() 获取浏览器原生媒体轨道——让播放、解码和轨道输出尽量交给浏览器媒体管线,而不是依赖 rAF。
  • 若浏览器不支持 video captureStream,再回退到原 canvas 采集方案。

五、关键代码

摄像头镜像转换管线

ts
/**
 * 通过帧驱动生成镜像摄像头轨道
 * 关键:由 MediaStreamTrackProcessor 的帧到达驱动,而非前台 rAF
 */
async function createMirroredCameraTrack(
  originTrack: MediaStreamVideoTrack
): Promise<MediaStreamVideoTrack> {
  // 1. 读取原始摄像头帧
  const processor = new MediaStreamTrackProcessor({ track: originTrack });
  const reader = processor.readable.getReader();

  // 2. 准备镜像输出轨道
  const generator = new MediaStreamTrackGenerator({ kind: 'video' });
  const writer = generator.writable.getWriter();

  // 3. 离屏 canvas 用于水平翻转
  const { width, height } = originTrack.getSettings();
  const canvas = new OffscreenCanvas(width ?? 1280, height ?? 720);
  const ctx = canvas.getContext('2d')!;

  // 4. 帧循环:每有一帧摄像头画面到达就翻转一帧
  (async () => {
    while (true) {
      const { value: frame, done } = await reader.read();
      if (done) break;

      // 水平镜像
      ctx.save();
      ctx.translate(canvas.width, 0);
      ctx.scale(-1, 1);
      ctx.drawImage(frame, 0, 0, canvas.width, canvas.height);
      ctx.restore();

      // 用当前帧的时间戳生成镜像帧,保持时序
      const mirrored = new VideoFrame(canvas, { timestamp: frame.timestamp });
      await writer.write(mirrored);

      frame.close();     // 必须关闭原始帧,否则内存泄漏
      mirrored.close();
    }
  })();

  // 保存句柄供 cleanup 使用
  mirrorPipeline = { reader, writer, generator };
  return generator;
}

特性检测与兜底

ts
function supportsInsertableStreams(): boolean {
  return (
    typeof MediaStreamTrackProcessor !== 'undefined' &&
    typeof MediaStreamTrackGenerator !== 'undefined'
  );
}

async function startCapture() {
  const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  const [cameraTrack] = stream.getVideoTracks();

  let publishTrack: MediaStreamVideoTrack;
  if (supportsInsertableStreams()) {
    publishTrack = await createMirroredCameraTrack(cameraTrack);   // 主路径
  } else {
    publishTrack = await createMirroredTrackByCanvasRAF(cameraTrack); // 兜底
  }

  await rtcEngine.setExternalVideoTrack(publishTrack);
}

清理逻辑(防泄漏)

ts
async function cleanupMirrorPipeline() {
  if (!mirrorPipeline) return;
  const { reader, writer, generator } = mirrorPipeline;
  try {
    await reader.cancel();
    await writer.close();
    generator.stop();
  } catch (e) {
    // reader/writer 可能已被对端关闭,吞掉异常即可
  }
  mirrorPipeline = null;
}
// 在 stopCapture 和 destroy 中都要调用

六、实战踩坑清单

这一节是纯经验,踩过的坑都列在这:

  1. VideoFrame 必须手动 close()MediaStreamTrackProcessor 读出的原始帧和你 new 出来的镜像帧,用完都要 close()。漏掉会导致底层帧缓冲区迅速耗尽,几秒内画面就卡死。这是最容易忽略、后果最严重的坑。

  2. 时间戳要透传 生成镜像帧时用 { timestamp: frame.timestamp } 继承原始帧的时间戳,否则输出轨道时序错乱,对端会花屏或忽快忽慢。

  3. 本地预览不要再叠 CSS 镜像 既然推流轨道本身已经是镜像的,把这条轨道渲染到本地 <video> 就已经是镜像效果。如果再加 transform: scaleX(-1),会被翻转两次,变回非镜像。改造后务必去掉本地那层 CSS 镜像。

  4. MediaStreamTrackGenerator / MediaStreamTrackProcessor 兼容性有限 属于 Insertable Streams(WebCodecs 相关),目前主要 Chromium 系支持,Firefox/Safari 支持不完整。必须做特性检测 + canvas+rAF 兜底,不能默认可用。

  5. 这不是"彻底阻止休眠" 要说清楚边界:如果浏览器/系统把页面完全冻结(freeze)或丢弃(discard),网页本身无法保证继续执行,这种极端场景任何前端方案都救不了。本次改造针对的是常见的切标签页导致 rAF 降频/暂停场景。

  6. cleanup 要覆盖所有退出路径stopCapturedestroy、组件卸载都要调 cleanup。reader/writer/generator 任一泄漏,下次采集可能拿到脏状态或直接报错。

七、新旧方案对比

旧方案 rAF 采集与新方案帧驱动管线对比

维度旧方案(canvas + rAF)新方案(帧驱动镜像轨道)
产帧驱动源前台 requestAnimationFrame摄像头帧到达 / 浏览器媒体管线
后台标签页表现rAF 被节流 → 停帧 → 切回闪动解耦渲染循环,稳定性显著提升
镜像推流✅ 支持✅ 支持(帧级翻转)
本地视频源canvas 采集优先原生 video.captureStream()
兼容性好(canvas 通用)需 Insertable Streams,配 canvas 兜底
内存风险需严格 VideoFrame.close()

八、结论与边界

这次改动保留了摄像头镜像推流语义,同时把推流关键路径从前台渲染循环里解耦出来,显著降低了后台标签页 rAF 节流对主路径推流的影响;本地视频源也优先走原生 video.captureStream(),减少对 canvas+rAF 的依赖。

需要明确的边界:这不是"彻底阻止浏览器休眠"。页面被完全冻结或丢弃时无法保证继续执行。但对于最常见的"切换标签页导致 rAF 降频/暂停"场景,推流稳定性已经明显改善。

References

  1. 页面可见性 API - MDN