中秋节前后,很多人会想起“回不了家”的故事。比如《熊出没》中的光头强,只能在中秋节给爸妈打电话,隔着屏幕听熟悉的声音。这个情节让人共情,因为它对应着现实中大量异地工作者的真实状态:父母在家,子女在外,逢年过节能做的只是拨一通电话或开一次视频。对开发者来说,这样一次通话并不神秘。从软件工程角度看,它本质上是一个实时音视频系统,涉及音视频采集、编码、网络传输、媒体协商、渲染播放等环节。这篇文章不展开动画剧情,而是以“异地亲情通话”为场景,带读者从零搭建一个浏览器端的视频通话最小系统。技术栈使用 WebRTC + Node.js + Socket.IO。读者可以跟着操作在本地跑通双端通话,然后逐步理解信令、媒体协商、NAT 穿透这些概念。学完后,你会知道一次视频通话的完整链路,也知道进入生产环境前还需要补哪些能力。

1. 为什么“打电话”场景比普通消息系统更难做

1.1 从一次远程通话看基本链路

“打电话”和“发消息”的体验差距来自时延和数据模型。消息是一次性传输的小数据包,发送者和接收者之间不需要保持连续通道;视频通话则需要在一段时间内连续传输音视频帧,并且对时延和抖动非常敏感。

如果把一次视频通话拆开,它的基本链路是麦克风或摄像头采集、音频或视频编码、打包为 RTP,然后通过网络传输,接收端解包、解码、渲染到扬声器和屏幕。

麦克风/摄像头采集 -> 音频/视频编码 -> 打包为 RTP -> 网络传输 -> 接收端解包 -> 解码 -> 扬声器/屏幕播放

在 WebRTC 中,这个链路的大部分细节被封装在浏览器内部,开发者主要接触的是 getUserMedia 、 RTCPeerConnection 和音视频标签。但这不代表可以忽略底层机制。浏览器只负责实现,不会替开发者解决业务层的房间管理、成员加入、断线重连和鉴权问题。

发送端和接收端必须先知道对方的网络地址和媒体参数,才能开始通话。这就像两个人打电话之前,先需要知道对方的号码和约定的通讯方式。WebRTC 用 SDP 描述媒体参数,用 ICE 候选描述网络路径,而这两类信息都需要经过信令通道传递。信令通道不搬运音视频数据,它只负责“帮双方互相认识”。

1.2 实时通话系统的核心模块

一个完整的视频通话系统可以拆成六个模块:

模块 作用 浏览器 WebRTC 中的对应能力
采集 拾取摄像头和麦克风数据 getUserMedia
编码 压缩音视频数据,降低带宽占用 VP8/VP9/H.264、Opus
传输 低时延传送媒体包 RTP、SRTP、RTCP
穿透 解决 NAT 和防火墙限制 STUN/TURN、ICE
信令 交换 SDP 和 ICE 候选 WebSocket/Socket.IO 自行实现
渲染 播放远端音视频 video/audio 标签

每个模块都可能成为问题点。采集阶段,用户可能拒绝授权或设备被占用。编码阶段,浏览器虽然会自动选择编码器,但不同硬件和浏览器组合可能产生兼容性问题。传输阶段,网络丢包、抖动、带宽不足会导致卡顿。穿透阶段,NAT 类型复杂时需要中继服务。信令阶段,房间管理和事件时序需要自己实现。渲染阶段,还要处理 muted 、 playsinline 和自动播放策略。

新手最容易忽略的是信令。很多人以为 WebRTC 会自动完成连接,其实浏览器只负责生成 SDP 和 ICE 候选,真正把它们送到对方手里的是自己的代码。Demo 范围虽然小,但信令消息的可靠性和顺序仍然会影响连接成功率。

2. 先理解 WebRTC 的四个关键机制

在动代码之前,需要把握四个机制,否则之后遇到的每个问题都会变成“玄学”。

2.1 P2P 连接与 NAT 穿透

WebRTC 的特点是尽量建立点对点连接,媒体数据不经过中心服务器。这样做的好处是时延低、带宽成本低。但在真实网络中,两台设备各自位于家庭路由或企业网关后,可能没有公网 IP,直接发给对端很难成功。

