20、点对点通话:建立P2P音视频通话、添加媒体流、处理ICE连接状态

好,咱们今天聊点实战的。前面几章我们把媒体流采集、信令交换这些基础打牢了,现在终于到了最激动人心的环节——让两个浏览器直接连起来,实现真正的点对点音视频通话。

说白了,P2P通话的核心就三件事:建立连接、添加流、管好状态。这三件事搞定了,一个能跑的通话demo就有了。我当年第一次调通P2P通话时,看到两个窗口里出现对方的画面,那种感觉,嗯,确实挺爽的。

一、P2P连接的本质:一个Offer,一个Answer

WebRTC的P2P连接,本质上就是一次「协商」。发起方创建一个Offer,接收方回复一个Answer。这个过程中,双方交换的是SDP(Session Description Protocol)信息。

SDP里有什么?说白了就是:

  • 你要用什么编解码器?H264还是VP8?
  • 你的音视频参数是什么?分辨率、帧率?
  • 你的网络地址在哪?IP、端口?

这些信息通过信令服务器中转。注意,信令服务器只负责「传话」,不负责「传数据」。数据走的是P2P通道。

核心流程:

  1. 发起方创建 RTCPeerConnection
  2. 发起方创建 Offer(createOffer)
  3. 发起方设置本地描述(setLocalDescription)
  4. 发起方通过信令发送 Offer 给接收方
  5. 接收方设置远端描述(setRemoteDescription)
  6. 接收方创建 Answer(createAnswer)
  7. 接收方设置本地描述
  8. 接收方通过信令发送 Answer 给发起方
  9. 发起方设置远端描述

这个流程我建议你背下来。我在项目中遇到过好几次,因为信令顺序搞反了,导致连接一直建立不起来。排查了半天,结果发现是setRemoteDescription的时机不对。

二、添加媒体流:把摄像头画面塞进通道

连接建立好了,接下来就是把采集到的媒体流加进去。这一步其实很简单,核心就一个API:addTrack

// 假设我们已经有了本地媒体流 localStream
const pc = new RTCPeerConnection(config);

// 把本地流的每个轨道都加到连接中
localStream.getTracks().forEach(track => {
  pc.addTrack(track, localStream);
});

这里有个细节:addTrack的第二个参数要传原始流对象。为什么?因为WebRTC需要知道这个轨道属于哪个流,这样接收方才能正确地把多个轨道组合成一个流。

接收方怎么拿到流?监听 ontrack 事件:

pc.ontrack = (event) => {
  // event.streams[0] 就是远程的媒体流
  const remoteStream = event.streams[0];
  remoteVideo.srcObject = remoteStream;
};

嗯,这里要注意:event.streams 是一个数组。虽然大多数情况下只有一个流,但理论上一个连接可以传输多个流。我建议你养成习惯,用 event.streams[0] 来取。

小技巧:如果你想让通话更流畅,可以在 addTrack 之前先设置一下编码参数。比如限制视频码率,避免带宽不足时画面卡顿。

三、ICE连接状态:别被状态机吓到

ICE(Interactive Connectivity Establishment)是WebRTC最核心也最复杂的部分。它的任务只有一个:找到一条能让两端通信的路径。

ICE的状态机有以下几个状态:

状态 说明
new 刚创建,还没开始收集候选地址
checking 正在收集候选地址,并尝试连接
connected 至少找到一条可用路径,但可能还在尝试其他路径
completed 所有候选地址都检查完毕,连接稳定
failed 所有路径都试过了,连不上
disconnected 曾经连上过,但现在断了
closed 连接被主动关闭

你想想看,这么多状态,我们开发者需要关注哪些?我个人习惯只关注三个:connected、failed、disconnected

pc.oniceconnectionstatechange = () => {
  const state = pc.iceConnectionState;
  console.log('ICE状态变了:', state);
  
  switch(state) {
    case 'connected':
      // 通话已建立,可以隐藏加载提示
      showCallStatus('已连接');
      break;
    case 'failed':
      // 连接失败,需要重试或提示用户
      showCallStatus('连接失败,请检查网络');
      break;
    case 'disconnected':
      // 网络波动,可以尝试重连
      showCallStatus('网络不稳定...');
      break;
  }
};

