24、项目实战:视频会议系统(三):音视频通话核心逻辑(加入/离开房间、媒体流管理)、屏幕共享与录制功能

好,我们接着往下走。上一章我们把信令和房间的概念搭好了,这一章要动真格的了——把音视频流真正跑起来。说白了,就是用户点“加入房间”之后,摄像头怎么开、麦克风怎么采、别人的画面怎么显示、人走了怎么清理。这些逻辑,是视频会议最核心的部分。

我个人习惯把这一块分成三个大块:房间的生命周期管理(加入/离开)、媒体流的收发与渲染、以及屏幕共享与录制。咱们一个一个来拆。

一、加入与离开房间:PeerConnection 的建立与销毁

加入房间,本质上就是跟房间里的每一个人建立一条 PeerConnection。离开房间,就是把所有 PeerConnection 关掉,并通知其他人。

我在项目中遇到过一个问题:用户快速刷新页面,结果服务端还留着旧的 PeerConnection,导致新连接一直建不起来。后来我加了一个“离开房间”的显式清理流程,才彻底解决。

1.1 加入房间的核心流程

当用户点击“加入”按钮,我们需要做这几件事:

  1. 获取本地音视频流(getUserMedia)
  2. 创建本地 PeerConnection 工厂(RTCPeerConnection)
  3. 通过信令服务器发送“join”消息
  4. 收到服务端返回的“已有用户列表”
  5. 对每个已有用户,创建 PeerConnection,并发起 offer
  6. 监听新用户加入事件,同样创建 PeerConnection

代码骨架大概是这样:

// 加入房间
async function joinRoom(roomId) {
  // 1. 获取本地流
  localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  document.getElementById('localVideo').srcObject = localStream;

  // 2. 连接信令
  socket.emit('join', { roomId, userId: myUserId });

  // 3. 监听已有用户
  socket.on('existingUsers', (users) => {
    users.forEach(user => {
      createPeerConnection(user.userId, true); // 主动发起offer
    });
  });

  // 4. 监听新用户
  socket.on('userJoined', (user) => {
    createPeerConnection(user.userId, false); // 等待对方offer
  });
}
小提示:createPeerConnection 函数里要记得把本地流的所有 track 都 addTrack 进去。我早期只加了视频 track,结果对方听不到声音,排查了半天。

1.2 离开房间的清理工作

离开房间时,很多人只关了摄像头,但 PeerConnection 还在后台跑着。这会导致内存泄漏,而且对方会一直显示“连接中”。

正确的做法是:

  • 遍历所有 PeerConnection,调用 close()
  • 将 remoteStream 的 srcObject 置空
  • 停止本地 track(可选,但建议做)
  • 通知信令服务器:我走了
function leaveRoom() {
  // 关闭所有 PeerConnection
  peerConnections.forEach(pc => pc.close());
  peerConnections.clear();

  // 停止本地流
  localStream.getTracks().forEach(track => track.stop());
  localStream = null;

  // 清理远程视频元素
  document.querySelectorAll('.remote-video').forEach(el => el.srcObject = null);

  // 通知服务端
  socket.emit('leave', { roomId, userId: myUserId });
}
注意:我曾经遇到一个坑——离开房间后,浏览器麦克风指示灯还亮着。原因是只调了 track.stop(),但没有把 MediaStream 对象置 null。记得把 localStream 也置空。

二、媒体流管理:谁在说话?谁的视频卡了?

媒体流管理,说白了就是“谁发了什么流,我怎么展示”。在多人会议里,你可能会收到 5 个、10 个甚至更多人的音视频流。怎么管理这些流?

我建议用一个 Map 来存储:key 是 userId,value 是 { pc, stream, videoElement }。这样增删改查都很方便。

2.1 流的添加与渲染

当 PeerConnection 触发 ontrack 事件时,我们拿到对方的 MediaStream。然后把它挂到一个 video 元素上。

pc.ontrack = (event) => {
  const remoteStream = event.streams[0];
  const videoEl = document.createElement('video');
  videoEl.srcObject = remoteStream;
  videoEl.autoplay = true;
  videoEl.playsInline = true;
  document.getElementById('remoteVideos').appendChild(videoEl);

  // 存起来
  remoteStreams.set(userId, { stream: remoteStream, element: videoEl });
};

你想想看,如果房间里有 10 个人,你就得动态创建 10 个 video 标签。布局上可以用 grid 或者 flex 自动排列。我个人习惯用 CSS grid,设置每行最多 4 个,超过就换行。

2.2 音频的自动播放策略

这里有个很烦人的问题:浏览器为了用户体验,不允许自动播放带声音的视频。如果你直接把 remoteStream 挂到 video 上,可能只有画面没有声音。

解决办法:在用户第一次点击“加入房间”时,先调用一次 audioContext.resume(),或者给 video 元素加 muted 属性,等用户交互后再取消静音。

核心经验:我在做第一个版本时,用户加入房间后听不到别人说话,但录屏却有声音。查了半天,发现是 Chrome 的自动播放策略在作怪。后来我在“加入”按钮的点击事件里,先调了一个 await audioCtx.resume(),问题解决。

