WebRTC 能点对点传输媒体流,但"找到对方在哪里"是最难的部分。ICE 就是解决这个问题的机制。
一、问题背景:为什么 P2P 连接难?
现实网络中,设备几乎都在 NAT(网络地址转换)后面:
css
手机A 运营商NAT 互联网 路由器NAT 手机B
192.168.1.5 ------> 公网IP: 1.2.3.4 ------> 互联网 ------> 公网IP: 5.6.7.8 ------> 192.168.0.10
问题:
- 手机A不知道自己的公网地址是什么
- 手机B不知道手机A的公网地址
- NAT 默认会丢弃外部发来的、未经"预热"的数据包
ICE(Interactive Connectivity Establishment)就是系统性地找到"双方都能互通的网络路径"的协议。
二、ICE Candidate 是什么?
ICE Candidate(候选地址)是"我可以通过这个地址接受连接"的声明,分三类:
1. Host Candidate(本机地址)
直接用本机网卡 IP,只有同一局域网才能用。
makefile
candidate:1234 1 udp 2113937151 192.168.1.5 54321 typ host
2. Server Reflexive Candidate(STUN 反射地址)
通过 STUN 服务器探测到的公网地址,大多数情况下能穿透 NAT。
makefile
candidate:2345 1 udp 1677729535 1.2.3.4 54321 typ srflx raddr 192.168.1.5 rport 54321
字段解读:
1.2.3.4 54321:公网侧地址(STUN 服务器看到的)typ srflx:server reflexive 类型raddr/rport:本机原始地址(related address)
3. Relay Candidate(TURN 中继地址)
通过 TURN 服务器中继,所有流量过服务器,最慢但最可靠,NAT 穿透全失败时的兜底。
makefile
candidate:3456 1 udp 33562367 9.8.7.6 3478 typ relay raddr 1.2.3.4 rport 54321
三、STUN 工作原理
STUN(Session Traversal Utilities for NAT) 是一个极简协议,只做一件事:告诉你自己的公网地址。
vbscript
手机A STUN 服务器(stun:stun.l.google.com:19302)
| |
|--- "我是谁?" (STUN Binding Request) ->|
| |
|<-- "你的公网地址是 1.2.3.4:54321" ---|
| (STUN Binding Response) |
拿到公网地址后,WebRTC 引擎把它封装成 srflx 类型的 Candidate,通过信令发给对端。
局限:STUN 只解决"地址发现"问题,不转发数据。对称型 NAT(Symmetric NAT)下,STUN 地址也无法被对端直连,这时必须用 TURN。
四、TURN 工作原理
TURN(Traversal Using Relays around NAT) 是真正的中继服务器,双方都连接到 TURN 服务器,数据经过 TURN 转发。
css
手机A ←------→ TURN Server ←------→ 手机B
(所有媒体数据经此中转)
代价:延迟增加、带宽消耗在服务器,通常需要付费。
TURN 在 pc_config 里以 ICE Server 形式配置,含认证凭据:
java
// RoomParametersFetcher.requestTurnServers()
PeerConnection.IceServer turnServer =
PeerConnection.IceServer.builder("turn:xxx.example.com:3478")
.setUsername("user123")
.setPassword("credential456")
.createIceServer();
代码中的逻辑(RoomParametersFetcher 第 130-148 行):
java
// 如果 pc_config 里没有 TURN,再单独请求一次 TURN 服务器
if (!isTurnPresent && !roomJson.optString("ice_server_url").isEmpty()) {
List<PeerConnection.IceServer> turnServers = requestTurnServers(...);
iceServers.addAll(turnServers);
}
五、ICE 协商完整流程
第一步:收集本地候选地址(Gathering)
创建 PeerConnection 后,WebRTC 引擎自动开始采集所有候选地址:
java
// PeerConnectionClient.createPeerConnectionInternal()
PeerConnection.RTCConfiguration rtcConfig =
new PeerConnection.RTCConfiguration(signalingParameters.iceServers); // 传入 STUN/TURN 列表
rtcConfig.continualGatheringPolicy =
PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; // 持续采集(网络切换时重新采集)
peerConnection = factory.createPeerConnection(rtcConfig, pcObserver);
采集顺序:Host → srflx(向 STUN 服务器查询)→ relay(向 TURN 服务器申请)
每采集到一个候选地址,就立刻通过回调上报:
java
// PCObserver(PeerConnectionClient 内部类)
@Override
public void onIceCandidate(final IceCandidate candidate) {
executor.execute(() -> events.onIceCandidate(candidate));
// → CallActivity → WebSocketRTCClient.sendLocalIceCandidate()
// → 通过信令发给对端
}
第二步:通过信令交换候选地址
每采集到一个 candidate 就立刻发给对端(Trickle ICE),不等全部收集完:
java
// WebSocketRTCClient.sendLocalIceCandidate()
JSONObject json = new JSONObject();
jsonPut(json, "type", "candidate");
jsonPut(json, "label", candidate.sdpMLineIndex); // 媒体行索引(0=audio, 1=video)
jsonPut(json, "id", candidate.sdpMid); // 媒体 ID
jsonPut(json, "candidate", candidate.sdp); // 完整 candidate 字符串
第三步:添加对端候选地址(有时序要求)
对端 candidate 到来时,不能立刻加入,要等 本地和远端 SDP 都设置完成 后才能调用 addIceCandidate。
这就是 queuedRemoteCandidates 的作用:
java
// PeerConnectionClient
public void addRemoteIceCandidate(final IceCandidate candidate) {
executor.execute(() -> {
if (queuedRemoteCandidates != null) {
queuedRemoteCandidates.add(candidate); // SDP 未就绪,先缓存
} else {
peerConnection.addIceCandidate(candidate, ...); // SDP 就绪,直接加
}
});
}
// setRemoteDescription 完成后,统一 drain
private void drainCandidates() {
for (IceCandidate candidate : queuedRemoteCandidates) {
peerConnection.addIceCandidate(candidate, ...);
}
queuedRemoteCandidates = null; // 此后新来的 candidate 直接加
}
第四步:连通性检测(Connectivity Check)
双方拿到对端候选地址后,WebRTC 引擎内部用 STUN Binding Request 对每对候选地址组合(candidate pair)进行连通性探测:
scss
手机A (1.2.3.4:54321) ---STUN ping---> 手机B (5.6.7.8:12345)
手机A (1.2.3.4:54321) <--STUN pong--- 手机B (5.6.7.8:12345) ✓ 可用
所有 candidate pair 按优先级排序(host > srflx > relay),选出最优的可通路径。
第五步:ICE 状态变迁
markdown
NEW → CHECKING → CONNECTED → COMPLETED
↘ FAILED(所有路径都不通)
↘ DISCONNECTED(连通后又断了)
代码中的状态监听(PCObserver.onIceConnectionChange):
java
if (newState == IceConnectionState.CONNECTED) {
events.onIceConnected(); // → CallActivity 显示"已连接"
} else if (newState == IceConnectionState.DISCONNECTED) {
events.onIceDisconnected();
} else if (newState == IceConnectionState.FAILED) {
reportError("ICE connection failed.");
}
六、ICE 优先级与选路策略
候选地址优先级(数字越大越优先):
| 类型 | 优先级 | 原因 |
|---|---|---|
| host | ~2113937151 | 直连,延迟最低 |
| srflx | ~1677729535 | NAT 穿透,次优 |
| relay | ~33562367 | 经中继,延迟最高 |
WebRTC 引擎同时检测多个 candidate pair,使用最高优先级的可通路径,同时保留备用路径(网络切换时快速恢复)。
七、配置总结(本项目中的关键参数)
java
// PeerConnectionClient.createPeerConnectionInternal()
rtcConfig.tcpCandidatePolicy = TcpCandidatePolicy.DISABLED; // 不使用 TCP candidate(延迟高)
rtcConfig.bundlePolicy = BundlePolicy.MAXBUNDLE; // 音视频共用一条连接
rtcConfig.rtcpMuxPolicy = RtcpMuxPolicy.REQUIRE; // RTCP 和 RTP 共用端口
rtcConfig.continualGatheringPolicy = GATHER_CONTINUALLY; // 网络变化时持续重新采集
rtcConfig.keyType = KeyType.ECDSA; // DTLS 加密用 ECDSA 证书
rtcConfig.sdpSemantics = SdpSemantics.UNIFIED_PLAN; // 现代 SDP 格式
八、一句话总结
| 组件 | 作用 |
|---|---|
| ICE Candidate | 描述"我可以从这个地址接受连接" |
| STUN | 帮你发现自己的公网地址,生成 srflx candidate |
| TURN | 当直连失败时,作为中继转发所有媒体数据 |
| ICE 协商 | 双方交换所有 candidate,逐一探测可通路径,选出最优的 |