30、综合实战:构建一个完整的视频会议应用(含信令、P2P、屏幕共享、聊天)

终于走到这一步了。说实话,前面29章我们拆解了WebRTC的每一个零件,现在该把它们组装起来了。这一章,我们要做一个真正的视频会议应用——不是demo,是能跑起来、能用的那种。

我记得第一次做完整会议系统时,最头疼的不是技术本身,而是怎么把这些模块串起来。信令、P2P连接、媒体流、屏幕共享、聊天……每个单独拎出来都不难,但要让它们协同工作,嗯,这里面的坑我踩过不少。

整体架构设计

先看看我们要搭的架子。一个完整的视频会议系统,核心就这几块:

  • 信令服务器:负责房间管理、用户加入/离开、交换SDP和ICE候选
  • P2P连接管理:建立和维护点对点的音视频通道
  • 媒体流处理:摄像头、麦克风、屏幕共享的采集和切换
  • 数据通道:用于文本聊天、文件传输等

说白了,信令是"媒人",P2P是"结婚",媒体流是"过日子",数据通道是"传小纸条"。这个比喻虽然糙了点,但道理是通的。

核心思路:所有参与者都通过信令服务器"认识"彼此,然后直接建立P2P连接。信令服务器不参与媒体数据传输,只负责协调。

信令服务器 用户A 摄像头 + 麦克风 用户B 摄像头 + 麦克风 用户C 屏幕共享 信令(SDP/ICE) P2P音视频 P2P音视频 P2P数据通道 数据流向: • 信令阶段:所有消息经过信令服务器中转 • 媒体阶段:音视频数据直接在P2P通道传输,不经过服务器

信令服务器的实现

信令服务器我用的是Node.js + Socket.IO。为什么选这个组合?因为Socket.IO自带房间管理和事件广播,省去不少轮子工作。

先看核心代码:

// server.js - 信令服务器核心
const io = require('socket.io')(server, {
  cors: { origin: '*' }
});

// 房间管理
const rooms = new Map();

io.on('connection', (socket) => {
  console.log(`用户连接: ${socket.id}`);

  // 加入房间
  socket.on('join-room', (roomId, userId) => {
    socket.join(roomId);
    
    if (!rooms.has(roomId)) {
      rooms.set(roomId, new Set());
    }
    rooms.get(roomId).add(userId);
    
    // 通知房间内其他用户
    socket.to(roomId).emit('user-joined', userId);
    
    // 发送当前用户列表
    const users = Array.from(rooms.get(roomId));
    socket.emit('room-users', users);
  });

  // 转发SDP
  socket.on('offer', (data) => {
    socket.to(data.target).emit('offer', {
      sdp: data.sdp,
      from: socket.id
    });
  });

  socket.on('answer', (data) => {
    socket.to(data.target).emit('answer', {
      sdp: data.sdp,
      from: socket.id
    });
  });

  // 转发ICE候选
  socket.on('ice-candidate', (data) => {
    socket.to(data.target).emit('ice-candidate', {
      candidate: data.candidate,
      from: socket.id
    });
  });

  // 离开房间
  socket.on('leave-room', (roomId) => {
    socket.leave(roomId);
    socket.to(roomId).emit('user-left', socket.id);
  });
});

我的经验:信令服务器一定要做心跳检测。我曾经遇到过用户断网后,服务器还认为他在房间里,导致新用户拿到的用户列表是错的。加个ping/pong机制,30秒无响应就踢掉。

P2P连接管理

客户端这边,我们需要一个PeerConnection管理器。说白了就是封装RTCPeerConnection的创建、连接、断开等操作。

// peer-manager.js
class PeerManager {
  constructor(socket, localStream) {
    this.socket = socket;
    this.localStream = localStream;
    this.peers = new Map(); // userId -> RTCPeerConnection
    this.config = {
      iceServers: [
        { urls: 'stun:stun.l.google.com:19302' },
        { urls: 'stun:stun1.l.google.com:19302' }
      ]
    };
  }

  // 创建对等连接
  createPeerConnection(userId) {
    const pc = new RTCPeerConnection(this.config);
    
    // 添加本地流
    this.localStream.getTracks().forEach(track => {
      pc.addTrack(track, this.localStream);
    });

    // ICE候选处理
    pc.onicecandidate = (event) => {
      if (event.candidate) {
        this.socket.emit('ice-candidate', {
          target: userId,
          candidate: event.candidate
        });
      }
    };

    // 远程流处理
    pc.ontrack = (event) => {
      this.onRemoteStream(userId, event.streams[0]);
    };

    // 连接状态变化
    pc.onconnectionstatechange = () => {
      console.log(`连接状态 [${userId}]: ${pc.connectionState}`);
      if (pc.connectionState === 'disconnected' || 
          pc.connectionState === 'failed') {
        this.removePeer(userId);
      }
    };

    this.peers.set(userId, pc);
    return pc;
  }

