8、点对点视频通话实战:结合信令服务器、完成完整的P2P视频通话流程、处理通话建立与挂断

好,终于到了最激动人心的一步。前面几章我们把信令、ICE、媒体协商、音视频采集全拆开讲了,现在要把它们串起来,跑通一个真正的点对点视频通话。

说实话,我第一次做这个的时候,心里也没底。信令服务器跑起来了,本地视频也出来了,但两个浏览器就是连不上。后来发现是ICE候选者没传过去。嗯,这种坑我踩过不少,今天咱们一起把它填平。

8.1 整体架构:信令 + WebRTC 双通道

先看整体流程。说白了,就是两条通道并行工作:

  • 信令通道:用 WebSocket 传控制消息(offer、answer、ICE候选者)
  • 媒体通道:用 WebRTC 传音视频数据(走 UDP,直连或中继)

信令通道只在建立和挂断时用,媒体通道才是真正干活儿的。我习惯把信令服务器做得尽量轻量,只负责转发,不做任何业务逻辑判断。

P2P视频通话双通道架构 信令服务器 浏览器 A (呼叫方) 浏览器 B (被呼叫方) offer/answer/ICE 转发 媒体通道 (SRTP/SCTP) 直连或TURN中继 信令通道 (WebSocket) 媒体通道 (WebRTC)

8.2 信令消息设计

我习惯把信令消息设计成 JSON 格式,带一个 type 字段区分消息类型。别搞太复杂,够用就行。

消息类型方向说明
join客户端→服务器用户加入房间
offerA→B发起方发送SDP offer
answerB→A接收方返回SDP answer
ice-candidate双向交换ICE候选者
hangup双向挂断通话
我的习惯: 每个消息都带上 from 和 to 字段,方便服务器做路由。虽然一对一场景用不到,但万一以后扩展成多人呢?

8.3 信令服务器核心代码(Node.js + ws)

信令服务器我用 Node.js 的 ws 库,几十行代码就能搞定。你想想看,它其实就是一个消息中转站。

const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

// 存储所有连接,key为userId
const clients = new Map();

wss.on('connection', (ws) => {
  let userId = null;

  ws.on('message', (data) => {
    const msg = JSON.parse(data);

    switch (msg.type) {
      case 'join':
        userId = msg.userId;
        clients.set(userId, ws);
        console.log(`${userId} 加入房间`);
        break;

      case 'offer':
      case 'answer':
      case 'ice-candidate':
        // 转发给目标用户
        const target = clients.get(msg.to);
        if (target && target.readyState === WebSocket.OPEN) {
          target.send(JSON.stringify(msg));
        }
        break;

      case 'hangup':
        // 通知对方挂断
        const peer = clients.get(msg.to);
        if (peer && peer.readyState === WebSocket.OPEN) {
          peer.send(JSON.stringify({ type: 'hangup', from: userId }));
        }
        break;
    }
  });

  ws.on('close', () => {
    clients.delete(userId);
    console.log(`${userId} 断开连接`);
  });
});

这段代码我实际项目里改过很多版。最开始没做 userId 校验,结果有人乱发消息把整个房间搞崩了。后来加了简单的身份验证,稳多了。

8.4 客户端:发起通话流程

客户端这边,我用一个 PeerConnectionManager 类来封装所有逻辑。这样代码干净,也方便复用。

class PeerConnectionManager {
  constructor(localVideo, remoteVideo) {
    this.localVideo = localVideo;
    this.remoteVideo = remoteVideo;
    this.pc = null;
    this.ws = null;
    this.localStream = null;
  }

  // 1. 连接信令服务器
  connectSignaling(serverUrl, userId) {
    this.ws = new WebSocket(serverUrl);
    this.ws.onopen = () => {
      this.ws.send(JSON.stringify({ type: 'join', userId }));
    };
    this.ws.onmessage = (event) => {
      this.handleSignalingMessage(JSON.parse(event.data));
    };
  }

  // 2. 获取本地媒体流
  async startLocalStream() {
    this.localStream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    this.localVideo.srcObject = this.localStream;
  }

