8、点对点视频通话实战:结合信令服务器、完成完整的P2P视频通话流程、处理通话建立与挂断
好,终于到了最激动人心的一步。前面几章我们把信令、ICE、媒体协商、音视频采集全拆开讲了,现在要把它们串起来,跑通一个真正的点对点视频通话。
说实话,我第一次做这个的时候,心里也没底。信令服务器跑起来了,本地视频也出来了,但两个浏览器就是连不上。后来发现是ICE候选者没传过去。嗯,这种坑我踩过不少,今天咱们一起把它填平。
8.1 整体架构:信令 + WebRTC 双通道
先看整体流程。说白了,就是两条通道并行工作:
- 信令通道:用 WebSocket 传控制消息(offer、answer、ICE候选者)
- 媒体通道:用 WebRTC 传音视频数据(走 UDP,直连或中继)
信令通道只在建立和挂断时用,媒体通道才是真正干活儿的。我习惯把信令服务器做得尽量轻量,只负责转发,不做任何业务逻辑判断。
8.2 信令消息设计
我习惯把信令消息设计成 JSON 格式,带一个 type 字段区分消息类型。别搞太复杂,够用就行。
| 消息类型 | 方向 | 说明 |
|---|---|---|
| join | 客户端→服务器 | 用户加入房间 |
| offer | A→B | 发起方发送SDP offer |
| answer | B→A | 接收方返回SDP answer |
| ice-candidate | 双向 | 交换ICE候选者 |
| hangup | 双向 | 挂断通话 |
8.3 信令服务器核心代码(Node.js + ws)
信令服务器我用 Node.js 的 ws 库,几十行代码就能搞定。你想想看,它其实就是一个消息中转站。
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
// 存储所有连接,key为userId
const clients = new Map();
wss.on('connection', (ws) => {
let userId = null;
ws.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'join':
userId = msg.userId;
clients.set(userId, ws);
console.log(`${userId} 加入房间`);
break;
case 'offer':
case 'answer':
case 'ice-candidate':
// 转发给目标用户
const target = clients.get(msg.to);
if (target && target.readyState === WebSocket.OPEN) {
target.send(JSON.stringify(msg));
}
break;
case 'hangup':
// 通知对方挂断
const peer = clients.get(msg.to);
if (peer && peer.readyState === WebSocket.OPEN) {
peer.send(JSON.stringify({ type: 'hangup', from: userId }));
}
break;
}
});
ws.on('close', () => {
clients.delete(userId);
console.log(`${userId} 断开连接`);
});
});
这段代码我实际项目里改过很多版。最开始没做 userId 校验,结果有人乱发消息把整个房间搞崩了。后来加了简单的身份验证,稳多了。
8.4 客户端:发起通话流程
客户端这边,我用一个 PeerConnectionManager 类来封装所有逻辑。这样代码干净,也方便复用。
class PeerConnectionManager {
constructor(localVideo, remoteVideo) {
this.localVideo = localVideo;
this.remoteVideo = remoteVideo;
this.pc = null;
this.ws = null;
this.localStream = null;
}
// 1. 连接信令服务器
connectSignaling(serverUrl, userId) {
this.ws = new WebSocket(serverUrl);
this.ws.onopen = () => {
this.ws.send(JSON.stringify({ type: 'join', userId }));
};
this.ws.onmessage = (event) => {
this.handleSignalingMessage(JSON.parse(event.data));
};
}
// 2. 获取本地媒体流
async startLocalStream() {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
this.localVideo.srcObject = this.localStream;
}
// 3. 创建PeerConnection并添加流
createPeerConnection() {
const config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
};
this.pc = new RTCPeerConnection(config);
// 添加本地流
this.localStream.getTracks().forEach(track => {
this.pc.addTrack(track, this.localStream);
});
// 监听远程流
this.pc.ontrack = (event) => {
this.remoteVideo.srcObject = event.streams[0];
};
// 监听ICE候选者
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.sendMessage({
type: 'ice-candidate',
candidate: event.candidate
});
}
};
}
// 4. 发起通话(创建offer)
async call(remoteUserId) {
this.remoteUserId = remoteUserId;
this.createPeerConnection();
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.sendMessage({
type: 'offer',
sdp: offer,
to: remoteUserId
});
}
// 5. 处理信令消息
handleSignalingMessage(msg) {
switch (msg.type) {
case 'offer':
this.handleOffer(msg);
break;
case 'answer':
this.handleAnswer(msg);
break;
case 'ice-candidate':
this.pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
break;
case 'hangup':
this.hangup();
break;
}
}
// 6. 处理offer(被呼叫方)
async handleOffer(msg) {
this.remoteUserId = msg.from;
this.createPeerConnection();
await this.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.sendMessage({
type: 'answer',
sdp: answer,
to: msg.from
});
}
// 7. 处理answer(呼叫方)
async handleAnswer(msg) {
await this.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
}
// 8. 挂断
hangup() {
if (this.pc) {
this.pc.close();
this.pc = null;
}
if (this.localStream) {
this.localStream.getTracks().forEach(track => track.stop());
}
this.localVideo.srcObject = null;
this.remoteVideo.srcObject = null;
// 通知对方
this.sendMessage({ type: 'hangup', to: this.remoteUserId });
}
sendMessage(msg) {
msg.from = this.userId;
this.ws.send(JSON.stringify(msg));
}
}
8.5 通话建立与挂断的完整时序
我把整个流程拆成几个关键步骤,你对照着代码看会更清楚:
- 双方加入房间:各自连接信令服务器,发送 join 消息
- A 发起呼叫:创建 PeerConnection,生成 offer,通过信令发给 B
- B 接收并应答:B 收到 offer,设置远端描述,创建 answer 发回给 A
- ICE 候选者交换:双方通过 onicecandidate 收集候选者,实时转发给对方
- 连接建立:ICE 连通后,ontrack 触发,远程视频出现
- 挂断:任一方调用 hangup,关闭连接并通知对方
关键点: ICE 候选者的交换是异步的,可能 offer/answer 还没交换完,候选者就已经来了。所以代码里要保证 setRemoteDescription 先执行,再调用 addIceCandidate。我习惯在 handleSignalingMessage 里加一个标志位判断。
8.6 避坑指南
做这个实战的时候,有几个坑我印象特别深:
- ICE 候选者丢失:我曾经遇到过候选者消息在 offer 之前到达,导致 addIceCandidate 报错。解决方案是在收到候选者时先缓存,等远端描述设置完再批量添加。
- 音频回声:本地和远端都在同一个房间,麦克风会采集到扬声器的声音。记得加回声消除:
getUserMedia({ audio: { echoCancellation: true } }) - 挂断后重连:挂断后 PeerConnection 状态变为 closed,不能复用。必须重新创建实例。我一开始没注意,第二次呼叫直接报 InvalidStateError。
- 信令服务器断开:WebSocket 断连后,PeerConnection 还在,但无法交换新的候选者。建议加心跳检测,断连后自动挂断。
好了,到这里你已经掌握了完整的 P2P 视频通话流程。从信令设计到客户端实现,再到挂断清理,每一步我都把实际项目中踩过的坑告诉你了。你照着这个结构写,基本不会出大问题。
公众号:蓝海资料掘金营,微信deep3321