30、项目实战:从零搭建视频通话App、完整流程复盘、常见问题解决
终于到了这一章。说实话,前面讲了那么多信令、ICE、媒体协商,你可能会觉得有点散。今天咱们就把这些东西串起来,真正从零搭一个能跑的视频通话App。
我个人习惯是,先跑通再优化。所以这一章,咱们不搞花里胡哨的UI,先把核心链路打通。你跟着我走一遍,会发现其实WebRTC没那么玄乎。
30.1 项目整体架构
先看一张图,理清整个App的模块关系。我当年第一次做的时候,就是没想清楚架构,结果信令和媒体流搅在一起,调试起来特别痛苦。
说白了,整个App就三块:采集端、传输层、渲染端。信令服务器只负责“牵线”,不碰媒体数据。STUN/TURN解决网络穿透问题。
30.2 第一步:搭建信令服务器
信令服务器其实不复杂。我用Node.js + Socket.IO,十几行代码就能跑起来。你想想看,它要做的就两件事:转发Offer/Answer,转发ICE Candidate。
// server.js
const io = require('socket.io')(3000);
io.on('connection', (socket) => {
console.log('用户连接:', socket.id);
socket.on('join', (room) => {
socket.join(room);
// 通知房间内其他用户
socket.to(room).emit('user-joined', socket.id);
});
socket.on('offer', (data) => {
socket.to(data.room).emit('offer', data);
});
socket.on('answer', (data) => {
socket.to(data.room).emit('answer', data);
});
socket.on('ice-candidate', (data) => {
socket.to(data.room).emit('ice-candidate', data);
});
});
30.3 第二步:客户端核心逻辑
客户端这边,核心就是创建 RTCPeerConnection,然后处理媒体流。我习惯把逻辑封装成一个类,这样调用方只需要关心“开始通话”和“挂断”两个接口。
class VideoCall {
constructor() {
this.pc = null;
this.localStream = null;
this.socket = io('http://localhost:3000');
}
async startCall(roomId) {
// 1. 获取本地摄像头
this.localStream = await navigator.mediaDevices.getUserMedia({
video: true, audio: true
});
document.getElementById('localVideo').srcObject = this.localStream;
// 2. 创建 PeerConnection
this.pc = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
// 3. 添加本地流
this.localStream.getTracks().forEach(track => {
this.pc.addTrack(track, this.localStream);
});
// 4. 监听远端流
this.pc.ontrack = (event) => {
document.getElementById('remoteVideo').srcObject = event.streams[0];
};
// 5. 监听 ICE Candidate
this.pc.onicecandidate = (event) => {
if (event.candidate) {
this.socket.emit('ice-candidate', {
room: roomId,
candidate: event.candidate
});
}
};
// 6. 创建 Offer
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
this.socket.emit('offer', { room: roomId, offer });
}
}
setLocalDescription 完成,再发送 Offer。我见过有人把顺序搞反了,结果远端收到的 SDP 是空的,排查了半天。
30.4 第三步:处理远端应答
当远端收到 Offer 后,需要创建 Answer 并返回。这里有个细节:远端也要创建自己的 PeerConnection,不能复用本地的。
// 远端收到 Offer 后的处理
socket.on('offer', async (data) => {
// 创建自己的 PC
const pc = new RTCPeerConnection({ iceServers: [...] });
// ... 添加本地流、监听远端流等(同上)
await pc.setRemoteDescription(new RTCSessionDescription(data.offer));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
socket.emit('answer', { room: data.room, answer });
});
嗯,这里要注意:setRemoteDescription 和 setLocalDescription 都是异步的,一定要 await。我刚开始写的时候忘了加 await,结果 ICE 状态一直卡在 "new",视频死活出不来。
30.5 完整流程复盘
咱们把整个流程走一遍,你对照着代码看:
- 用户A加入房间 → 信令服务器通知用户B
- 用户A创建 Offer → 通过信令发给用户B
- 用户B收到 Offer → 设置远端描述,创建 Answer 发回
- 用户A收到 Answer → 设置远端描述
- 双方交换 ICE Candidate → 建立 P2P 连接
- 媒体流开始传输 → 视频画面出现
说白了,就是“你发我接,我发你接”的握手过程。ICE Candidate 的交换是并行的,谁先发现谁先发。
30.6 常见问题解决
这部分是我最想跟你分享的。我在项目中遇到过不少坑,挑几个典型的说说:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 视频黑屏 | 本地有画面,远端黑屏 | 检查 ontrack 是否触发,确认 SDP 中是否有视频 m-line |
| ICE 连接失败 | 状态一直 "checking" | 检查 STUN 服务器地址,或者加 TURN 服务器做中继 |
| 音频回声 | 对方能听到自己的声音 | 启用回声消除:getUserMedia({ audio: { echoCancellation: true } }) |
| 信令丢失 | Offer/Answer 没收到 | 检查 WebSocket 连接状态,加心跳重连机制 |
我曾经遇到一个特别隐蔽的 bug: 在 Android 上,某些浏览器在 getUserMedia 成功后,如果不立即调用 addTrack,摄像头会被系统回收。结果就是视频流断了,但 PeerConnection 还活着。后来我改成在 getUserMedia 的 then 里直接添加轨道,问题解决。
30.7 避坑指南
- 不要在生产环境只用 STUN:很多企业网络有对称 NAT,STUN 穿透不了。至少配一个 TURN 服务器做 fallback。
- ICE Candidate 要全量交换:不要只发一个 candidate,要等
onicecandidate触发完(candidate 为 null 时表示结束)。 - 媒体协商要在同一线程:别在 setTimeout 或回调里操作 PeerConnection,容易出竞态条件。
- 记得处理挂断:关闭 PeerConnection、释放摄像头、断开 Socket。不然下次通话可能拿不到设备。
好了,这一章的内容就到这里。你跟着代码走一遍,应该能跑通一个基础版。剩下的 UI 美化、多人通话、屏幕共享,都是在这个基础上加功能。