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”。最后双方达成一致,连接建立。
这个过程分为三步:
- 创建Offer:发起方调用
createOffer(),生成一个SDP描述。 - 设置本地描述:发起方调用
setLocalDescription(),把自己的SDP存起来。 - 发送给远端:通过信令服务器把SDP传给接收方。
- 接收方设置远端描述:接收方调用
setRemoteDescription()。 - 创建Answer:接收方调用
createAnswer(),生成应答SDP。 - 设置本地描述:接收方调用
setLocalDescription()。 - 发送给发起方:通过信令服务器把Answer传回去。
- 发起方设置远端描述:发起方调用
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的核心逻辑串了起来。你保存下来,以后调试的时候对照着看,思路会清晰很多。
好了,这一章的内容就到这儿。你如果能把状态机、ICE候选者、Offer/Answer模型这三块吃透,RTCPeerConnection对你来说就不再是黑盒了。下次遇到连接问题,先看状态机到哪一步了,再检查候选者有没有收集到,最后确认Offer/Answer有没有正确交换——三步排查法,屡试不爽。