8、建立P2P连接实战:完整的1对1通话流程

好,前面我们把信令、SDP、ICE这些概念都拆开讲了一遍。现在到了真正动手的时候——把所有这些拼起来,跑通一个完整的1对1通话。

说实话,我第一次做这个的时候也紧张。总觉得这么多环节,哪个断了都不行。但后来发现,只要理清流程,一步步来,其实没那么玄乎。今天我就带你走一遍完整的实战流程。

核心流程一句话概括: 信令交换元数据 → SDP协商能力 → ICE收集候选 → 连通性检测 → 媒体流传输。

8.1 完整的信令交换流程

信令是P2P连接的"握手"阶段。它不传音视频,只传控制信息。我习惯把信令分成四个步骤:

  1. 发起方创建Offer — 把自己的SDP打包发出去
  2. 接收方创建Answer — 收到Offer后,回复自己的SDP
  3. 双方交换ICE候选 — 告诉对方自己能用的网络路径
  4. 连接建立 — 双方选出一条最优路径,开始传数据

你想想看,这就像两个人约见面。先发消息说"我在哪"(Offer),对方回"我也在"(Answer),然后互相说"你可以走这条路来找我"(ICE候选)。最后选一条最快的路见面。

下面是一个典型的信令交互代码框架。注意,这里我用的是WebSocket做信令通道——这是最常用的方式。

// 发起方(Caller)
const pc = new RTCPeerConnection(config);

// 1. 创建Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);

// 2. 通过信令通道发送Offer
ws.send(JSON.stringify({
  type: 'offer',
  sdp: pc.localDescription
}));

// 3. 接收Answer
ws.onmessage = async (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'answer') {
    await pc.setRemoteDescription(new RTCSessionDescription(msg));
  }
};

// 4. 接收ICE候选
ws.onmessage = async (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'candidate') {
    await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
  }
};

我的经验: 信令通道一定要做消息去重和顺序保证。我在项目中遇到过两次Offer同时发出的情况,结果双方都卡住了。后来加了个简单的状态机,才彻底解决。

8.2 SDP的创建与交换

SDP说白了就是一份"能力清单"。它告诉对方:我能编解码什么格式、支持什么分辨率、用哪个端口收数据。

创建SDP时,有两点我特别提醒你注意:

  • createOffer/createAnswer 必须在获取媒体流之后调用 — 否则SDP里没有媒体信息
  • setLocalDescription 必须在发送之前调用 — 这是WebRTC内部状态机的要求

来看一个完整的SDP交换示例:

// 获取本地媒体流
const stream = await navigator.mediaDevices.getUserMedia({
  video: true,
  audio: true
});

stream.getTracks().forEach(track => pc.addTrack(track, stream));

// 创建并发送Offer
pc.createOffer()
  .then(offer => pc.setLocalDescription(offer))
  .then(() => {
    // 此时pc.localDescription就是完整的SDP
    ws.send(JSON.stringify({
      type: 'offer',
      sdp: pc.localDescription.sdp
    }));
  })
  .catch(err => console.error('创建Offer失败:', err));

// 接收Answer
ws.onmessage = async (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'answer') {
    const answer = new RTCSessionDescription({
      type: 'answer',
      sdp: msg.sdp
    });
    await pc.setRemoteDescription(answer);
    console.log('远程SDP设置成功');
  }
};

注意: 千万不要在setLocalDescription之前发送SDP。我曾经犯过这个错,结果对方收到的SDP里ice-ufrag字段是空的,ICE协商直接失败。排查了半天才发现是顺序问题。

8.3 ICE候选的收集与交换

ICE候选收集,是WebRTC里最"神奇"的一步。它要找出所有可能的网络路径:本机地址、NAT映射地址、中继服务器地址。

ICE候选的收集是异步的。你调用pc.createOffer()之后,浏览器会慢慢收集候选,然后通过onicecandidate事件告诉你。

// 监听ICE候选事件
pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 把候选发送给对方
    ws.send(JSON.stringify({
      type: 'candidate',
      candidate: event.candidate
    }));
    console.log('发送ICE候选:', event.candidate.candidate);
  } else {
    // candidate为null表示收集完毕
    console.log('所有ICE候选已收集完毕');
  }
};

// 接收对方的ICE候选
ws.onmessage = async (event) => {
  const msg = JSON.parse(event.data);
  if (msg.type === 'candidate' && msg.candidate) {
    try {
      await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
      console.log('添加远程ICE候选成功');
    } catch (err) {
      console.error('添加ICE候选失败:', err);
    }
  }
};

这里有个细节:onicecandidate事件可能会触发很多次。每次触发,你都要把候选发出去。直到收到一个candidate为null的事件,才表示收集完毕。

避坑指南: 我曾经遇到一个问题——ICE候选发得太快,对方还没设置远程SDP就收到了候选。结果addIceCandidate直接报错。解决方案很简单:在收到Answer并设置远程SDP之后,再开始处理收到的候选。或者用队列缓存一下。

8.4 连接状态监控

连接建立之后,不代表就万事大吉了。网络随时可能变化,你需要实时监控连接状态。

WebRTC提供了几个关键事件,我建议你一定要监听:

事件 触发时机 我的处理方式
connectionstatechange 连接状态变化时 显示"已连接/已断开"提示
iceconnectionstatechange ICE连接状态变化时 尝试重新连接或切换中继
icecandidateerror ICE候选收集失败时 记录错误日志,提示用户检查网络
track 收到远程媒体流时 绑定到video/audio元素播放

来看一个完整的状态监控实现:

// 连接状态变化
pc.onconnectionstatechange = () => {
  console.log('连接状态:', pc.connectionState);
  switch(pc.connectionState) {
    case 'connected':
      showStatus('已连接', 'green');
      break;
    case 'disconnected':
      showStatus('连接断开,尝试重连...', 'yellow');
      break;
    case 'failed':
      showStatus('连接失败', 'red');
      restartConnection(); // 尝试重新建立连接
      break;
    case 'closed':
      showStatus('连接已关闭', 'gray');
      break;
  }
};

// ICE连接状态
pc.oniceconnectionstatechange = () => {
  console.log('ICE状态:', pc.iceConnectionState);
  if (pc.iceConnectionState === 'failed') {
    // ICE失败,尝试用中继服务器
    console.warn('ICE连接失败,尝试中继模式');
    // 这里可以重新配置ICE服务器并重建连接
  }
};

// ICE候选收集错误
pc.onicecandidateerror = (event) => {
  console.error('ICE候选错误:', event.errorText);
  // 记录错误,用于后续排查
  logError('ICE候选错误', event);
};

我的习惯: 我会在界面上放一个连接状态指示灯。绿色表示正常,黄色表示不稳定,红色表示断开。用户一看就知道当前通话质量如何。这个小细节,用户体验提升很明显。

8.5 错误处理与重连机制

P2P连接不可能永远稳定。网络切换、NAT超时、服务器宕机,都可能导致连接中断。所以,错误处理和重连机制是必须的。

我总结了一套"三步走"的错误处理策略:

  1. 检测到断开 — 先别慌,可能是短暂的网络抖动
  2. 等待3秒 — 如果自动恢复,什么都不用做
  3. 主动重连 — 如果3秒后还没恢复,重新走一遍信令流程
let reconnectTimer = null;
const RECONNECT_DELAY = 3000;

pc.oniceconnectionstatechange = () => {
  if (pc.iceConnectionState === 'disconnected') {
    // 启动重连计时器
    if (!reconnectTimer) {
      reconnectTimer = setTimeout(() => {
        console.log('尝试重新建立连接...');
        restartConnection();
        reconnectTimer = null;
      }, RECONNECT_DELAY);
    }
  } else if (pc.iceConnectionState === 'connected') {
    // 连接恢复,取消重连
    if (reconnectTimer) {
      clearTimeout(reconnectTimer);
      reconnectTimer = null;
    }
  }
};

async function restartConnection() {
  // 关闭旧连接
  pc.close();
  
  // 重新创建RTCPeerConnection
  pc = new RTCPeerConnection(config);
  
  // 重新绑定事件
  setupEventHandlers(pc);
  
  // 重新添加媒体流
  stream.getTracks().forEach(track => pc.addTrack(track, stream));
  
  // 重新发起信令交换
  await startSignaling();
}

注意: 重连时一定要关闭旧的RTCPeerConnection对象。否则浏览器会保留旧的连接状态,导致新连接无法建立。我见过有人忘了这一步,结果内存里堆了十几个连接对象,浏览器直接卡死。

8.6 完整流程SVG图

下面这张图,把整个P2P连接流程串起来了。我建议你对照着代码看,会清晰很多。

P2P连接完整流程 发起方 (Caller) 信令服务器 接收方 (Callee) ① 创建Offer ② Offer ③ 转发Offer ④ 创建Answer ⑤ Answer ⑥ 转发Answer ⑦ 收集ICE候选 ⑧ 收集ICE候选 ⑨ ICE候选 ⑩ ICE候选 ⑪ 连通性检测 (STUN/TURN) STUN/TURN 服务器 ⑫ P2P连接建立 ✅

从图上你能看到,整个流程分12步。前6步是信令和SDP交换,中间3步是ICE候选收集与交换,最后3步是连通性检测和连接建立。

嗯,这里要注意:第⑨⑩步的ICE候选交换,其实是双向并发的。双方都在收集,也都在发送。图上为了清晰,画成了先后顺序,实际是同时进行的。

8.7 实战小结

好了,这一章的内容不少。我帮你总结几个关键点:

  • 信令是基础 — 没有信令通道,P2P连接无从谈起。选WebSocket还是其他方式,看你的场景
  • SDP交换有顺序 — 先Offer后Answer,先setLocal再setRemote,顺序不能乱
  • ICE候选要实时转发 — 收到一个发一个,不要等全部收集完再发
  • 状态监控不能省 — 连接状态、ICE状态、候选错误,这三个事件必须监听
  • 重连机制要健壮 — 网络不会永远稳定,做好重连才能保证用户体验

我个人觉得,P2P连接最难的不是写代码,而是调试。因为涉及两个端、一个信令服务器、还有STUN/TURN服务器,任何一个环节出问题,连接都建立不起来。我的建议是:先在本机用两个页面测试,通了再部署到不同网络环境。

一个小技巧: 调试时打开chrome://webrtc-internals,里面能看到所有SDP、ICE候选、连接状态的详细信息。我排查问题基本都靠它。

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