5、RTCPeerConnection:连接状态机、ICE候选者收集、Offer/Answer模型实战

好,咱们今天来啃一块硬骨头——RTCPeerConnection。说实话,这是整个WebRTC里最核心、也最容易踩坑的部分。我当年第一次接触它的时候,光ICE状态机就绕了好几天。你想想看,两个浏览器要穿越NAT、防火墙,还要协商出一套双方都支持的编解码器,这背后的事情远比表面看起来复杂。

不过别怕,咱们今天就把这块拆开揉碎了讲。我会带着你一步步走通连接状态机、ICE候选者收集,以及Offer/Answer模型。嗯,咱们开始吧。

5.1 连接状态机:从“新兵”到“老兵”的蜕变

RTCPeerConnection内部维护了一个状态机。说白了,就是它从创建到关闭,会经历几个固定的阶段。我个人习惯把状态机画在脑子里,每次调试都先看一眼当前状态,能省不少时间。

核心状态一览:

  • new:刚创建,啥也没干。
  • connecting:正在尝试建立连接。ICE开始收集候选者,DTLS握手进行中。
  • connected:连接建立成功,可以传输媒体数据了。
  • disconnected:连接暂时中断,但可能恢复。
  • failed:连接彻底挂了,无法恢复。
  • closed:连接被主动关闭。

我在项目中遇到过一个问题:用户网络突然波动,状态从connected跳到了disconnected,然后又自动恢复。当时我以为是bug,后来才发现这是WebRTC的自我保护机制——它不会立刻放弃,而是给你一个恢复的机会。

小技巧:监听onconnectionstatechange事件,实时更新UI状态。我曾经用这个事件给用户显示“连接中”、“已连接”、“网络不稳定”等提示,体验好很多。

// 监听连接状态变化
peerConnection.onconnectionstatechange = (event) => {
  console.log('连接状态:', peerConnection.connectionState);
  if (peerConnection.connectionState === 'failed') {
    // 尝试重新连接
    restartIce();
  }
};

5.2 ICE候选者收集:穿越NAT的“侦察兵”

ICE候选者,说白了就是告诉你“我能从哪些地址联系到你”。每个候选者包含一个IP地址和端口号。收集候选者的过程,就像派出一队侦察兵,去探测所有可能的通信路径。

候选者分为三种类型:

类型 说明 优先级
host 本机IP地址,比如192.168.1.100 最高
srflx 经过NAT映射后的公网地址 中等
relay 通过TURN服务器中转的地址 最低

你想想看,如果双方都在同一个局域网,host候选者就够了。但如果一方在公司内网,另一方在家里,那就需要srflx甚至relay。我曾经调试过一个跨国的视频通话,最后发现是TURN服务器配置错了,导致候选者一直收集不到relay地址。

避坑指南:我曾经因为没配置STUN/TURN服务器,导致所有候选者都是host类型,结果两个不同网络的用户死活连不上。记住,生产环境一定要配置至少一个STUN服务器,必要时加上TURN。

// 配置ICE服务器
const config = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    {
      urls: 'turn:your-turn-server.com:3478',
      username: 'user',
      credential: 'pass'
    }
  ]
};

const pc = new RTCPeerConnection(config);

// 监听候选者收集
pc.onicecandidate = (event) => {
  if (event.candidate) {
    console.log('发现候选者:', event.candidate.candidate);
  } else {
    console.log('候选者收集完毕');
  }
};

5.3 Offer/Answer模型:一场“你情我愿”的协商

Offer/Answer模型,说白了就是双方坐下来谈条件。发起方说“我想用VP8编码,分辨率720p”,接收方说“我支持VP8,但只能到480p”。最后双方达成一致,连接建立。

这个过程分为三步:

  1. 创建Offer:发起方调用createOffer(),生成一个SDP描述。
  2. 设置本地描述:发起方调用setLocalDescription(),把自己的SDP存起来。
  3. 发送给远端:通过信令服务器把SDP传给接收方。
  4. 接收方设置远端描述:接收方调用setRemoteDescription()
  5. 创建Answer:接收方调用createAnswer(),生成应答SDP。
  6. 设置本地描述:接收方调用setLocalDescription()
  7. 发送给发起方:通过信令服务器把Answer传回去。
  8. 发起方设置远端描述:发起方调用setRemoteDescription()

嗯,这里要注意:顺序不能乱。我见过有人先设置远端描述再创建Offer,结果状态机直接报错。记住,Offer/Answer是一对一的,不能多也不能少。

// 发起方:创建Offer
async function createOffer(pc) {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  // 通过信令发送给远端
  signaling.send({ type: 'offer', sdp: offer.sdp });
}

// 接收方:处理Offer并创建Answer
async function handleOffer(pc, offerSdp) {
  await pc.setRemoteDescription(new RTCSessionDescription({ type: 'offer', sdp: offerSdp }));
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  // 通过信令发送给发起方
  signaling.send({ type: 'answer', sdp: answer.sdp });
}

5.4 实战:完整连接流程

咱们把上面三个知识点串起来,写一个完整的连接示例。这个例子我用了很多次,基本能覆盖90%的场景。

// 完整连接流程
async function startConnection() {
  const config = {
    iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
  };
  const pc = new RTCPeerConnection(config);

  // 监听ICE候选者
  pc.onicecandidate = (event) => {
    if (event.candidate) {
      signaling.send({ type: 'candidate', candidate: event.candidate });
    }
  };

  // 监听连接状态
  pc.onconnectionstatechange = () => {
    console.log('状态:', pc.connectionState);
  };

  // 添加本地媒体流
  const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
  stream.getTracks().forEach(track => pc.addTrack(track, stream));

  // 创建Offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  signaling.send({ type: 'offer', sdp: offer.sdp });
}

个人经验:我习惯在onicecandidate里加一个防抖逻辑,避免候选者太多导致信令服务器压力过大。尤其是relay候选者,有时候会一下子冒出好几个。

5.5 知识体系总览

下面这张图是我自己画的,把RTCPeerConnection的核心逻辑串了起来。你保存下来,以后调试的时候对照着看,思路会清晰很多。

RTCPeerConnection 核心流程 连接状态机 new → connecting connecting → connected connected → disconnected disconnected → failed failed / closed ICE候选者收集 host 候选者 srflx 候选者 relay 候选者 候选者排序与连通性检查 Offer/Answer模型 createOffer() setLocalDescription() 信令传输 createAnswer() setRemoteDescription() 三者协同工作,完成P2P连接建立 状态机驱动流程,ICE提供路径,Offer/Answer协商参数

好了,这一章的内容就到这儿。你如果能把状态机、ICE候选者、Offer/Answer模型这三块吃透,RTCPeerConnection对你来说就不再是黑盒了。下次遇到连接问题,先看状态机到哪一步了,再检查候选者有没有收集到,最后确认Offer/Answer有没有正确交换——三步排查法,屡试不爽。


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