11. ICE Candidate 交换:ICE 协议简介、Candidate 消息处理、Trickle ICE 实现

好,咱们进入第11章。说实话,ICE 这块是 WebRTC 里最容易让人头大的部分之一。我刚接触那会儿,看着一堆 Candidate 信息在信令通道里飞来飞去,完全搞不清它们在干嘛。后来踩了不少坑,才慢慢摸清楚门道。

这一章,咱们就聊聊 ICE Candidate 的交换过程。我会把 ICE 协议的核心逻辑、Candidate 消息怎么处理、以及 Trickle ICE 的实现细节,掰开揉碎了讲清楚。

11.1 ICE 协议简介:它到底在解决什么问题?

ICE,全称 Interactive Connectivity Establishment,交互式连接建立。名字挺长,说白了就是:帮两个浏览器找到一条能通的路

你想想看,两个设备可能在不同的局域网后面,中间有 NAT 路由器、有防火墙。A 想直接连 B,但 A 的 IP 是内网的 192.168.x.x,B 也是内网地址。它们怎么知道对方的公网地址?ICE 就是干这个的。

ICE 协议的核心思路是:收集所有可能的连接方式,然后逐个尝试,找到能用的那条

这些“连接方式”就是 Candidate。每个 Candidate 包含一个 IP 地址和端口号,以及传输协议(通常是 UDP)。ICE 会把这些 Candidate 分成三类:

Candidate 类型 来源 说明
host 本机网卡 内网 IP,比如 192.168.1.100:3478
srflx STUN 服务器 NAT 映射后的公网 IP,比如 203.0.113.50:5000
relay TURN 服务器 中继地址,用于对称 NAT 或防火墙限制的情况

我个人习惯把 ICE 比作“找路游戏”。host 是家门口的路,srflx 是小区大门外的路,relay 是绕远路但一定能走通的高速公路。ICE 会优先尝试 host,不行再试 srflx,最后才用 relay。

11.2 Candidate 消息处理:信令通道里传的是什么?

ICE Candidate 的交换,是通过信令服务器中转的。A 收集到自己的 Candidate 后,打包成一条消息发给信令服务器,服务器再转发给 B。反过来也一样。

一条典型的 Candidate 消息长什么样?看代码:

{
  "type": "candidate",
  "candidate": {
    "candidate": "candidate:1 1 UDP 2122252543 192.168.1.100 3478 typ host",
    "sdpMid": "0",
    "sdpMLineIndex": 0
  }
}

这里每个字段都有含义:

  • candidate 字符串:核心信息,包含优先级、IP、端口、类型等
  • sdpMid:对应 SDP 中的媒体描述标识,比如 "0" 表示音频,"1" 表示视频
  • sdpMLineIndex:媒体行的索引,从 0 开始

我在项目中遇到过一个问题:两个 Candidate 的 sdpMid 和 sdpMLineIndex 对不上,结果 ICE 连接一直失败。排查了半天才发现是信令服务器转发时把字段搞乱了。所以,处理 Candidate 消息时,一定要严格校验这两个字段

⚠️ 我曾经踩过的坑: 不要假设 Candidate 消息的到达顺序。A 可能先发 Candidate 1,再发 Candidate 2,但 B 收到时顺序可能颠倒。ICE 协议本身不依赖顺序,但你的信令服务器如果做了缓存或排序,就要小心了。

11.3 Trickle ICE 实现:不等了,边收集边发

传统的 ICE 流程是:先收集所有 Candidate,全部收集完再一次性发给对方。这个过程可能很慢,尤其是 relay 类型的 Candidate,需要跟 TURN 服务器交互,耗时可能几秒钟。

Trickle ICE 的思路是:不等了,收集到一个就发一个。这样对方可以尽早开始连通性检查,整体连接速度会快很多。

实现 Trickle ICE,信令服务器需要支持增量式的 Candidate 转发。客户端代码大致是这样:

// 创建 RTCPeerConnection
const pc = new RTCPeerConnection(config);

// 监听 ICE Candidate 事件
pc.onicecandidate = (event) => {
  if (event.candidate) {
    // 收集到一个 Candidate,立即通过信令通道发送
    sendToSignalingServer({
      type: 'candidate',
      candidate: event.candidate
    });
  } else {
    // candidate 为 null 表示收集完成
    console.log('所有 Candidate 已收集完毕');
  }
};

// 收到对方的 Candidate 时,添加到连接中
function handleRemoteCandidate(candidateMsg) {
  const candidate = new RTCIceCandidate(candidateMsg.candidate);
  pc.addIceCandidate(candidate).catch(e => {
    console.error('添加远程 Candidate 失败:', e);
  });
}

这里有个细节要注意:onicecandidate 事件在 Candidate 收集完成时会触发一次,此时 event.candidate 为 null。很多新手会忽略这个信号,导致逻辑不完整。

💡 我的建议: 在信令服务器端,可以为每个连接维护一个 Candidate 列表。收到新的 Candidate 时,先检查是否已存在(去重),再转发。这样可以避免重复消息导致的性能问题。

11.4 ICE 连接状态与 Candidate 优先级

ICE 在尝试连接时,会给每个 Candidate 对(本地 + 远程)计算优先级。优先级越高,越先尝试。计算公式比较复杂,但你可以简单理解为:host 类型优先级最高,relay 类型最低

ICE 连接状态的变化,可以通过 oniceconnectionstatechange 监听:

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

嗯,这里要注意:failed 状态不一定是最终结果。有时候网络抖动会导致短暂失败,ICE 会自动重试。我建议在 failed 状态持续超过 5 秒后再做重连处理。

11.5 一张图看懂 ICE Candidate 交换流程

下面这张 SVG 图,展示了完整的 ICE Candidate 交换过程。从收集到发送,再到连通性检查,一目了然。

ICE Candidate 交换流程 Peer A 信令服务器 Peer B ① 收集 host/srflx/relay ② 发送 Candidate ③ 转发 ④ 收集自己的 Candidate ⑤ 发送 Candidate ⑥ 转发 ⑦ 双方开始连通性检查(STUN 打洞) 尝试所有 Candidate 对,按优先级从高到低 ⑧ 连接建立成功! 选中最优 Candidate 对,开始传输媒体数据 Trickle ICE 模式下,步骤 ②~⑥ 会重复多次,直到所有 Candidate 收集完毕 Peer A 信令服务器 Peer B

11.6 实战中的几个关键点

最后,分享几个我在实际项目中总结的经验:

  • Candidate 去重很重要:同一个 Candidate 可能因为网络波动被多次触发,信令服务器要做好去重,否则对方会收到大量重复消息。
  • 超时处理不能少:如果 30 秒内 ICE 还没建立连接,我建议主动触发重连。别傻等。
  • 日志要详细:ICE 失败的原因千奇百怪,把 Candidate 信息、状态变化都记下来,排查问题时会省很多时间。
  • Trickle ICE 不是万能的:有些老旧浏览器不支持 Trickle ICE,需要 fallback 到传统模式。信令服务器最好兼容两种方式。

核心要点回顾:

  • ICE 协议负责找到两个设备之间的可用网络路径
  • Candidate 有三种类型:host、srflx、relay,优先级依次降低
  • Trickle ICE 边收集边发送,能显著缩短连接建立时间
  • 信令服务器需要处理 Candidate 的转发、去重和顺序问题
  • ICE 连接状态变化要监听,失败后要有重试机制

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