STUN 服务器解决“出口地址是什么”的问题。设备向 STUN 服务器发一个请求,服务器返回“从公网看到你的地址是某个 IP:端口”。这个地址会被收集为 ICE 候选。但仅知道出口地址还不够,部分 NAT 不允许外部主动向内发起连接,所以 WebRTC 还要通过 ICE 协议组合多种候选并尝试不同路径。

ICE 候选类型主要有三种:

候选类型 获取方式 适用范围
host 本机网卡地址 局域网或同网段设备
srflx STUN 反射地址 常见 NAT 后设备,可被对端访问
relay TURN 中继地址 P2P 全部失败时的兜底方案

relay 候选会消耗中继服务器的带宽和流量,成本不能忽略。生产环境需要评估中继用量,并按地域部署多个节点。

2.2 SDP 媒体协商

两端要正常通话,必须对“用什么编解码器、分辨率、端口、传输方向”达成一致。这个描述放在 SDP 中。发起方生成 offer,接收方根据 offer 和自己的情况生成 answer。

简化后的 SDP 片段如下:

v=0
o=- 4611731404012334688 2 IN IP4 127.0.0.1
s=-
t=0 0
m=audio 49170 RTP/AVP 111
a=rtpmap:111 opus/48000/2
m=video 51372 RTP/AVP 96
a=rtpmap:96 VP8/90000

真实 SDP 会比这个复杂得多,里面包含编码器优先级、网络地址、带宽限制、传输方向等大量字段。开发者不需要手工拼接 SDP,浏览器通过 createOffer 和 createAnswer 自动生成。关键是要用 setLocalDescription 和 setRemoteDescription 正确管理本地和远端状态。

2.3 ICE Candidate 与 STUN/TURN

ICE 是浏览器自动完成的“选路”过程。 RTCPeerConnection 创建后,会开始收集候选,每次发现新候选都会触发 onicecandidate 回调。开发者拿到候选后,要通过信令通道发给对端。对端调用 addIceCandidate 后,两端再尝试寻找可用路径。

可以通过 connectionState 观察连接状态。正常状态会经历:

new -> checking -> connected -> completed

如果看到 failed ,说明没有候选路径可用,通常需要补充 TURN。排错时也可以打开 chrome://webrtc-internals 查看候选对和连接状态。

2.4 信令服务为什么不属于 WebRTC 标准

WebRTC 本身不定义信令协议,因为信令的业务属性太强:需要房间系统、鉴权、自动扩容,不同产品差异很大。WebRTC 只规定了 offer/answer 如何描述,不规定它们如何传输。所以信令部分要用 WebSocket、Socket.IO 或普通 HTTP 自己实现。

本文选择 Socket.IO,因为它内置房间、事件广播和断线检测,写 Demo 很方便。但在生产环境,信令服务需要考虑消息丢失、重复、顺序、多节点房间同步和权限控制等问题。

信令消息可以按以下格式设计:

事件 发送方向 负载 触发时机
join-room 客户端 -> 服务端 roomName 打开画面并准备通话
joined 服务端 -> 客户端 peerCount 加入房间成功
peer-joined 服务端 -> 原房间客户端 无 有第二个人加入
offer 客户端 -> 服务端 -> 对端 description 发起方生成 offer
answer 对端 -> 服务端 -> 发起方 description 接收方生成 answer
ice-candidate 客户端 -> 服务端 -> 对端 candidate 本地发现新候选
peer-disconnected 服务端 -> 房间内其他客户端 无 对端离开或断线

这张表在开发信令时非常有用,可以先定消息类型,再写具体实现。

3. 环境准备与依赖安装

3.1 技术选型

这里选择 Node.js + Socket.IO + WebRTC。

  • 浏览器原生支持 WebRTC,不需要额外安装音视频 SDK。
  • Node.js 生态写信令服务最简单,一台服务器可以同时托管静态资源和 Socket.IO。
  • Socket.IO 自带房间和断线检测,适合快速搭出可用最小系统。

这是一个学习成本低、验证速度快的组合。如果已经有业务后端,可以把 Socket.IO 挂到现有 HTTP 服务上,也可以独立部署信令服务。

