8、建立P2P连接实战:完整的1对1通话流程
好,前面我们把信令、SDP、ICE这些概念都拆开讲了一遍。现在到了真正动手的时候——把所有这些拼起来,跑通一个完整的1对1通话。
说实话,我第一次做这个的时候也紧张。总觉得这么多环节,哪个断了都不行。但后来发现,只要理清流程,一步步来,其实没那么玄乎。今天我就带你走一遍完整的实战流程。
核心流程一句话概括: 信令交换元数据 → SDP协商能力 → ICE收集候选 → 连通性检测 → 媒体流传输。
8.1 完整的信令交换流程
信令是P2P连接的"握手"阶段。它不传音视频,只传控制信息。我习惯把信令分成四个步骤:
- 发起方创建Offer — 把自己的SDP打包发出去
- 接收方创建Answer — 收到Offer后,回复自己的SDP
- 双方交换ICE候选 — 告诉对方自己能用的网络路径
- 连接建立 — 双方选出一条最优路径,开始传数据
你想想看,这就像两个人约见面。先发消息说"我在哪"(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超时、服务器宕机,都可能导致连接中断。所以,错误处理和重连机制是必须的。
我总结了一套"三步走"的错误处理策略:
- 检测到断开 — 先别慌,可能是短暂的网络抖动
- 等待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连接流程串起来了。我建议你对照着代码看,会清晰很多。
从图上你能看到,整个流程分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候选、连接状态的详细信息。我排查问题基本都靠它。