30、综合实战:构建一个完整的视频会议应用(含信令、P2P、屏幕共享、聊天)
终于走到这一步了。说实话,前面29章我们拆解了WebRTC的每一个零件,现在该把它们组装起来了。这一章,我们要做一个真正的视频会议应用——不是demo,是能跑起来、能用的那种。
我记得第一次做完整会议系统时,最头疼的不是技术本身,而是怎么把这些模块串起来。信令、P2P连接、媒体流、屏幕共享、聊天……每个单独拎出来都不难,但要让它们协同工作,嗯,这里面的坑我踩过不少。
整体架构设计
先看看我们要搭的架子。一个完整的视频会议系统,核心就这几块:
- 信令服务器:负责房间管理、用户加入/离开、交换SDP和ICE候选
- P2P连接管理:建立和维护点对点的音视频通道
- 媒体流处理:摄像头、麦克风、屏幕共享的采集和切换
- 数据通道:用于文本聊天、文件传输等
说白了,信令是"媒人",P2P是"结婚",媒体流是"过日子",数据通道是"传小纸条"。这个比喻虽然糙了点,但道理是通的。
核心思路:所有参与者都通过信令服务器"认识"彼此,然后直接建立P2P连接。信令服务器不参与媒体数据传输,只负责协调。
信令服务器的实现
信令服务器我用的是Node.js + Socket.IO。为什么选这个组合?因为Socket.IO自带房间管理和事件广播,省去不少轮子工作。
先看核心代码:
// server.js - 信令服务器核心
const io = require('socket.io')(server, {
cors: { origin: '*' }
});
// 房间管理
const rooms = new Map();
io.on('connection', (socket) => {
console.log(`用户连接: ${socket.id}`);
// 加入房间
socket.on('join-room', (roomId, userId) => {
socket.join(roomId);
if (!rooms.has(roomId)) {
rooms.set(roomId, new Set());
}
rooms.get(roomId).add(userId);
// 通知房间内其他用户
socket.to(roomId).emit('user-joined', userId);
// 发送当前用户列表
const users = Array.from(rooms.get(roomId));
socket.emit('room-users', users);
});
// 转发SDP
socket.on('offer', (data) => {
socket.to(data.target).emit('offer', {
sdp: data.sdp,
from: socket.id
});
});
socket.on('answer', (data) => {
socket.to(data.target).emit('answer', {
sdp: data.sdp,
from: socket.id
});
});
// 转发ICE候选
socket.on('ice-candidate', (data) => {
socket.to(data.target).emit('ice-candidate', {
candidate: data.candidate,
from: socket.id
});
});
// 离开房间
socket.on('leave-room', (roomId) => {
socket.leave(roomId);
socket.to(roomId).emit('user-left', socket.id);
});
});
我的经验:信令服务器一定要做心跳检测。我曾经遇到过用户断网后,服务器还认为他在房间里,导致新用户拿到的用户列表是错的。加个ping/pong机制,30秒无响应就踢掉。
P2P连接管理
客户端这边,我们需要一个PeerConnection管理器。说白了就是封装RTCPeerConnection的创建、连接、断开等操作。
// peer-manager.js
class PeerManager {
constructor(socket, localStream) {
this.socket = socket;
this.localStream = localStream;
this.peers = new Map(); // userId -> RTCPeerConnection
this.config = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
}
// 创建对等连接
createPeerConnection(userId) {
const pc = new RTCPeerConnection(this.config);
// 添加本地流
this.localStream.getTracks().forEach(track => {
pc.addTrack(track, this.localStream);
});
// ICE候选处理
pc.onicecandidate = (event) => {
if (event.candidate) {
this.socket.emit('ice-candidate', {
target: userId,
candidate: event.candidate
});
}
};
// 远程流处理
pc.ontrack = (event) => {
this.onRemoteStream(userId, event.streams[0]);
};
// 连接状态变化
pc.onconnectionstatechange = () => {
console.log(`连接状态 [${userId}]: ${pc.connectionState}`);
if (pc.connectionState === 'disconnected' ||
pc.connectionState === 'failed') {
this.removePeer(userId);
}
};
this.peers.set(userId, pc);
return pc;
}
// 发起呼叫
async callUser(userId) {
const pc = this.createPeerConnection(userId);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
this.socket.emit('offer', {
target: userId,
sdp: offer
});
}
// 接听呼叫
async answerCall(userId, offer) {
const pc = this.createPeerConnection(userId);
await pc.setRemoteDescription(new RTCSessionDescription(offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
this.socket.emit('answer', {
target: userId,
sdp: answer
});
}
// 设置远程描述
async setRemoteDescription(userId, sdp) {
const pc = this.peers.get(userId);
if (pc) {
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
}
}
// 添加ICE候选
async addIceCandidate(userId, candidate) {
const pc = this.peers.get(userId);
if (pc) {
try {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
} catch (e) {
console.error('添加ICE候选失败:', e);
}
}
}
// 移除对等连接
removePeer(userId) {
const pc = this.peers.get(userId);
if (pc) {
pc.close();
this.peers.delete(userId);
}
}
// 清理所有连接
cleanup() {
this.peers.forEach((pc, userId) => {
pc.close();
});
this.peers.clear();
}
}
注意:ICE服务器配置里,STUN是必须的,但TURN不是。如果只是局域网或者网络条件好的环境,STUN就够了。但如果你要支持NAT穿透失败的情况,建议部署TURN服务器。我推荐coturn,开源免费,部署简单。
屏幕共享的实现
屏幕共享是视频会议里最常用的功能之一。实现起来其实不复杂,核心就是getDisplayMedia API。
// screen-share.js
class ScreenShare {
constructor(peerManager) {
this.peerManager = peerManager;
this.screenStream = null;
this.isSharing = false;
}
// 开始屏幕共享
async startShare() {
try {
// 获取屏幕流
this.screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
cursor: 'always',
displaySurface: 'monitor'
},
audio: false
});
this.isSharing = true;
// 替换所有对等连接的视频轨道
const videoTrack = this.screenStream.getVideoTracks()[0];
this.peerManager.peers.forEach((pc) => {
const sender = pc.getSenders().find(s =>
s.track && s.track.kind === 'video'
);
if (sender) {
sender.replaceTrack(videoTrack);
}
});
// 监听屏幕共享停止
videoTrack.onended = () => {
this.stopShare();
};
return this.screenStream;
} catch (err) {
console.error('屏幕共享失败:', err);
throw err;
}
}
// 停止屏幕共享
stopShare() {
if (this.screenStream) {
this.screenStream.getTracks().forEach(track => track.stop());
this.screenStream = null;
}
this.isSharing = false;
// 恢复摄像头
this.peerManager.restoreCameraTrack();
}
}
你可能会问:为什么用replaceTrack而不是重新添加轨道?因为replaceTrack不会触发重新协商,切换更平滑。我在项目中测试过,用replaceTrack切换屏幕共享,对端几乎感觉不到卡顿。
文本聊天(数据通道)
聊天功能走的是RTCDataChannel,不经过服务器。好处是延迟低、隐私好。
// chat-manager.js
class ChatManager {
constructor(peerManager) {
this.peerManager = peerManager;
this.channels = new Map(); // userId -> RTCDataChannel
}
// 创建数据通道(发起方)
createDataChannel(userId) {
const pc = this.peerManager.peers.get(userId);
if (!pc) return;
const channel = pc.createDataChannel('chat', {
ordered: true
});
this.setupChannel(channel, userId);
return channel;
}
// 接收数据通道(接收方)
onDataChannel(userId, channel) {
this.setupChannel(channel, userId);
}
// 设置通道事件
setupChannel(channel, userId) {
channel.onopen = () => {
console.log(`数据通道已打开: ${userId}`);
};
channel.onmessage = (event) => {
const data = JSON.parse(event.data);
this.onMessage(userId, data);
};
channel.onclose = () => {
this.channels.delete(userId);
};
this.channels.set(userId, channel);
}
// 发送消息
sendMessage(userId, text) {
const channel = this.channels.get(userId);
if (channel && channel.readyState === 'open') {
const message = {
type: 'text',
content: text,
timestamp: Date.now()
};
channel.send(JSON.stringify(message));
}
}
// 广播消息给所有人
broadcastMessage(text) {
this.channels.forEach((channel, userId) => {
this.sendMessage(userId, text);
});
}
}
小技巧:数据通道除了聊天,还能做很多事情。比如同步播放进度、传输文件(分片发送)、甚至做简单的白板数据同步。我做过一个项目,用数据通道传输鼠标位置,实现了远程光标指示功能。
完整集成示例
最后,我们把所有模块串起来。这是主应用的核心逻辑:
// app.js - 主应用
class VideoConferenceApp {
constructor() {
this.socket = io('https://your-signaling-server.com');
this.localStream = null;
this.peerManager = null;
this.screenShare = null;
this.chatManager = null;
this.roomId = null;
this.userId = generateUserId();
}
// 初始化
async init(roomId) {
this.roomId = roomId;
// 1. 获取本地媒体
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
this.showLocalVideo(this.localStream);
// 2. 初始化Peer管理器
this.peerManager = new PeerManager(this.socket, this.localStream);
// 3. 初始化屏幕共享
this.screenShare = new ScreenShare(this.peerManager);
// 4. 初始化聊天
this.chatManager = new ChatManager(this.peerManager);
// 5. 设置信令事件
this.setupSignaling();
// 6. 加入房间
this.socket.emit('join-room', roomId, this.userId);
}
// 信令事件处理
setupSignaling() {
this.socket.on('user-joined', async (userId) => {
console.log(`用户加入: ${userId}`);
// 创建数据通道
this.chatManager.createDataChannel(userId);
// 发起呼叫
await this.peerManager.callUser(userId);
});
this.socket.on('offer', async (data) => {
await this.peerManager.answerCall(data.from, data.sdp);
// 接收方也要创建数据通道
const pc = this.peerManager.peers.get(data.from);
pc.ondatachannel = (event) => {
this.chatManager.onDataChannel(data.from, event.channel);
};
});
this.socket.on('answer', async (data) => {
await this.peerManager.setRemoteDescription(data.from, data.sdp);
});
this.socket.on('ice-candidate', async (data) => {
await this.peerManager.addIceCandidate(data.from, data.candidate);
});
this.socket.on('user-left', (userId) => {
this.peerManager.removePeer(userId);
this.removeRemoteVideo(userId);
});
}
// 切换屏幕共享
async toggleScreenShare() {
if (this.screenShare.isSharing) {
this.screenShare.stopShare();
} else {
await this.screenShare.startShare();
}
}
// 发送聊天消息
sendChatMessage(text) {
this.chatManager.broadcastMessage(text);
}
// 离开房间
leaveRoom() {
this.socket.emit('leave-room', this.roomId);
this.peerManager.cleanup();
this.localStream.getTracks().forEach(t => t.stop());
}
}
常见问题与避坑指南
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 屏幕共享时听不到对方声音 | 屏幕共享替换了视频轨道,但音频轨道没动 | 确保只替换video轨道,audio保持原样 |
| 数据通道消息丢失 | 通道还没完全打开就发送消息 | 检查readyState,或者用队列缓存消息 |
| 多人会议时视频卡顿 | 每个连接都发送全分辨率视频 | 使用Simulcast或SVC,根据带宽调整分辨率 |
| ICE连接一直失败 | 没有配置TURN服务器 | 部署coturn,配置TURN地址 |
我曾经踩过的坑:有一次线上会议,用户反馈屏幕共享后摄像头画面回不来了。排查了半天,发现是replaceTrack之后,原来的摄像头track被停用了。正确的做法是:在开始屏幕共享前,保存摄像头track的引用,停止共享时重新添加回去。
好了,到这里,一个完整的视频会议应用的核心模块就都讲完了。信令、P2P、屏幕共享、聊天——每个模块单独看都不复杂,但组合起来就是一个能用的产品了。你想想看,从获取摄像头到建立P2P连接,再到屏幕共享和实时聊天,这一路走来,WebRTC的能力确实强大。
实际开发中,你还需要考虑UI交互、错误处理、带宽自适应、录制功能等等。但核心骨架就是这些。把基础打牢,剩下的就是往上添砖加瓦了。