项目实战一:一对一视频通话、功能需求分析、核心代码实现、测试与部署
终于到了实战环节。说实话,前面讲了那么多理论,什么信令、ICE、SDP,大家可能早就手痒了。这一章我们就真刀真枪地干一个完整的一对一视频通话项目。我会带着你从需求分析开始,一步步走到部署上线。
嗯,先说说我个人的习惯。每次做项目,我不会急着写代码。我会先问自己三个问题:这个功能到底要解决什么问题?用户会怎么用?最核心的流程是什么?想清楚了再动手,反而更快。
功能需求分析
一对一视频通话,听起来简单,但拆开来看,其实包含好几个关键模块。我把它整理成了一张表,方便你对照着看。
| 模块 | 功能点 | 说明 |
|---|---|---|
| 用户管理 | 登录、创建房间、加入房间 | 用户需要有一个身份标识,才能建立连接 |
| 信令服务 | 交换SDP、ICE候选者 | 这是WebRTC的“握手”环节,必须可靠 |
| 媒体协商 | 获取本地音视频流、设置远端流 | 说白了就是让双方知道对方用什么格式传数据 |
| 连接管理 | ICE连接、重连、断开 | 网络环境复杂,连接可能会断,要有容错机制 |
| UI交互 | 本地画面、远端画面、挂断按钮 | 用户能直观看到自己和对方,操作要简单 |
你想想看,如果少了信令服务,两个浏览器就像两个哑巴,谁也联系不上谁。我在项目中遇到过有人直接把SDP硬编码在代码里测试,结果换了个网络环境就全崩了。所以,信令服务是地基,地基不稳,房子再漂亮也没用。
核心架构图
下面这张图展示了一对一视频通话的核心流程。我建议你多看几遍,把整个链路印在脑子里。
这张图里,红色箭头是媒体流,黑色虚线是信令。你会发现,媒体流是直连的,不经过服务器。这就是WebRTC最核心的思想——能直连就直连,省带宽、降延迟。
核心代码实现
好,理论说完了,咱们直接上代码。我会把最核心的部分拆成几个小模块来讲。
1. 信令服务(Node.js + Socket.IO)
信令服务我用的是Socket.IO,因为它自带房间管理,省了我不少事。核心逻辑就两个:转发SDP和ICE候选。
// server.js
const io = require('socket.io')(3000);
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
// 加入房间
socket.on('join-room', (roomId) => {
socket.join(roomId);
socket.to(roomId).emit('user-connected', socket.id);
});
// 转发SDP
socket.on('offer', (data) => {
socket.to(data.roomId).emit('offer', data.sdp);
});
socket.on('answer', (data) => {
socket.to(data.roomId).emit('answer', data.sdp);
});
// 转发ICE候选
socket.on('ice-candidate', (data) => {
socket.to(data.roomId).emit('ice-candidate', data.candidate);
});
// 断开连接
socket.on('disconnect', () => {
socket.broadcast.emit('user-disconnected', socket.id);
});
});
2. 客户端核心逻辑(WebRTC API)
客户端这边,主要就是创建PeerConnection、获取媒体流、处理SDP交换。我习惯把逻辑封装成一个类,方便复用。
// client.js
class VideoCall {
constructor(socket, localVideo, remoteVideo) {
this.socket = socket;
this.localVideo = localVideo;
this.remoteVideo = remoteVideo;
this.pc = null;
this.localStream = null;
}
// 初始化本地媒体流
async initLocalStream() {
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true,
audio: true
});
this.localVideo.srcObject = this.localStream;
}
// 创建PeerConnection
createPeerConnection() {
this.pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' }
]
});
// 添加本地流
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.socket.emit('ice-candidate', {
roomId: this.roomId,
candidate: event.candidate
});
}
};
}
// 发起呼叫(创建Offer)
async call(roomId) {
this.roomId = roomId;
this.createPeerConnection();
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.socket.emit('offer', { roomId, sdp: offer });
}
// 接听呼叫(创建Answer)
async answer(roomId, sdp) {
this.roomId = roomId;
this.createPeerConnection();
await this.pc.setRemoteDescription(new RTCSessionDescription(sdp));
const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
this.socket.emit('answer', { roomId, sdp: answer });
}
// 挂断
hangUp() {
this.pc.close();
this.pc = null;
this.localStream.getTracks().forEach(track => track.stop());
}
}
3. UI交互层
UI这块,我尽量保持简洁。一个本地视频窗口、一个远端视频窗口、一个挂断按钮。核心就是绑定事件。
// ui.js
const socket = io('http://localhost:3000');
const call = new VideoCall(socket,
document.getElementById('localVideo'),
document.getElementById('remoteVideo')
);
// 加入房间
document.getElementById('joinBtn').onclick = async () => {
const roomId = document.getElementById('roomInput').value;
await call.initLocalStream();
socket.emit('join-room', roomId);
};
// 发起呼叫
document.getElementById('callBtn').onclick = () => {
const roomId = document.getElementById('roomInput').value;
call.call(roomId);
};
// 挂断
document.getElementById('hangupBtn').onclick = () => {
call.hangUp();
};
// 监听信令事件
socket.on('offer', async (sdp) => {
await call.answer(socket.id, sdp);
});
socket.on('answer', (sdp) => {
call.pc.setRemoteDescription(new RTCSessionDescription(sdp));
});
socket.on('ice-candidate', (candidate) => {
call.pc.addIceCandidate(new RTCIceCandidate(candidate));
});
测试与部署
代码写完了,怎么测?我一般分三步走。
- 本地测试:开两个浏览器窗口,一个模拟A,一个模拟B。看看视频能不能通,声音有没有延迟。
- 局域网测试:找两台不同的电脑,连同一个WiFi。这时候ICE一般会走host类型,延迟最低。
- 公网测试:部署到服务器上,找两个不同网络环境的人测。这时候可能会走relay(TURN),延迟会高一些。
部署方面,我推荐用Nginx做反向代理,把Socket.IO和静态文件分开。前端用Webpack打包,后端用PM2管理进程。这样既稳定又方便扩展。
好了,这一章的内容就到这里。代码都在上面了,你可以直接复制下来跑一跑。遇到问题别慌,先看看控制台报什么错,八成是SDP交换或者ICE候选没处理好。