7、RTCPeerConnection:创建连接、添加轨道、创建Offer/Answer、ICE候选处理

好,咱们今天来聊聊 WebRTC 里最核心的一个对象——RTCPeerConnection。说实话,我刚开始接触 WebRTC 的时候,觉得这玩意儿就是个黑盒。你给它一些配置,它就能把音视频传过去。但实际用起来,里面的门道可不少。

这一章,我会带你手把手把整个连接流程走一遍。从创建连接,到添加轨道,再到交换 SDP 和 ICE 候选。嗯,咱们一步步来。

7.1 创建 RTCPeerConnection

创建连接的第一步,就是实例化一个 RTCPeerConnection 对象。你想想看,它就像是你和远端之间的一条虚拟网线。没有它,啥都传不了。

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);

这里有个坑,我一开始就踩过。ICE 服务器配置里,STUN 服务器是必须的,但 TURN 服务器不是。不过,如果你的用户可能处在对称 NAT 后面,那 TURN 就是救命稻草。我个人习惯是,至少配一个 Google 的公共 STUN 服务器,再配一个自己的 TURN 服务器兜底。

小提示: 生产环境中,TURN 服务器的带宽成本不低。建议只在 STUN 失败时才回退到 TURN。你可以通过监听 ICE 连接状态来判断。

7.2 添加轨道

连接建好了,接下来要往里塞数据。说白了,就是把你本地的音视频轨道加进去。

const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));

这里有个细节,addTrack 的第二个参数是流对象。为什么需要传流?因为远端收到轨道后,需要知道这些轨道属于哪个流,才能正确渲染。我在项目中遇到过,有人把同一个流里的视频和音频轨道分开添加,结果远端播放器只显示了画面,没有声音。排查了半天,才发现是流对象没传对。

另外,如果你要做屏幕共享,那就用 getDisplayMedia 替代 getUserMedia。代码几乎一样,只是用户会看到一个选择窗口。

7.3 创建 Offer 和 Answer

轨道加好了,接下来就是发起方创建 Offer,接收方回复 Answer。这个过程,说白了就是双方交换各自的媒体能力。

// 发起方
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 通过信令服务器发送 offer 给远端

// 接收方
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 通过信令服务器发送 answer 给发起方

// 发起方收到 answer
await pc.setRemoteDescription(answer);

嗯,这里要注意顺序。必须先 setLocalDescription,再发送。为什么?因为 setLocalDescription 会触发 ICE 候选的收集。如果你先发送了 Offer,再设置本地描述,ICE 候选可能还没生成,远端就收不到候选了。

警告: 千万不要在 createOffer 之后直接发送 Offer。一定要等 setLocalDescription 完成。我曾经因为这个问题,调试了整整一个下午。

7.4 ICE 候选处理

最后一步,也是最容易出问题的一步——ICE 候选交换。ICE 候选,说白了就是告诉对方,你有哪些可能的网络路径可以连接。

pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 通过信令服务器发送 candidate 给远端
    signalingServer.send({
      type: 'candidate',
      candidate: event.candidate
    });
  }
};

// 远端收到 candidate
pc.addIceCandidate(new RTCIceCandidate(candidate));

这里有个常见的误解:是不是必须把所有候选都收集完,才能开始连接?其实不是。ICE 协议是渐进式的,每收集到一个候选,就会尝试连接。所以,你收到一个候选,就立即添加一个,不用等全部收齐。

我记得有一次,用户反馈连接特别慢。排查后发现,是信令服务器做了缓存,等所有候选都收齐了才一次性发送。改成立即转发后,连接速度明显提升。

7.5 完整流程示意图

下面这张图,把整个流程串起来了。你可以对照着看,每一步在做什么。

发起方 (Offerer) 1. 创建 RTCPeerConnection 配置 ICE 服务器 2. 添加轨道 addTrack(track, stream) 3. 创建 Offer createOffer() → setLocalDescription() 4. 接收 Answer setRemoteDescription(answer) 5. 添加 ICE 候选 接收方 (Answerer) 1. 创建 RTCPeerConnection 配置 ICE 服务器 2. 接收 Offer setRemoteDescription(offer) 3. 创建 Answer createAnswer() → setLocalDescription() 4. 添加轨道 addTrack(track, stream) 5. 添加 ICE 候选 Offer (SDP) Answer (SDP) ICE 候选双向交换

7.6 状态监听与调试

连接建立后,别忘了监听状态变化。这能帮你快速定位问题。

pc.oniceconnectionstatechange = () => {
  console.log('ICE 状态:', pc.iceConnectionState);
  // 可能的值: new, checking, connected, completed, failed, disconnected, closed
};

pc.onconnectionstatechange = () => {
  console.log('连接状态:', pc.connectionState);
  // 可能的值: new, connecting, connected, disconnected, failed, closed
};

我个人习惯,在 faileddisconnected 状态时,尝试重新创建连接。不过要注意,不要频繁重试,否则会浪费带宽。一般等 2-3 秒再重试比较合理。

核心要点:
  • 创建连接时,STUN 服务器必配,TURN 服务器按需配
  • 添加轨道时,确保流对象正确传递
  • Offer/Answer 交换,必须先 setLocalDescription 再发送
  • ICE 候选要立即转发,不要缓存
  • 监听状态变化,及时处理异常

好了,这一章的内容就到这里。RTCPeerConnection 是整个 WebRTC 的基石,理解了它,后面的路就好走了。如果你在实际项目中遇到问题,不妨回头看看这几个步骤,八成能找到原因。

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