  // 3. 创建PeerConnection并添加流
  createPeerConnection() {
    const config = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' }
      ]
    };
    this.pc = new RTCPeerConnection(config);

    // 添加本地流
    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.sendMessage({
          type: 'ice-candidate',
          candidate: event.candidate
        });
      }
    };
  }

  // 4. 发起通话(创建offer)
  async call(remoteUserId) {
    this.remoteUserId = remoteUserId;
    this.createPeerConnection();

    const offer = await this.pc.createOffer();
    await this.pc.setLocalDescription(offer);

    this.sendMessage({
      type: 'offer',
      sdp: offer,
      to: remoteUserId
    });
  }

  // 5. 处理信令消息
  handleSignalingMessage(msg) {
    switch (msg.type) {
      case 'offer':
        this.handleOffer(msg);
        break;
      case 'answer':
        this.handleAnswer(msg);
        break;
      case 'ice-candidate':
        this.pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
        break;
      case 'hangup':
        this.hangup();
        break;
    }
  }

  // 6. 处理offer(被呼叫方)
  async handleOffer(msg) {
    this.remoteUserId = msg.from;
    this.createPeerConnection();

    await this.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
    const answer = await this.pc.createAnswer();
    await this.pc.setLocalDescription(answer);

    this.sendMessage({
      type: 'answer',
      sdp: answer,
      to: msg.from
    });
  }

  // 7. 处理answer(呼叫方)
  async handleAnswer(msg) {
    await this.pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
  }

  // 8. 挂断
  hangup() {
    if (this.pc) {
      this.pc.close();
      this.pc = null;
    }
    if (this.localStream) {
      this.localStream.getTracks().forEach(track => track.stop());
    }
    this.localVideo.srcObject = null;
    this.remoteVideo.srcObject = null;

    // 通知对方
    this.sendMessage({ type: 'hangup', to: this.remoteUserId });
  }

  sendMessage(msg) {
    msg.from = this.userId;
    this.ws.send(JSON.stringify(msg));
  }
}
注意: 挂断时一定要先关闭 PeerConnection,再停止本地流。顺序反了会导致 remoteVideo 还残留最后一帧画面。我曾经因为这个被测试同学提了bug,画面卡在最后一帧,用户以为还在通话中。

8.5 通话建立与挂断的完整时序

我把整个流程拆成几个关键步骤,你对照着代码看会更清楚:

  1. 双方加入房间:各自连接信令服务器,发送 join 消息
  2. A 发起呼叫:创建 PeerConnection,生成 offer,通过信令发给 B
  3. B 接收并应答:B 收到 offer,设置远端描述,创建 answer 发回给 A
  4. ICE 候选者交换:双方通过 onicecandidate 收集候选者,实时转发给对方
  5. 连接建立:ICE 连通后,ontrack 触发,远程视频出现
  6. 挂断:任一方调用 hangup,关闭连接并通知对方

关键点: ICE 候选者的交换是异步的,可能 offer/answer 还没交换完,候选者就已经来了。所以代码里要保证 setRemoteDescription 先执行,再调用 addIceCandidate。我习惯在 handleSignalingMessage 里加一个标志位判断。

8.6 避坑指南

做这个实战的时候,有几个坑我印象特别深:

  • ICE 候选者丢失:我曾经遇到过候选者消息在 offer 之前到达,导致 addIceCandidate 报错。解决方案是在收到候选者时先缓存,等远端描述设置完再批量添加。
  • 音频回声:本地和远端都在同一个房间,麦克风会采集到扬声器的声音。记得加回声消除:getUserMedia({ audio: { echoCancellation: true } })
  • 挂断后重连:挂断后 PeerConnection 状态变为 closed,不能复用。必须重新创建实例。我一开始没注意,第二次呼叫直接报 InvalidStateError。
  • 信令服务器断开:WebSocket 断连后,PeerConnection 还在,但无法交换新的候选者。建议加心跳检测,断连后自动挂断。
调试小技巧: 打开 chrome://webrtc-internals,能看到所有 ICE 连接状态、候选者对、比特率等。我排查问题时第一个就开这个页面。

好了,到这里你已经掌握了完整的 P2P 视频通话流程。从信令设计到客户端实现,再到挂断清理,每一步我都把实际项目中踩过的坑告诉你了。你照着这个结构写,基本不会出大问题。


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