我曾经在一个项目中,因为没监听 disconnected 状态,导致用户网络闪断后,界面一直显示「通话中」,但实际上已经没声音没画面了。用户投诉说「你们这个通话怎么点不动了?」——嗯,从那以后我再也不敢忽略这个状态了。

四、ICE候选地址:STUN和TURN的故事

ICE能成功连接,靠的是候选地址(candidate)。每个候选地址代表一种可能的通信路径。常见的候选地址类型有:

  • host:本机地址,局域网内通信最快
  • srflx:通过STUN服务器获取的公网地址,适用于NAT穿透
  • relay:通过TURN服务器中转,适用于对称NAT或防火墙限制

ICE的收集过程是这样的:

// 监听候选地址收集
pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 把候选地址通过信令发送给对方
    signaling.send({
      type: 'candidate',
      candidate: event.candidate
    });
  } else {
    // 所有候选地址收集完毕
    console.log('候选地址收集完成');
  }
};

// 收到对方的候选地址
function handleRemoteCandidate(candidate) {
  pc.addIceCandidate(new RTCIceCandidate(candidate));
}

这里有个坑:addIceCandidate 必须在 setRemoteDescription 之后调用。如果顺序反了,ICE会报错。我建议你在信令处理逻辑里加一个队列,等远端描述设置完再批量添加候选地址。

避坑指南:我曾经在生产环境中遇到过一个问题——用户在内网可以通话,但外网就不行。排查后发现是STUN服务器地址配错了。记住,STUN和TURN的地址一定要在部署前测试通过,不要等到上线了才发现。

五、完整流程:一张图看懂

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

P2P音视频通话完整流程 发起方 (Caller) 信令服务器 接收方 (Callee) ① 创建Offer createOffer() ② setLocalDescription ③ 发送Offer ④ 转发Offer ⑤ setRemoteDescription ⑥ createAnswer() ⑦ setLocalDescription ⑧ 发送Answer ⑨ 转发Answer ⑩ setRemoteDescription ⑪ ICE候选地址交换(通过信令) → 建立P2P连接 → 添加媒体流

这张图把整个流程分成了11步。你仔细看,核心就是「Offer→Answer→ICE」这三个阶段。每个阶段都有对应的API调用和状态变化。

六、实战代码:一个最小可用的P2P通话

最后,我贴一个最小可用的P2P通话代码片段。这个代码去掉了信令部分,只展示核心逻辑:

// 发起方
async function startCall(localStream) {
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  });
  
  // 添加本地流
  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });
  
  // 监听远程流
  pc.ontrack = (event) => {
    remoteVideo.srcObject = event.streams[0];
  };
  
  // 监听ICE状态
  pc.oniceconnectionstatechange = () => {
    console.log('ICE:', pc.iceConnectionState);
  };
  
  // 创建Offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  
  // 通过信令发送offer(伪代码)
  signaling.send({ type: 'offer', sdp: offer });
  
  return pc;
}

// 接收方
async function handleOffer(offer, localStream) {
  const pc = new RTCPeerConnection({
    iceServers: [
      { urls: 'stun:stun.l.google.com:19302' }
    ]
  });
  
  localStream.getTracks().forEach(track => {
    pc.addTrack(track, localStream);
  });
  
  pc.ontrack = (event) => {
    remoteVideo.srcObject = event.streams[0];
  };
  
  pc.oniceconnectionstatechange = () => {
    console.log('ICE:', pc.iceConnectionState);
  };
  
  // 设置远端描述
  await pc.setRemoteDescription(new RTCSessionDescription(offer));
  
  // 创建Answer
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  
  // 通过信令发送answer(伪代码)
  signaling.send({ type: 'answer', sdp: answer });
  
  return pc;
}

这个代码虽然简单,但已经包含了P2P通话的所有核心要素。你把它跑起来,配合一个简单的信令服务器,就能看到两个浏览器互相显示对方的画面了。

最后说一句:调试P2P通话时,一定要打开浏览器开发者工具的Console和Network面板。ICE状态变化、候选地址交换、SDP信息,这些都能在控制台里看到。我每次调试都会先确认ICE状态走到了connected,再去看画面有没有出来。顺序搞反了,排查起来会很痛苦。

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