环境要求如下:

项目 要求
Node.js 14 或更高版本,建议使用 LTS 版本
浏览器 Chrome/Edge 最新版,需支持 WebRTC
硬件 可用摄像头、麦克风
网络 本地测试可在 localhost,跨网测试需要 STUN/TURN

先检查本机 Node.js 和 npm 是否可用:

node -v
npm -v

如果命令不存在,需要先安装 Node.js。建议使用 LTS 版本,避免新版本带来未知兼容问题。

3.2 项目目录结构

创建项目目录并规划文件位置:

webrtc-family-call/
├── package.json
├── server.js
└── public/
    ├── index.html
    └── main.js
  • server.js 是 Node.js 信令服务入口。
  • public 目录存放前端静态页面。
  • index.html 是页面骨架。
  • main.js 是前端 WebRTC 逻辑。

这样划分后,前端和后端在同一个项目里,部署时一条命令启动即可。后续如果信令服务需要扩容,可以把 public 静态资源挂到 Nginx 或 CDN, server.js 单独作为 Socket.IO 服务。

3.3 初始化项目与安装依赖

在项目目录下执行:

mkdir webrtc-family-call
cd webrtc-family-call
npm init -y
npm install express socket.io

npm init -y 会生成一个默认 package.json 。接着安装 express 和 socket.io 。 express 用来托管前端静态页面, socket.io 用来建立信令通道。

给 package.json 添加启动脚本,方便以后用 npm start 启动:

"scripts": {
  "start": "node server.js"
}

安装完成后,项目内会生成 node_modules 和 package-lock.json 。 package-lock.json 建议提交到版本库,保证团队成员安装的依赖版本一致。

4. 实现最小视频通话系统

4.1 后端信令服务器代码

最小信令服务只需要做四件事:管理房间、转发 offer/answer、转发 ICE candidate、通知成员离开。由于 Demo 限定一个房间最多两个人,所以不需要复杂的房间状态机。

在项目根目录创建 server.js :

const express = require('express');
const http = require('http');
const { Server } = require('socket.io');

const app = express();
const server = http.createServer(app);
const io = new Server(server);

app.use(express.static('public'));

io.on('connection', (socket) => {
  socket.on('join-room', (roomName) => {
    const room = io.sockets.adapter.rooms.get(roomName);
    const size = room ? room.size : 0;

    if (size >= 2) {
      socket.emit('room-full');
      return;
    }

    socket.join(roomName);

    if (size === 0) {
      socket.emit('joined', { peerCount: 1 });
    } else if (size === 1) {
      socket.to(roomName).emit('peer-joined');
      socket.emit('joined', { peerCount: 2 });
    }
  });

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

  socket.on('leave-room', (roomName) => {
    socket.to(roomName).emit('peer-disconnected');
    socket.leave(roomName);
  });

  socket.on('disconnecting', () => {
    const rooms = [...socket.rooms].filter((r) => r !== socket.id);
    rooms.forEach((room) => {
      socket.to(room).emit('peer-disconnected');
    });
  });
});

const PORT = process.env.PORT || 3000;
server.listen(PORT, () => {
  console.log(`Server listening on http://localhost:${PORT}`);
});

关键点:

  • 用 io.sockets.adapter.rooms.get(roomName) 获取房间当前人数,避免第三个用户加入后产生状态混乱。
  • 第一个用户收到 joined 且 peerCount 为 1,此时只创建连接对象,等待对端。
  • 第二个用户加入时,服务器先向旧成员发送 peer-joined ,再向新成员发送 joined 且 peerCount 为 2。客户端约定: peerCount 为 2 的成员发起 offer。
  • socket.to(data.room) 会把消息发给房间内除自己外的成员。Demo 最多两人,语义刚好。
  • 使用 disconnecting 事件而不是 disconnect ,因为在 disconnect 阶段 socket 已经离开所有房间,拿不到房间信息。

4.2 前端页面与基础 UI

