22、WebRTC内存管理:MediaStream与Track的生命周期、避免内存泄漏、大分辨率场景的内存控制

内存管理这件事,在WebRTC里经常被忽视。很多人觉得JavaScript有垃圾回收,不用操心。但做实时通信的都知道,视频帧一多,内存涨起来比谁都快。我见过不少项目,跑1080p通话半小时后,页面直接崩了。嗯,今天我们就来聊聊这个。

MediaStream与Track的生命周期

先搞清楚一个基本问题:MediaStream和MediaStreamTrack到底什么时候该销毁?

说白了,MediaStream只是一个容器。它里面装着Track,Track才是真正干活的东西。你调用getUserMedia拿到一个流,这个流里可能有一个视频Track和一个音频Track。当你把流传给RTCPeerConnection时,底层其实是在消费Track的数据。

生命周期管理的关键点有三个:

  • Track的停止:调用track.stop()会释放摄像头/麦克风资源。但注意,这不会立即释放内存中的视频帧缓冲区。
  • 流的释放stream.getTracks().forEach(t => t.stop())是标准做法。但流对象本身还在内存里,直到没有引用。
  • PeerConnection的解绑:从RTCPeerConnection中移除Track后,内部编码器、缓冲区才会逐步释放。

核心原则:Track的生命周期由谁创建谁负责。本地Track由你创建,你必须手动stop。远端Track由PeerConnection创建,你只需要在连接关闭时清理引用。

我在项目中遇到过一个问题:用户切换摄像头时,旧的Track没有stop,结果摄像头一直被占用。新设备拿不到权限。嗯,这个坑很多人踩过。

避免内存泄漏的实战技巧

内存泄漏在WebRTC里很隐蔽。你想想看,一个视频通话持续一小时,如果每帧泄漏几个字节,累积起来就是灾难。我总结了几条铁律:

1. 及时释放MediaStream引用

很多人把MediaStream存在全局变量里,通话结束后忘了清空。正确的做法是:

// 错误示范
let localStream;
async function startCall() {
  localStream = await navigator.mediaDevices.getUserMedia({ video: true });
  // ... 通话结束后,localStream 一直存在
}

// 正确做法
async function startCall() {
  const stream = await navigator.mediaDevices.getUserMedia({ video: true });
  // 使用 stream
  // 通话结束时
  stream.getTracks().forEach(t => t.stop());
  // 让 stream 离开作用域
}

2. 清理RTCPeerConnection中的Track

当你调用pc.removeTrack(sender)时,只是从连接中移除了发送器。但Track本身还在。你需要手动stop:

function cleanupConnection(pc) {
  pc.getSenders().forEach(sender => {
    if (sender.track) {
      sender.track.stop();
    }
  });
  pc.close();
}

警告:不要依赖垃圾回收。JavaScript的GC不会帮你释放摄像头硬件资源。Track.stop()是必须调用的。

3. 避免闭包导致的隐式引用

我曾经调试过一个诡异的内存泄漏:一个ontrack回调里引用了外部变量,导致整个MediaStream无法被回收。代码大概长这样:

// 泄漏版本
function setupCall() {
  const largeBuffer = new ArrayBuffer(1024 * 1024 * 10); // 10MB
  pc.ontrack = (event) => {
    // 这里引用了 largeBuffer
    console.log(largeBuffer.byteLength);
    // event.streams 也被闭包捕获了
  };
}

// 修复版本
function setupCall() {
  pc.ontrack = (event) => {
    // 只处理 event,不引用外部大对象
    handleTrack(event);
  };
}

function handleTrack(event) {
  // 处理完后,event 自然释放
}

大分辨率场景的内存控制

4K视频通话?嗯,这个场景我踩过最深的坑。一帧4K未压缩的RGBA数据有多大?3840 × 2160 × 4 = 约33MB。如果帧率30fps,每秒就是1GB的数据量。浏览器扛不住的。

解决方案分几个层面:

1. 分辨率降级策略

不要直接采集4K。用getUserMedia的约束来控制:

const constraints = {
  video: {
    width: { ideal: 1920, max: 1920 },
    height: { ideal: 1080, max: 1080 },
    frameRate: { ideal: 30, max: 30 }
  }
};

如果必须处理4K输入(比如屏幕共享),那就用Canvas降采样:

function downsampleFrame(video, canvas, ctx) {
  // 把4K视频帧绘制到1080p的canvas上
  ctx.drawImage(video, 0, 0, 1920, 1080);
  // 从canvas获取降采样后的帧
  return canvas.captureStream(30);
}

2. 帧缓冲池管理

大分辨率场景下,频繁创建和销毁ImageDataVideoFrame对象会导致GC压力。我习惯用对象池:

class FrameBufferPool {
  constructor(size, width, height) {
    this.pool = [];
    for (let i = 0; i < size; i++) {
      this.pool.push(new ImageData(width, height));
    }
  }

  acquire() {
    return this.pool.pop() || null;
  }

  release(frame) {
    if (this.pool.length < 10) {
      this.pool.push(frame);
    }
  }
}

技巧:对于VideoFrame(WebCodecs API),记得调用close()方法。它不会自动被GC回收。我曾经因为忘记close,导致一个4K视频处理应用内存涨到2GB。

3. 编码器参数调优

大分辨率下,编码器的内存占用是大头。以H.264为例:

分辨率 编码器内存(约) 建议码率
640×480 50-80MB 500kbps
1280×720 150-200MB 1.5Mbps
1920×1080 300-400MB 3-5Mbps
3840×2160 800MB-1.2GB 15-20Mbps

注意,这是编码器内部缓冲区的占用。如果你同时开多个编码器(比如Simulcast),内存会线性增长。

核心知识体系

下面这张图总结了本章的内存管理脉络:

WebRTC内存管理核心体系 生命周期管理 内存泄漏防护 大分辨率控制 Track.stop() 流引用释放 PC解绑 全局变量 闭包引用 sender.track 降采样 对象池 编码器调优 核心原则:谁创建谁销毁,及时stop,控制分辨率

实战中的避坑指南

最后分享几个我踩过的坑:

  • 不要重复调用getUserMedia:每次调用都会创建新的MediaStream。我曾经见过一个代码,每次切换摄像头都重新调用,结果内存里堆了十几个流。正确的做法是复用同一个流,只替换Track。
  • 注意Safari的特殊行为:Safari对MediaStream的引用计数和Chrome不同。在Safari上,即使你调用了stop(),流对象也可能不会立即释放。我建议在Safari上额外设置stream = null
  • 大分辨率下的Canvas内存:如果你用Canvas做处理,注意Canvas本身也会占用显存。4K Canvas大约占用33MB显存。多个Canvas叠加,GPU内存会先爆。

一句话总结:WebRTC内存管理没有银弹。核心就是三点——及时stop、控制分辨率、避免隐式引用。做到这三点,大部分内存问题都能避免。

公众号:蓝海资料掘金营,微信deep3321