30、项目实战:从零搭建视频通话App、完整流程复盘、常见问题解决

终于到了这一章。说实话,前面讲了那么多信令、ICE、媒体协商,你可能会觉得有点散。今天咱们就把这些东西串起来,真正从零搭一个能跑的视频通话App。

我个人习惯是,先跑通再优化。所以这一章,咱们不搞花里胡哨的UI,先把核心链路打通。你跟着我走一遍,会发现其实WebRTC没那么玄乎。

30.1 项目整体架构

先看一张图,理清整个App的模块关系。我当年第一次做的时候,就是没想清楚架构,结果信令和媒体流搅在一起,调试起来特别痛苦。

视频通话App 核心架构图 本地视频采集 getUserMedia() RTCPeerConnection 媒体协商 + 传输 远端视频渲染 remoteVideo.srcObject 信令服务器 WebSocket / Socket.IO Offer/Answer ICE Candidate STUN / TURN 服务器 NAT穿透 / 中继 图:视频通话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 });
  }
}
⚠️ 注意: 创建 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 });
});

嗯,这里要注意:setRemoteDescriptionsetLocalDescription 都是异步的,一定要 await。我刚开始写的时候忘了加 await,结果 ICE 状态一直卡在 "new",视频死活出不来。

30.5 完整流程复盘

咱们把整个流程走一遍,你对照着代码看:

  1. 用户A加入房间 → 信令服务器通知用户B
  2. 用户A创建 Offer → 通过信令发给用户B
  3. 用户B收到 Offer → 设置远端描述,创建 Answer 发回
  4. 用户A收到 Answer → 设置远端描述
  5. 双方交换 ICE Candidate → 建立 P2P 连接
  6. 媒体流开始传输 → 视频画面出现

说白了,就是“你发我接,我发你接”的握手过程。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 美化、多人通话、屏幕共享,都是在这个基础上加功能。

📌 我的建议: 先别急着加功能。把这一章的单对单通话跑通,然后故意制造一些网络问题(比如关掉 WiFi),看看 ICE 重连的表现。这样你对 WebRTC 的理解会更深。
公众号:蓝海资料掘金营,微信deep3321