创建 public/index.html :

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>异地亲情视频通话 Demo</title>
  <style>
    body {
      font-family: Arial, sans-serif;
      margin: 20px;
    }
    video {
      width: 360px;
      height: 270px;
      border: 1px solid #ddd;
      background: #f5f5f5;
    }
    .controls {
      margin-bottom: 12px;
    }
    .controls input {
      padding: 6px;
    }
    .controls button {
      padding: 6px 12px;
    }
  </style>
</head>
<body>
  <h1>异地亲情视频通话 Demo</h1>
  <div class="controls">
    <input id="roomName" placeholder="请输入房间号" />
    <button id="joinBtn">加入房间</button>
    <button id="hangupBtn">挂断</button>
  </div>
  <div>
    <video id="localVideo" autoplay muted playsinline></video>
    <video id="remoteVideo" autoplay playsinline></video>
  </div>
  <script src="/socket.io/socket.io.js"></script>
  <script src="/main.js"></script>
</body>
</html>

本地 video 标签加 muted 是为了避免自己的声音被扬声器播放后又传回麦克风,形成回声。 playsinline 对 iOS Safari 很重要,否则视频可能会全屏播放。

4.3 前端 WebRTC 逻辑

创建 public/main.js :

const socket = io();

let localStream = null;
let peerConnection = null;
let roomName = '';
let isInitiator = false;

const localVideo = document.getElementById('localVideo');
const remoteVideo = document.getElementById('remoteVideo');
const roomInput = document.getElementById('roomName');
const joinBtn = document.getElementById('joinBtn');
const hangupBtn = document.getElementById('hangupBtn');

const iceServers = [
  { urls: 'stun:stun.l.google.com:19302' }
];

joinBtn.addEventListener('click', async () => {
  roomName = roomInput.value.trim();
  if (!roomName) return;

  localStream = await navigator.mediaDevices.getUserMedia({
    video: true,
    audio: true
  });
  localVideo.srcObject = localStream;

  socket.emit('join-room', roomName);
});

hangupBtn.addEventListener('click', () => {
  if (peerConnection) {
    peerConnection.close();
    peerConnection = null;
  }
  remoteVideo.srcObject = null;
  socket.emit('leave-room', roomName);
});

socket.on('joined', async ({ peerCount }) => {
  await createPeerConnection();

  if (peerCount === 2) {
    isInitiator = true;
    const offer = await peerConnection.createOffer();
    await peerConnection.setLocalDescription(offer);
    socket.emit('offer', { room: roomName, description: offer });
  }
});

socket.on('peer-joined', async () => {
  await createPeerConnection();
});

socket.on('offer', async ({ description }) => {
  await createPeerConnection();
  await peerConnection.setRemoteDescription(description);
  const answer = await peerConnection.createAnswer();
  await peerConnection.setLocalDescription(answer);
  socket.emit('answer', { room: roomName, description: answer });
});

socket.on('answer', async ({ description }) => {
  await peerConnection.setRemoteDescription(description);
});

socket.on('ice-candidate', async ({ candidate }) => {
  if (peerConnection) {
    await peerConnection.addIceCandidate(candidate);
  }
});

socket.on('room-full', () => {
  alert('房间人数已满,请输入其他房间号');
});

socket.on('peer-disconnected', () => {
  if (peerConnection) {
    peerConnection.close();
    peerConnection = null;
  }
  remoteVideo.srcObject = null;
});

async function createPeerConnection() {
  if (peerConnection) return;

  peerConnection = new RTCPeerConnection({ iceServers });

  if (localStream) {
    localStream.getTracks().forEach((track) => {
      peerConnection.addTrack(track, localStream);
    });
  }

  peerConnection.onicecandidate = (event) => {
    if (event.candidate) {
      socket.emit('ice-candidate', {
        room: roomName,
        candidate: event.candidate
      });
    }
  };

  peerConnection.ontrack = (event) => {
    if (event.streams && event.streams[0]) {
      remoteVideo.srcObject = event.streams[0];
    }
  };

  peerConnection.onconnectionstatechange = () => {
    console.log('ICE connection state:', peerConnection.connectionState);
  };
}

