项目实战一:一对一视频通话、功能需求分析、核心代码实现、测试与部署

终于到了实战环节。说实话,前面讲了那么多理论,什么信令、ICE、SDP,大家可能早就手痒了。这一章我们就真刀真枪地干一个完整的一对一视频通话项目。我会带着你从需求分析开始,一步步走到部署上线。

嗯,先说说我个人的习惯。每次做项目,我不会急着写代码。我会先问自己三个问题:这个功能到底要解决什么问题?用户会怎么用?最核心的流程是什么?想清楚了再动手,反而更快。

功能需求分析

一对一视频通话,听起来简单,但拆开来看,其实包含好几个关键模块。我把它整理成了一张表,方便你对照着看。

模块 功能点 说明
用户管理 登录、创建房间、加入房间 用户需要有一个身份标识,才能建立连接
信令服务 交换SDP、ICE候选者 这是WebRTC的“握手”环节,必须可靠
媒体协商 获取本地音视频流、设置远端流 说白了就是让双方知道对方用什么格式传数据
连接管理 ICE连接、重连、断开 网络环境复杂,连接可能会断,要有容错机制
UI交互 本地画面、远端画面、挂断按钮 用户能直观看到自己和对方,操作要简单

你想想看,如果少了信令服务,两个浏览器就像两个哑巴,谁也联系不上谁。我在项目中遇到过有人直接把SDP硬编码在代码里测试,结果换了个网络环境就全崩了。所以,信令服务是地基,地基不稳,房子再漂亮也没用。

核心架构图

下面这张图展示了一对一视频通话的核心流程。我建议你多看几遍,把整个链路印在脑子里。

用户A(浏览器) 用户B(浏览器) 信令服务器 STUN/TURN 服务器 ① 创建/加入房间 ② 通知B ③ 发送SDP ④ 转发SDP ⑤ ICE候选 ⑥ ICE候选 ⑦ 媒体流(P2P直连)

这张图里,红色箭头是媒体流,黑色虚线是信令。你会发现,媒体流是直连的,不经过服务器。这就是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);
  });
});
小提示:我在项目中遇到过一个问题——用户刷新页面后,房间里的其他人不知道。所以一定要在disconnect事件里广播通知。不然对方会一直傻等。

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());
  }
}
注意:getUserMedia必须在HTTPS环境下才能使用。本地开发可以用localhost,但部署到线上一定要配HTTPS。我曾经因为这个被坑了一下午,浏览器直接报错,连摄像头都打不开。

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));
});

测试与部署

代码写完了,怎么测?我一般分三步走。

  1. 本地测试:开两个浏览器窗口,一个模拟A,一个模拟B。看看视频能不能通,声音有没有延迟。
  2. 局域网测试:找两台不同的电脑,连同一个WiFi。这时候ICE一般会走host类型,延迟最低。
  3. 公网测试:部署到服务器上,找两个不同网络环境的人测。这时候可能会走relay(TURN),延迟会高一些。
避坑指南:我曾经在公网测试时发现视频死活连不上。查了半天,原来是STUN服务器返回的地址是内网IP。后来加了TURN服务器才解决。所以,如果你的用户可能处在对称NAT环境下,一定要配TURN服务器。

部署方面,我推荐用Nginx做反向代理,把Socket.IO和静态文件分开。前端用Webpack打包,后端用PM2管理进程。这样既稳定又方便扩展。

好了,这一章的内容就到这里。代码都在上面了,你可以直接复制下来跑一跑。遇到问题别慌,先看看控制台报什么错,八成是SDP交换或者ICE候选没处理好。

公众号:蓝海资料掘金营,微信deep3321