  // 发起呼叫
  async callUser(userId) {
    const pc = this.createPeerConnection(userId);
    const offer = await pc.createOffer();
    await pc.setLocalDescription(offer);
    
    this.socket.emit('offer', {
      target: userId,
      sdp: offer
    });
  }

  // 接听呼叫
  async answerCall(userId, offer) {
    const pc = this.createPeerConnection(userId);
    await pc.setRemoteDescription(new RTCSessionDescription(offer));
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    
    this.socket.emit('answer', {
      target: userId,
      sdp: answer
    });
  }

  // 设置远程描述
  async setRemoteDescription(userId, sdp) {
    const pc = this.peers.get(userId);
    if (pc) {
      await pc.setRemoteDescription(new RTCSessionDescription(sdp));
    }
  }

  // 添加ICE候选
  async addIceCandidate(userId, candidate) {
    const pc = this.peers.get(userId);
    if (pc) {
      try {
        await pc.addIceCandidate(new RTCIceCandidate(candidate));
      } catch (e) {
        console.error('添加ICE候选失败:', e);
      }
    }
  }

  // 移除对等连接
  removePeer(userId) {
    const pc = this.peers.get(userId);
    if (pc) {
      pc.close();
      this.peers.delete(userId);
    }
  }

  // 清理所有连接
  cleanup() {
    this.peers.forEach((pc, userId) => {
      pc.close();
    });
    this.peers.clear();
  }
}

注意:ICE服务器配置里,STUN是必须的,但TURN不是。如果只是局域网或者网络条件好的环境,STUN就够了。但如果你要支持NAT穿透失败的情况,建议部署TURN服务器。我推荐coturn,开源免费,部署简单。

屏幕共享的实现

屏幕共享是视频会议里最常用的功能之一。实现起来其实不复杂,核心就是getDisplayMedia API。

// screen-share.js
class ScreenShare {
  constructor(peerManager) {
    this.peerManager = peerManager;
    this.screenStream = null;
    this.isSharing = false;
  }

  // 开始屏幕共享
  async startShare() {
    try {
      // 获取屏幕流
      this.screenStream = await navigator.mediaDevices.getDisplayMedia({
        video: {
          cursor: 'always',
          displaySurface: 'monitor'
        },
        audio: false
      });

      this.isSharing = true;

      // 替换所有对等连接的视频轨道
      const videoTrack = this.screenStream.getVideoTracks()[0];
      this.peerManager.peers.forEach((pc) => {
        const sender = pc.getSenders().find(s => 
          s.track && s.track.kind === 'video'
        );
        if (sender) {
          sender.replaceTrack(videoTrack);
        }
      });

      // 监听屏幕共享停止
      videoTrack.onended = () => {
        this.stopShare();
      };

      return this.screenStream;
    } catch (err) {
      console.error('屏幕共享失败:', err);
      throw err;
    }
  }

  // 停止屏幕共享
  stopShare() {
    if (this.screenStream) {
      this.screenStream.getTracks().forEach(track => track.stop());
      this.screenStream = null;
    }
    this.isSharing = false;

    // 恢复摄像头
    this.peerManager.restoreCameraTrack();
  }
}

你可能会问:为什么用replaceTrack而不是重新添加轨道?因为replaceTrack不会触发重新协商,切换更平滑。我在项目中测试过,用replaceTrack切换屏幕共享,对端几乎感觉不到卡顿。

文本聊天(数据通道)

聊天功能走的是RTCDataChannel,不经过服务器。好处是延迟低、隐私好。

// chat-manager.js
class ChatManager {
  constructor(peerManager) {
    this.peerManager = peerManager;
    this.channels = new Map(); // userId -> RTCDataChannel
  }

  // 创建数据通道(发起方)
  createDataChannel(userId) {
    const pc = this.peerManager.peers.get(userId);
    if (!pc) return;

    const channel = pc.createDataChannel('chat', {
      ordered: true
    });

    this.setupChannel(channel, userId);
    return channel;
  }

  // 接收数据通道(接收方)
  onDataChannel(userId, channel) {
    this.setupChannel(channel, userId);
  }

  // 设置通道事件
  setupChannel(channel, userId) {
    channel.onopen = () => {
      console.log(`数据通道已打开: ${userId}`);
    };

    channel.onmessage = (event) => {
      const data = JSON.parse(event.data);
      this.onMessage(userId, data);
    };

    channel.onclose = () => {
      this.channels.delete(userId);
    };

    this.channels.set(userId, channel);
  }