这段代码的流程:

  1. 用户输入房间号并点击加入。
  2. 浏览器请求摄像头和麦克风权限,获得本地媒体流。
  3. 客户端向服务器发送 join-room 。
  4. 服务器返回 joined 。如果是第二个用户,会收到 peerCount: 2 ,于是创建 offer 并发送给先加入者。
  5. 先加入者收到 offer 后,创建 answer 回复。
  6. 双方通过 ice-candidate 交换候选地址。
  7. 媒体流通过 RTCPeerConnection 建立,远端画面出现在 remoteVideo 。

这里一个容易踩的坑是, createPeerConnection 在 peer-joined 和 joined 中都可能被调用,所以函数开头要做空判断,避免重复创建。

4.4 房间和信令消息设计

在 Coding 之前先定义事件,会让通信逻辑更清晰。上面的实现中,房间号作为消息路由的维度,同一个房间内的事件只转发给房间内其他成员。

Socket.IO 的 room 机制只在服务器内存中。服务器重启后,房间状态会丢失。多机部署时,需要把房间信息同步到 Redis 或使用独立的状态服务。这个复杂度不是 Demo 需要的,但上线前必须考虑。

Demo 限定一个房间两个人,是因为 mesh 模式下每增加一个成员,端上的 peerConnection 数量会同步增加。三人通话时每个端点要维护两条连接,六人以上时带宽和 CPU 压力会非常明显。多人通话应该考虑 SFU 架构,而不是在客户端无限循环 peerConnection 。

5. 运行验证与效果确认

5.1 启动服务

在项目根目录执行:

npm start

启动成功后,终端会出现:

Server listening on http://localhost:3000

如果端口被占用,可以用环境变量指定其他端口:

PORT=3001 npm start

5.2 双标签页模拟双人通话

打开两个 Chrome 标签页,访问 http://localhost:3000 。

  • 第一个标签页输入房间名 family ,点击“加入房间”。
  • 浏览器会弹出摄像头和麦克风权限请求,选择允许。
  • 页面出现本地预览画面。
  • 第二个标签页输入相同房间名 family ,点击“加入房间”。
  • 两个标签页应能看到对方的画面。

本地 video 标签加了 muted ,所以本地预览不会产生回声。远端 video 没有 muted ,当对端说话时能听到声音。

这种方式虽然是同一台设备上的两个标签页,但 WebRTC 仍然会走完整的信令、ICE 候选和媒体连接流程,适合先确认代码逻辑是否正确。它不能验证跨公网,但足以排查 90% 的代码问题。

5.3 预期输出与日志

打开 DevTools 控制台,可以看到类似输出:

ICE connection state: new
ICE connection state: checking
ICE connection state: connected

如果看到 failed ,说明候选路径没有打通。优先检查 STUN 服务器是否能访问,以及两个页面是否真的加入了同一个房间。

还可以打开 chrome://webrtc-internals ,查看 RTCIceCandidatePair 的状态、音视频包的发送和接收情况。这是排查 WebRTC 问题最直接的工具。

5.4 局域网真机测试要改的地址

本地标签页跑通后,可以用手机和电脑测试。先查看电脑局域网 IP:

ipconfig

Linux/macOS 使用 ifconfig 或 ip addr 。假设电脑 IP 是 192.168.1.10 ,手机访问:

http://192.168.1.10:3000

这里有一个限制:除了 localhost ,浏览器通常将局域网 IP 视为非安全上下文, getUserMedia 会被拒绝。所以要真机测试,最好通过 HTTPS 访问。临时测试可以在电脑上使用本地代理或其他方案,但生产环境必须配置 HTTPS,不能绕过权限限制。

注意:非 localhost 环境下,摄像头权限受浏览器安全上下文限制。真机测试建议直接部署 HTTPS,或用 CDN/网关统一接证书。

6. 常见问题与排查链路

6.1 本地摄像头无法打开

现象:点击“加入房间”后没有出现本地视频,控制台报 NotAllowedError 或 NotFoundError 。

排查顺序:

  1. 检查浏览器地址栏左侧是否允许摄像头和麦克风权限。
  2. 打开浏览器设置里的摄像头权限,看当前站点是否被阻止。
  3. 检查系统是否已有其他应用占用摄像头。
  4. 使用 HTTPS 或 localhost 访问页面,排除安全上下文问题。
  5. 打开 chrome://settings/content/camera 检查网站权限。

