16、点对点视频通话实战:完整的一对一视频通话流程、本地预览与远端显示、视频窗口布局

好,到了这一章,咱们终于要真刀真枪地写一个完整的点对点视频通话了。

前面几章我们聊了信令、ICE、SDP、媒体协商这些概念。说实话,光看理论容易晕。我自己刚学WebRTC那会儿,也是看了好几遍流程图,但一动手写代码就卡壳。

这一章,我们就来打通最后一公里。把本地摄像头打开,把画面传给对方,再把对方的画面显示出来。嗯,说白了就是——你看到我,我看到你。

16.1 整体流程回顾

先理一下思路。一个完整的点对点视频通话,核心就三步:

  • 本地预览:打开摄像头,把画面显示在自己的页面上。
  • 媒体协商:通过信令服务器交换SDP和ICE候选。
  • 远端显示:把接收到的媒体流渲染到对方的视频标签里。

我习惯把这三步拆成两个阶段:

  1. 本地准备阶段:获取用户媒体设备,创建本地流,开始预览。
  2. 连接建立阶段:创建RTCPeerConnection,添加流,触发协商,等待连接。

你想想看,如果本地摄像头都没打开,后面的一切都是白搭。所以第一步,我们先搞定本地预览。

核心要点:本地预览用的是 getUserMedia,远端显示用的是 RTCPeerConnectionontrack 回调。两者最终都绑定到 <video> 标签上。

16.2 本地预览:打开摄像头

获取本地媒体流,代码其实就几行。但坑不少。

// 获取本地视频流
async function startLocalPreview() {
  try {
    const stream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    const localVideo = document.getElementById('localVideo');
    localVideo.srcObject = stream;
    return stream;
  } catch (err) {
    console.error('获取媒体设备失败:', err);
    // 我曾经遇到过用户拒绝授权的情况,页面直接白屏
    // 所以一定要加错误处理
    alert('请允许使用摄像头和麦克风');
  }
}

这里有个细节:srcObject 直接赋值为 stream,不要用 src 属性。我见过有人写成 localVideo.src = URL.createObjectURL(stream),这在老版本浏览器里能用,但现在不推荐了。

小技巧:如果你只想预览,不想让音频回放造成啸叫,可以把本地视频标签的 muted 属性设为 true。嗯,这个我踩过坑,第一次测试时差点被自己的回声震聋。

16.3 创建 PeerConnection 并添加流

本地预览搞定后,就该创建连接了。这里我习惯把创建 RTCPeerConnection 封装成一个函数,方便复用。

function createPeerConnection(localStream) {
  const config = {
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  };
  const pc = new RTCPeerConnection(config);

  // 添加本地流
  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });

  // 监听远端流
  pc.ontrack = (event) => {
    const remoteVideo = document.getElementById('remoteVideo');
    if (remoteVideo.srcObject !== event.streams[0]) {
      remoteVideo.srcObject = event.streams[0];
      console.log('收到远端流');
    }
  };

  // 监听ICE候选
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      // 通过信令发送给远端
      sendMessage({ type: 'candidate', candidate: event.candidate });
    }
  };

  return pc;
}

注意 ontrack 这个回调。它是在收到远端媒体流时触发的。我刚开始做的时候,以为要手动去拿流,其实不用。WebRTC 框架会自动把流送过来,你只需要把 event.streams[0] 赋值给视频标签就行。

注意:一定要在 addTrack 之后才触发协商。否则你创建的 Offer 里可能没有媒体信息,导致协商失败。我曾经因为这个排查了半天,最后发现是调用顺序错了。

16.4 发起呼叫与应答

发起方创建 Offer,接收方创建 Answer。这个流程我们之前讲过,这里直接上代码。

// 发起方:创建 Offer
async function call() {
  const pc = createPeerConnection(localStream);
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  sendMessage({ type: 'offer', sdp: offer });
}

// 接收方:收到 Offer 后创建 Answer
async function handleOffer(offer) {
  const pc = createPeerConnection(localStream);
  await pc.setRemoteDescription(new RTCSessionDescription(offer));
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  sendMessage({ type: 'answer', sdp: answer });
}

这里有个关键点:setLocalDescriptionsetRemoteDescription 的顺序不能乱。发起方先设本地,再设远端;接收方先设远端,再设本地。说白了,就是谁先发起,谁先设本地。

16.5 视频窗口布局

技术流程走完了,咱们聊聊 UI。一个视频通话页面,至少要有两个视频窗口:本地预览和远端显示。

我常用的布局方案有两种:

布局方式 适用场景 特点
左右并排 桌面端、大屏 双方画面等大,适合正式会议
画中画 移动端、小屏 远端全屏,本地小窗悬浮

我个人比较推荐画中画布局。因为用户更关注对方的反应,自己的画面只需要偶尔瞄一眼确认摄像头没关就行。

下面是一个简单的画中画布局 CSS 示例:

.video-container {
  position: relative;
  width: 100%;
  max-width: 800px;
  margin: 0 auto;
}

#remoteVideo {
  width: 100%;
  height: auto;
  border-radius: 8px;
  background: #000;
}

#localVideo {
  position: absolute;
  bottom: 16px;
  right: 16px;
  width: 160px;
  height: 120px;
  border-radius: 8px;
  border: 2px solid #fff;
  object-fit: cover;
  background: #333;
}

嗯,这里要注意:本地视频一般要设置 object-fit: cover,防止画面变形。我见过有人没设这个,结果本地画面被拉伸得跟哈哈镜似的。

16.6 完整流程的 SVG 图

说了这么多,咱们用一张图把整个流程串起来。

点对点视频通话完整流程 用户A(发起方) 1. getUserMedia 2. 创建 PeerConnection 3. 创建 Offer 4. 设置本地 SDP 5. 接收 Answer 信令服务器 转发 Offer / Answer 转发 ICE 候选 WebSocket 通信 用户B(接收方) 1. getUserMedia 2. 创建 PeerConnection 3. 接收 Offer 4. 创建 Answer 5. 设置远端 SDP Offer 转发 Offer Answer 转发 Answer 最终:双方建立 P2P 连接,视频流直接传输

16.7 避坑指南

最后,分享几个我实际项目中遇到的坑:

  • 摄像头权限被拒绝:一定要在 getUserMedia 的 catch 里做提示,不要静默失败。
  • 视频标签不显示:检查 srcObject 是否赋值成功,以及视频标签有没有 autoplayplaysinline 属性。
  • 远端画面卡顿:可能是 ICE 候选没交换完整。我建议在 oniceconnectionstatechange 里打印状态,方便排查。
  • 本地画面镜像:如果你想让本地预览看起来像镜子,可以给视频标签加 transform: scaleX(-1)。但注意,这个只影响显示,不影响发送出去的流。

个人习惯:我一般会在页面加载完成后,先调用一次 getUserMedia 做预览,等用户点击「开始通话」时再创建 PeerConnection。这样用户体验更流畅,不会出现点击后等半天才看到画面的情况。

好了,这一章的内容就到这里。代码不多,但每一步都关键。你只要把本地预览、PeerConnection 创建、媒体协商、视频渲染这四个环节串起来,一个完整的点对点视频通话就成型了。

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