20、点对点通话:建立P2P音视频通话、添加媒体流、处理ICE连接状态
好,咱们今天聊点实战的。前面几章我们把媒体流采集、信令交换这些基础打牢了,现在终于到了最激动人心的环节——让两个浏览器直接连起来,实现真正的点对点音视频通话。
说白了,P2P通话的核心就三件事:建立连接、添加流、管好状态。这三件事搞定了,一个能跑的通话demo就有了。我当年第一次调通P2P通话时,看到两个窗口里出现对方的画面,那种感觉,嗯,确实挺爽的。
一、P2P连接的本质:一个Offer,一个Answer
WebRTC的P2P连接,本质上就是一次「协商」。发起方创建一个Offer,接收方回复一个Answer。这个过程中,双方交换的是SDP(Session Description Protocol)信息。
SDP里有什么?说白了就是:
- 你要用什么编解码器?H264还是VP8?
- 你的音视频参数是什么?分辨率、帧率?
- 你的网络地址在哪?IP、端口?
这些信息通过信令服务器中转。注意,信令服务器只负责「传话」,不负责「传数据」。数据走的是P2P通道。
核心流程:
- 发起方创建 RTCPeerConnection
- 发起方创建 Offer(createOffer)
- 发起方设置本地描述(setLocalDescription)
- 发起方通过信令发送 Offer 给接收方
- 接收方设置远端描述(setRemoteDescription)
- 接收方创建 Answer(createAnswer)
- 接收方设置本地描述
- 接收方通过信令发送 Answer 给发起方
- 发起方设置远端描述
这个流程我建议你背下来。我在项目中遇到过好几次,因为信令顺序搞反了,导致连接一直建立不起来。排查了半天,结果发现是setRemoteDescription的时机不对。
二、添加媒体流:把摄像头画面塞进通道
连接建立好了,接下来就是把采集到的媒体流加进去。这一步其实很简单,核心就一个API:addTrack。
// 假设我们已经有了本地媒体流 localStream
const pc = new RTCPeerConnection(config);
// 把本地流的每个轨道都加到连接中
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
这里有个细节:addTrack的第二个参数要传原始流对象。为什么?因为WebRTC需要知道这个轨道属于哪个流,这样接收方才能正确地把多个轨道组合成一个流。
接收方怎么拿到流?监听 ontrack 事件:
pc.ontrack = (event) => {
// event.streams[0] 就是远程的媒体流
const remoteStream = event.streams[0];
remoteVideo.srcObject = remoteStream;
};
嗯,这里要注意:event.streams 是一个数组。虽然大多数情况下只有一个流,但理论上一个连接可以传输多个流。我建议你养成习惯,用 event.streams[0] 来取。
小技巧:如果你想让通话更流畅,可以在 addTrack 之前先设置一下编码参数。比如限制视频码率,避免带宽不足时画面卡顿。
三、ICE连接状态:别被状态机吓到
ICE(Interactive Connectivity Establishment)是WebRTC最核心也最复杂的部分。它的任务只有一个:找到一条能让两端通信的路径。
ICE的状态机有以下几个状态:
| 状态 | 说明 |
|---|---|
| new | 刚创建,还没开始收集候选地址 |
| checking | 正在收集候选地址,并尝试连接 |
| connected | 至少找到一条可用路径,但可能还在尝试其他路径 |
| completed | 所有候选地址都检查完毕,连接稳定 |
| failed | 所有路径都试过了,连不上 |
| disconnected | 曾经连上过,但现在断了 |
| closed | 连接被主动关闭 |
你想想看,这么多状态,我们开发者需要关注哪些?我个人习惯只关注三个:connected、failed、disconnected。
pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState;
console.log('ICE状态变了:', state);
switch(state) {
case 'connected':
// 通话已建立,可以隐藏加载提示
showCallStatus('已连接');
break;
case 'failed':
// 连接失败,需要重试或提示用户
showCallStatus('连接失败,请检查网络');
break;
case 'disconnected':
// 网络波动,可以尝试重连
showCallStatus('网络不稳定...');
break;
}
};
我曾经在一个项目中,因为没监听 disconnected 状态,导致用户网络闪断后,界面一直显示「通话中」,但实际上已经没声音没画面了。用户投诉说「你们这个通话怎么点不动了?」——嗯,从那以后我再也不敢忽略这个状态了。
四、ICE候选地址:STUN和TURN的故事
ICE能成功连接,靠的是候选地址(candidate)。每个候选地址代表一种可能的通信路径。常见的候选地址类型有:
- host:本机地址,局域网内通信最快
- srflx:通过STUN服务器获取的公网地址,适用于NAT穿透
- relay:通过TURN服务器中转,适用于对称NAT或防火墙限制
ICE的收集过程是这样的:
// 监听候选地址收集
pc.onicecandidate = (event) => {
if (event.candidate) {
// 把候选地址通过信令发送给对方
signaling.send({
type: 'candidate',
candidate: event.candidate
});
} else {
// 所有候选地址收集完毕
console.log('候选地址收集完成');
}
};
// 收到对方的候选地址
function handleRemoteCandidate(candidate) {
pc.addIceCandidate(new RTCIceCandidate(candidate));
}
这里有个坑:addIceCandidate 必须在 setRemoteDescription 之后调用。如果顺序反了,ICE会报错。我建议你在信令处理逻辑里加一个队列,等远端描述设置完再批量添加候选地址。
避坑指南:我曾经在生产环境中遇到过一个问题——用户在内网可以通话,但外网就不行。排查后发现是STUN服务器地址配错了。记住,STUN和TURN的地址一定要在部署前测试通过,不要等到上线了才发现。
五、完整流程:一张图看懂
说了这么多,咱们用一张图把整个流程串起来:
这张图把整个流程分成了11步。你仔细看,核心就是「Offer→Answer→ICE」这三个阶段。每个阶段都有对应的API调用和状态变化。
六、实战代码:一个最小可用的P2P通话
最后,我贴一个最小可用的P2P通话代码片段。这个代码去掉了信令部分,只展示核心逻辑:
// 发起方
async function startCall(localStream) {
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
// 添加本地流
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// 监听远程流
pc.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
// 监听ICE状态
pc.oniceconnectionstatechange = () => {
console.log('ICE:', pc.iceConnectionState);
};
// 创建Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 通过信令发送offer(伪代码)
signaling.send({ type: 'offer', sdp: offer });
return pc;
}
// 接收方
async function handleOffer(offer, localStream) {
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
pc.ontrack = (event) => {
remoteVideo.srcObject = event.streams[0];
};
pc.oniceconnectionstatechange = () => {
console.log('ICE:', pc.iceConnectionState);
};
// 设置远端描述
await pc.setRemoteDescription(new RTCSessionDescription(offer));
// 创建Answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 通过信令发送answer(伪代码)
signaling.send({ type: 'answer', sdp: answer });
return pc;
}
这个代码虽然简单,但已经包含了P2P通话的所有核心要素。你把它跑起来,配合一个简单的信令服务器,就能看到两个浏览器互相显示对方的画面了。
最后说一句:调试P2P通话时,一定要打开浏览器开发者工具的Console和Network面板。ICE状态变化、候选地址交换、SDP信息,这些都能在控制台里看到。我每次调试都会先确认ICE状态走到了connected,再去看画面有没有出来。顺序搞反了,排查起来会很痛苦。