如果报 NotFoundError ,说明系统里没有可用的摄像头设备。如果报 NotAllowedError ,通常是用户拒绝了权限或浏览器策略阻止了页面。

6.2 信令能通但视频黑屏

现象:两个页面都加入了房间,信令日志没报错,但远端画面一直黑屏。

可能原因:

  • SDP 没有正确到达对端。
  • 对端没有添加本地媒体流轨道。
  • ICE 连接未成功。
  • ontrack 未触发或 remoteVideo.srcObject 没有被赋值。
  • 浏览器自动播放策略阻止了带声音的视频。

检查方式:

  • 在控制台打印 peerConnection.connectionState ,确认状态为 connected 。
  • 在 ontrack 回调里打印日志,确认触发了。
  • 打开 chrome://webrtc-internals ,观察 RTCInboundRtpVideoStream 是否持续收到包。
  • 用 getStats 读取收发字节数,确认媒体数据是否流动。

下面是简易诊断代码:

setInterval(async () => {
  if (!peerConnection) return;
  const stats = await peerConnection.getStats();
  stats.forEach((report) => {
    console.log(report.type, report.id, report.state || report.bytesSent || '');
  });
}, 3000);

6.3 不同网络无法互联

现象:两台设备在同一个局域网可以通话,但处于不同宽带或移动网络时黑屏。

原因:NAT 类型或防火墙不允许 P2P 数据包直接通过,ICE 选不到可用路径,但当前没有配置 TURN。

处理方式:

  • 部署一个可用的 TURN 服务,并在前端 iceServers 中配置。
  • 如果使用云厂商,申请专门的媒体服务,按需计费。
  • 增加 TURN 后, connectionState 应能进入 connected 或 completed 。

TURN 配置示例:

const iceServers = [
  { urls: 'stun:stun.example.com:3478' },
  {
    urls: 'turn:turn.example.com:3478',
    username: '你的用户名',
    credential: '你的密码'
  }
];

TURN 账号不能硬编码在前端。生产环境应通过接口下发短期凭据,防止账号被盗用后产生流量费用。

6.4 回声和音量问题

现象:本地扬声器播放远端声音后,麦克风又把声音录回去,对方听到自己的回声。

处理建议:

  • 本地 video 标签保持 muted 。
  • 使用耳机,避免外放导致回声。
  • 打开浏览器的回声消除和自动增益设置。
  • 不要用 AudioContext 随意处理麦克风流,容易破坏默认的 echoCancellation 。

音量问题通常可以通过 getUserMedia 的音频约束和 audio 标签音量属性调整。生产环境建议提供音量自测页面,让用户先确认输入输出设备正常。

7. 生产环境部署前必须补充的能力

7.1 认证与房间管理

Demo 中任何人只要知道房间名就可以加入。生产环境必须增加用户登录和房间访问控制。常见做法是:

  1. 用户通过业务系统获得临时 token。
  2. 信令服务校验 token,并绑定用户 ID。
  3. 加入房间前检查用户是否有该房间的访问权限。
  4. 记录每一次加入、离开、通话时长的日志。

信令服务不能直接暴露给公网,建议放在网关后面,只允许登录用户连接。否则可能被非法用户恶意填充房间,干扰在线通话。

7.2 TURN 集群与就近接入

跨网通话能否稳定,很大程度取决于 TURN 服务质量。单台 TURN 无法支撑大量媒体流量,生产环境需要:

  • 按地域部署多个 TURN 节点。
  • 通过 DNS 解析或云负载均衡把用户调度到最近节点。
  • 提前规划带宽预算,因为中继流量会占用大量服务器带宽。
  • 监控中继用户数、吞吐量、UDP/TCP 连接数。

当 TURN 节点接近瓶颈时,要及时扩容或设置调度阈值,避免媒体连接不稳定。

7.3 录音、录制、转写与合规

如果产品涉及通话录音、录制或语音转写,必须在业务上明确告知用户并获得同意。录音文件要加密存储,设置访问权限和保留过期时间。不能把 Demo 中的简单回调逻辑直接搬到生产环境,也不能在没有安全措施的情况下保存音视频文件。