三、屏幕共享:把桌面变成第二个摄像头

屏幕共享,本质上就是用 getDisplayMedia 替换掉本地的视频 track。注意,音频 track 不能从屏幕共享里获取,所以我们需要单独处理。

3.1 启动屏幕共享

async function startScreenShare() {
  try {
    const screenStream = await navigator.mediaDevices.getDisplayMedia({
      video: true,
      audio: false // 屏幕共享不包含系统音频
    });

    // 替换本地视频 track
    const videoTrack = screenStream.getVideoTracks()[0];
    const sender = peerConnections.get(remoteUserId).getSenders().find(s => s.track.kind === 'video');
    await sender.replaceTrack(videoTrack);

    // 监听用户停止共享(点击浏览器自带的“停止共享”按钮)
    videoTrack.onended = () => {
      stopScreenShare();
    };
  } catch (err) {
    console.error('屏幕共享失败:', err);
  }
}

这里要注意:replaceTrack 是替换 track,而不是重新 addTrack。这样其他用户不需要重新建立连接,画面就会自动切换。

3.2 停止屏幕共享

停止共享时,我们需要把本地摄像头的 track 换回来。

async function stopScreenShare() {
  const cameraStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  const videoTrack = cameraStream.getVideoTracks()[0];
  const sender = peerConnections.get(remoteUserId).getSenders().find(s => s.track.kind === 'video');
  await sender.replaceTrack(videoTrack);

  // 停止屏幕共享流
  screenStream.getTracks().forEach(track => track.stop());
}
注意:停止屏幕共享后,一定要把旧的 screenStream 的 track 全部 stop 掉,否则摄像头灯会一直亮着。我踩过这个坑,用户反馈说“我明明关了共享,为什么摄像头还亮着?”——就是因为没 stop 旧的 track。

四、录制功能:把会议存下来

录制功能,我用的是 MediaRecorder API。它可以把 MediaStream 直接录成 blob,然后下载或者上传到服务器。

4.1 录制本地流或远程流

你可以选择录制“本地摄像头+麦克风”,也可以录制“整个会议画面”(比如把多个 remote video 合成一个 canvas 再录)。这里我讲最简单的:录制本地流。

let mediaRecorder;
let recordedChunks = [];

function startRecording(stream) {
  recordedChunks = [];
  mediaRecorder = new MediaRecorder(stream, {
    mimeType: 'video/webm; codecs=vp9' // 浏览器支持的话
  });

  mediaRecorder.ondataavailable = (event) => {
    if (event.data.size > 0) {
      recordedChunks.push(event.data);
    }
  };

  mediaRecorder.onstop = () => {
    const blob = new Blob(recordedChunks, { type: 'video/webm' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = `meeting-${Date.now()}.webm`;
    a.click();
  };

  mediaRecorder.start(1000); // 每秒生成一个 chunk
}

function stopRecording() {
  if (mediaRecorder && mediaRecorder.state !== 'inactive') {
    mediaRecorder.stop();
  }
}

4.2 录制整个会议画面

如果你想录制“画中画”效果,需要把所有 remote video 画到一个 canvas 上,然后用 canvas.captureStream() 获取合成流。

const canvas = document.getElementById('meetingCanvas');
const ctx = canvas.getContext('2d');

// 每帧绘制所有视频
function drawMeeting() {
  ctx.drawImage(localVideo, 0, 0, 200, 150);
  remoteVideos.forEach((video, index) => {
    const x = (index % 4) * 200;
    const y = Math.floor(index / 4) * 150;
    ctx.drawImage(video, x, y, 200, 150);
  });
  requestAnimationFrame(drawMeeting);
}

// 获取合成流
const mixedStream = canvas.captureStream(30); // 30fps
startRecording(mixedStream);
提示:canvas.captureStream 的性能开销不小。如果房间人数超过 6 个,建议降低帧率到 15fps,或者只录制主讲人的画面。

五、整体架构图

下面这张图,把本章的核心逻辑串起来了。你可以看到从加入房间到媒体流管理,再到屏幕共享和录制,整个流程是环环相扣的。

视频会议核心逻辑流程图 1. 加入房间 2. getUserMedia 3. 创建PeerConnection 4. 媒体流管理(ontrack) 5. 屏幕共享 6. MediaRecorder录制 7. 离开房间(清理) 核心逻辑:加入 → 获取流 → 建立连接 → 管理流 → 共享/录制 → 离开清理

六、避坑总结

最后,我把这一章容易踩的坑列一下,都是我用真金白银换来的经验:

场景 解决办法
加入房间 重复创建 PeerConnection 用 Map 缓存,先检查是否存在
离开房间 摄像头灯不灭 显式调用 track.stop() 并置空 stream
屏幕共享 停止共享后画面黑屏 用 replaceTrack 切回摄像头 track
录制 录出来的文件没有声音 确保 MediaRecorder 的 mimeType 支持音频编码

嗯,这一章的内容就到这里。代码量虽然不大,但每一行都关系到用户体验。你写的时候,多想想“如果用户网络断了怎么办”、“如果用户点了两次加入怎么办”。把这些边界情况处理好,你的视频会议系统才算真正能用。

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