  // 发送消息
  sendMessage(userId, text) {
    const channel = this.channels.get(userId);
    if (channel && channel.readyState === 'open') {
      const message = {
        type: 'text',
        content: text,
        timestamp: Date.now()
      };
      channel.send(JSON.stringify(message));
    }
  }

  // 广播消息给所有人
  broadcastMessage(text) {
    this.channels.forEach((channel, userId) => {
      this.sendMessage(userId, text);
    });
  }
}

小技巧:数据通道除了聊天,还能做很多事情。比如同步播放进度、传输文件(分片发送)、甚至做简单的白板数据同步。我做过一个项目,用数据通道传输鼠标位置,实现了远程光标指示功能。

完整集成示例

最后,我们把所有模块串起来。这是主应用的核心逻辑:

// app.js - 主应用
class VideoConferenceApp {
  constructor() {
    this.socket = io('https://your-signaling-server.com');
    this.localStream = null;
    this.peerManager = null;
    this.screenShare = null;
    this.chatManager = null;
    this.roomId = null;
    this.userId = generateUserId();
  }

  // 初始化
  async init(roomId) {
    this.roomId = roomId;

    // 1. 获取本地媒体
    this.localStream = await navigator.mediaDevices.getUserMedia({
      video: true,
      audio: true
    });
    this.showLocalVideo(this.localStream);

    // 2. 初始化Peer管理器
    this.peerManager = new PeerManager(this.socket, this.localStream);
    
    // 3. 初始化屏幕共享
    this.screenShare = new ScreenShare(this.peerManager);
    
    // 4. 初始化聊天
    this.chatManager = new ChatManager(this.peerManager);

    // 5. 设置信令事件
    this.setupSignaling();

    // 6. 加入房间
    this.socket.emit('join-room', roomId, this.userId);
  }

  // 信令事件处理
  setupSignaling() {
    this.socket.on('user-joined', async (userId) => {
      console.log(`用户加入: ${userId}`);
      // 创建数据通道
      this.chatManager.createDataChannel(userId);
      // 发起呼叫
      await this.peerManager.callUser(userId);
    });

    this.socket.on('offer', async (data) => {
      await this.peerManager.answerCall(data.from, data.sdp);
      // 接收方也要创建数据通道
      const pc = this.peerManager.peers.get(data.from);
      pc.ondatachannel = (event) => {
        this.chatManager.onDataChannel(data.from, event.channel);
      };
    });

    this.socket.on('answer', async (data) => {
      await this.peerManager.setRemoteDescription(data.from, data.sdp);
    });

    this.socket.on('ice-candidate', async (data) => {
      await this.peerManager.addIceCandidate(data.from, data.candidate);
    });

    this.socket.on('user-left', (userId) => {
      this.peerManager.removePeer(userId);
      this.removeRemoteVideo(userId);
    });
  }

  // 切换屏幕共享
  async toggleScreenShare() {
    if (this.screenShare.isSharing) {
      this.screenShare.stopShare();
    } else {
      await this.screenShare.startShare();
    }
  }

  // 发送聊天消息
  sendChatMessage(text) {
    this.chatManager.broadcastMessage(text);
  }

  // 离开房间
  leaveRoom() {
    this.socket.emit('leave-room', this.roomId);
    this.peerManager.cleanup();
    this.localStream.getTracks().forEach(t => t.stop());
  }
}

常见问题与避坑指南

问题 原因 解决方案
屏幕共享时听不到对方声音 屏幕共享替换了视频轨道,但音频轨道没动 确保只替换video轨道,audio保持原样
数据通道消息丢失 通道还没完全打开就发送消息 检查readyState,或者用队列缓存消息
多人会议时视频卡顿 每个连接都发送全分辨率视频 使用Simulcast或SVC,根据带宽调整分辨率
ICE连接一直失败 没有配置TURN服务器 部署coturn,配置TURN地址

我曾经踩过的坑:有一次线上会议,用户反馈屏幕共享后摄像头画面回不来了。排查了半天,发现是replaceTrack之后,原来的摄像头track被停用了。正确的做法是:在开始屏幕共享前,保存摄像头track的引用,停止共享时重新添加回去。

好了,到这里,一个完整的视频会议应用的核心模块就都讲完了。信令、P2P、屏幕共享、聊天——每个模块单独看都不复杂,但组合起来就是一个能用的产品了。你想想看,从获取摄像头到建立P2P连接,再到屏幕共享和实时聊天,这一路走来,WebRTC的能力确实强大。

实际开发中,你还需要考虑UI交互、错误处理、带宽自适应、录制功能等等。但核心骨架就是这些。把基础打牢,剩下的就是往上添砖加瓦了。

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