合规不是一句口号,而是要在产品里落实到隐私政策、操作记录、数据生命周期管理和权限审计。

7.4 监控与日志

实时通话的故障通常与网络强相关,生产环境至少记录:

  • 加入房间成功率。
  • join-room 、 offer 、 answer 、 ice-candidate 消息数量。
  • ICE 连接失败率。
  • 通话时长、平均码率、分辨率。
  • 断线重连次数。

日志建议输出为结构化 JSON,接入 Elasticsearch、Loki 等系统,再配置告警。只有通过指标监控,才能提前发现网络质量恶化或信令服务异常。

7.5 资源成本与人数上限

Demo 是两人 mesh 通话,媒体流点对点传输。如果增加到三个人,每端需要同时维护两条 peerConnection ,上行带宽会翻倍。人数到 6 人以上,客户端 CPU、带宽和网络都会明显吃力。

常见视频会议架构对比如下:

架构 媒体转发方式 适合人数 客户端压力 服务端成本
mesh 端点之间直接互连 2-5 高 低
SFU 服务端选择性转发 10-100 中 中
MCU 服务端合流后发送 更多 低 高

生产环境建议使用 SFU 架构。常见开源方案包括 mediasoup、LiveKit、Janus,也可以使用商业云服务。选型时需要考虑容量、价格、部署难度和浏览器兼容性。

8. 从 Demo 到产品的实践建议(最佳实践)

8.1 不要一开始就自研信令协议

自研信令很容易在房间状态一致性、消息去重、服务端重启恢复上出问题。如果只是个人学习,可以按本文的方式写一遍。如果要上线,建议先使用成熟 SDK 或开源组件,等业务规模稳定后再考虑自研。自研之前先回答清楚:是否需要多人会议、是否需要录制、是否需要服务端转码。

8.2 媒体流与数据通道分开处理

WebRTC 还有 DataChannel,可以用来传文件、聊天消息、演示内容。不要把业务数据塞进音视频轨道。DataChannel 要单独建立,并注意小包并发性能。聊天和信令消息尽量走自己的业务通道,不要和 WebRTC 的媒体状态混在一起。

8.3 移动端和弱网要单独做降级策略

移动端摄像头、网络和 CPU 差异大,需要做好码率自适应。弱网时可以先降低视频分辨率,再关闭视频,最后只保留音频。WebRTC 支持对发送参数进行动态调整,可以通过 getStats 观察丢包率,然后降编码码率。

不同场景的建议策略:

场景 建议策略
弱网 降低视频码率,优先保证音频通话
CPU 占用高 降低分辨率或关闭视频
移动端 使用 H.264 编码,兼容性更好
大房间 开启 Simulcast,让服务端选择合适视频层

8.4 可复用检查清单

上线一个音视频功能前,可以按这个清单逐项检查:

  • 是否在 HTTPS 环境下提供页面。
  • 是否配置了 STUN 和 TURN,TURN 是否具备高可用。
  • 是否在客户端监听 connectionState 并提示用户网络异常。
  • 是否处理浏览器权限拒绝和设备不存在。
  • 是否包含信令服务重连和房间状态恢复。
  • 是否对房间加入做了认证和人数限制。
  • 是否记录通话质量日志,能否追踪一次失败通话。
  • 是否明确录制和隐私规则。
  • 是否做好回滚方案,信令服务升级不能影响在途通话。

这个清单可以直接放到团队发布检查流程里。每次改动音视频相关代码前,至少执行一遍,能减少很多线上问题。

中秋节光头强给爸妈打一通电话,看起来是一次简单的情感连接,但背后是采集、编码、网络传输、媒体协商、信令交互一系列技术环节共同工作。本文从一个最小视频通话 Demo 入手,把这条链路从黑盒变成了可以调试的代码。看完之后,建议先在本机把两个标签页跑通,再慢慢换成 HTTPS、增加 TURN、接入房间鉴权。后面每加一层能力,都会更接近一个可上线的音视频产品。对新手而言,最重要的不是背熟 API,而是遇到黑屏或连接失败时,能顺着 ICE、SDP、信令这条链路找到问题到底出在哪个环节。

Logo

邀请您加入社区

更多推荐