Skip to main content

WebRTC 全景实战 (2):第一个 P2P 视频通话

Rainy
雨落无声,代码成诗 —— 致力于技术与艺术的极致平衡
Rainy
23 MIN READ... VIEWS

"跑通第一个通话,比读完十页 RFC 更有说服力。"

Ch1 我们学会了采集媒体。本章用 RTCPeerConnection 把媒体发给另一个浏览器——第一个完整 P2P 视频通话

Serge Lachapelle 在 WebRTC for the Curious — 历史 中解释了一个关键设计决策:IETF 刻意不对信令(Signaling)重新标准化,因为 SIP 等方案已存在,"重新标准化只会引发政治斗争"。这意味着 WebRTC 只标准化了媒体通道(SDP、ICE、DTLS、SRTP),而 Offer/Answer 的传递方式完全由应用决定——本章先用「复制粘贴 JSON」理解原理,Ch3 再替换为 WebSocket 信令服务器。

配套代码:examples/webrtc-lab/client/ch02-p2p-basic/


本篇术语表

术语英文解释
对等连接RTCPeerConnection浏览器 WebRTC 的核心对象,管理 SDP 协商、ICE、DTLS、媒体收发
JSEPJavaScript Session Establishment ProtocolRFC 8829,定义浏览器如何用 Offer/Answer 交换 SDP
Offer发起方的会话描述,包含媒体能力、ICE 参数、DTLS 指纹
Answer应答方的会话描述,确认选择的编解码器与网络参数
SDPSession Description Protocol文本格式的会话描述,详见 Ch4
ICEInteractive Connectivity EstablishmentNAT 穿透协议,通过候选地址配对找到最优路径,详见 Ch5
ICE Candidate一个可用的网络地址(IP:Port + 类型:host/srflx/relay)
信令Signaling在 PeerConnection 之外传递 SDP 和 ICE Candidate 的控制通道
Trickle ICE边收集 ICE Candidate 边发送,而非等全部收集完毕
Glare冲突双方同时发送 Offer 导致的协商冲突
Perfect NegotiationW3C 推荐的无冲突协商模式,通过 polite/impolite 角色解决 glare
信令状态signalingStatestablehave-local-offerhave-remote-offerclosed
ICE 连接状态iceConnectionStatenewcheckingconnectedcompleted / failed
连接状态connectionState综合 ICE + DTLS 的整体状态:connectingconnectedclosed
Unified Plan现代 SDP 语义,每个 m-line 对应一个 transceiver(Plan B 已废弃)
TransceiverRTCRtpTransceiver绑定 sender + receiver 的媒体通道,Unified Plan 的核心单元

一、RTCPeerConnection 在协议栈中的位置

RTCPeerConnection 是浏览器暴露给开发者的「黑盒」——你负责交换 SDP 和 ICE Candidate(信令),浏览器自动完成 ICE 连通性检查、DTLS 握手、SRTP 密钥协商和媒体传输。

1.1 开发者职责 vs 浏览器职责

开发者负责(信令层)浏览器自动完成(媒体层)
创建 / 转发 Offer / AnswerICE 候选收集与连通性检查
转发 ICE CandidateSTUN Binding Request
选择 Caller / Callee 角色DTLS 握手与证书验证
实现信令通道(Ch3 WebSocket)SRTP 密钥导出与加解密
错误处理与重连策略编解码器选择与 RTP 打包
Perfect Negotiation 角色分配RTCP 反馈与拥塞控制(Ch10)

Curious 历史 中强调:WebRTC 的设计哲学是 「Bring Your Own Signaling」——媒体标准化,信令留给应用。

1.2 RTCPeerConnection 构造配置

const pc = new RTCPeerConnection({
// ICE 服务器 — STUN 发现公网地址,TURN 中继(Ch5 / Ch14)
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
// { urls: "turn:turn.example.com:3478", username: "user", credential: "pass" },
],

// ICE 传输策略
// "all"(默认):尝试 host + srflx + relay
// "relay":仅使用 TURN 中继(企业防火墙场景)
iceTransportPolicy: "all",

// SDP 打包策略
// "balanced"(默认):音频和视频分别打包
// "max-compat":每个 track 独立 m-line
// "max-bundle":所有媒体打包到一条传输
bundlePolicy: "balanced",

// RTCP 复用 — 现代浏览器固定 true
rtcpMuxPolicy: "require",

// 预收集 ICE 候选 — 加速首次连接
iceCandidatePoolSize: 0, // 设为 2-10 可预热,但消耗 STUN 资源
});

二、JSEP Offer/Answer 模型

JSEP(RFC 8829) 定义了 WebRTC 的会话建立流程。核心思想:一方创建 Offer 描述自己的媒体能力,另一方创建 Answer 确认选择

2.1 完整信令时序

2.2 信令状态机

每次 setLocalDescription / setRemoteDescription 都会推进 signalingState

signalingState含义下一步操作
stable无进行中的 Offer/Answer可以 createOffer
have-local-offer已发出 Offer,等待 Answer对端应 createAnswer
have-remote-offer收到 Offer,等待本地 Answer本地应 createAnswer
closed连接已关闭不可恢复

2.3 addTrack vs addTransceiver

// 方式 1:addTrack — 简单场景首选
pc.addTrack(videoTrack, localStream);
pc.addTrack(audioTrack, localStream);

// 方式 2:addTransceiver — 需要精细控制方向时
pc.addTransceiver("video", {
direction: "sendrecv", // "sendonly" | "recvonly" | "inactive"
streams: [localStream],
});
pc.addTransceiver("audio", { direction: "sendrecv" });

// 仅接收(不发送摄像头,但想看对方画面)
pc.addTransceiver("video", { direction: "recvonly" });
Unified Plan

现代浏览器均使用 Unified Plan SDP 语义。每个 transceiver 对应 SDP 中一条 m-line。旧的 Plan B(Chrome 63 前)已废弃,无需了解。


三、最小可运行代码:1v1 视频通话

3.1 共享配置

// STUN 服务器 — 帮助发现公网地址(Ch5 深入)
const ICE_SERVERS = {
iceServers: [
{ urls: "stun:stun.l.google.com:19302" },
{ urls: "stun:stun1.l.google.com:19302" },
],
};

3.2 通用 PeerConnection 工厂

/**
* 创建 RTCPeerConnection 并绑定事件
* @param {MediaStream} localStream - Ch1 采集的本地流
* @param {HTMLVideoElement} remoteVideo - 远端画面元素
* @param {Function} onIceCandidate - ICE Candidate 回调(发给信令)
*/
function createPeerConnection(localStream, remoteVideo, onIceCandidate) {
const pc = new RTCPeerConnection(ICE_SERVERS);

// 添加本地轨道 — 每个 track 关联一个 stream
for (const track of localStream.getTracks()) {
pc.addTrack(track, localStream);
}

// 接收远端轨道
pc.ontrack = (event) => {
// event.streams[0] 是远端 MediaStream
// event.track 是单个 MediaStreamTrack
// event.transceiver 是 RTCRtpTransceiver
if (event.streams[0]) {
remoteVideo.srcObject = event.streams[0];
}
console.log(`收到远端 ${event.track.kind} track`);
};

// ICE Candidate 产出 — 通过信令发给对端
pc.onicecandidate = (event) => {
if (event.candidate) {
onIceCandidate(event.candidate);
}
// event.candidate === null 表示 ICE Gathering 完成
};

// 需要重新协商时触发(addTrack / removeTrack / ICE Restart)
pc.onnegotiationneeded = () => {
console.log("negotiationneeded — 需要重新 Offer/Answer");
// 1v1 首次连接手动 createOffer,此处通常不处理
// Ch3 多人场景 + Perfect Negotiation 会用到
};

// 状态监控(见第六节)
pc.oniceconnectionstatechange = () => {
console.log("ICE 状态:", pc.iceConnectionState);
};
pc.onconnectionstatechange = () => {
console.log("连接状态:", pc.connectionState);
};
pc.onsignalingstatechange = () => {
console.log("信令状态:", pc.signalingState);
};

return pc;
}

3.3 Caller(呼叫方)流程

async function callerFlow(localStream, remoteVideo, sendSignal) {
const pc = createPeerConnection(localStream, remoteVideo, (candidate) => {
sendSignal({ type: "candidate", candidate });
});

// 1. 创建 Offer
const offer = await pc.createOffer();
// createOffer 可选参数:
// { offerToReceiveAudio: true, offerToReceiveVideo: true }
// 现代浏览器通过 addTrack 已隐含,通常不需要

// 2. 设置本地描述 — 触发 ICE Gathering
await pc.setLocalDescription(offer);

// 3. 通过信令发送 Offer
sendSignal({ type: "offer", sdp: pc.localDescription });

// 4. 等待对端 Answer(在信令回调中处理)
return pc;
}

// 收到 Answer 时
async function handleAnswer(pc, answerSdp) {
await pc.setRemoteDescription(answerSdp);
// 此时 ICE 连通性检查自动开始
}

3.4 Callee(应答方)流程

async function calleeFlow(localStream, remoteVideo, offerSdp, sendSignal) {
const pc = createPeerConnection(localStream, remoteVideo, (candidate) => {
sendSignal({ type: "candidate", candidate });
});

// 1. 设置远端 Offer
await pc.setRemoteDescription(offerSdp);

// 2. 创建 Answer
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);

// 3. 通过信令发送 Answer
sendSignal({ type: "answer", sdp: pc.localDescription });

return pc;
}

3.5 完整交互流程图

3.6 完整端到端示例类

/**
* 1v1 通话管理器 — 整合 Caller / Callee 逻辑
* Ch2 Lab 的面向对象版本
*/
class P2PCall {
/** @type {RTCPeerConnection | null} */
pc = null;
/** @type {MediaStream | null} */
localStream = null;
/** @type {RTCIceCandidate[]} */
pendingCandidates = [];

constructor({ role, localVideo, remoteVideo, onSignal }) {
this.role = role; // "caller" | "callee"
this.localVideo = localVideo;
this.remoteVideo = remoteVideo;
this.onSignal = onSignal;
}

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

createConnection() {
this.pc = new RTCPeerConnection(ICE_SERVERS);

for (const track of this.localStream.getTracks()) {
this.pc.addTrack(track, this.localStream);
}

this.pc.ontrack = (e) => {
this.remoteVideo.srcObject = e.streams[0];
};

this.pc.onicecandidate = (e) => {
if (e.candidate) this.pendingCandidates.push(e.candidate);
};

this.pc.onconnectionstatechange = () => {
console.log(`[${this.role}] connection:`, this.pc.connectionState);
};
}

async createOfferBundle() {
this.createConnection();
const offer = await this.pc.createOffer();
await this.pc.setLocalDescription(offer);
await waitIceGatheringComplete(this.pc);

return {
sdp: this.pc.localDescription,
candidates: this.pendingCandidates,
};
}

async createAnswerBundle(offerBundle) {
this.createConnection();
await this.pc.setRemoteDescription(offerBundle.sdp);

for (const c of offerBundle.candidates || []) {
await this.pc.addIceCandidate(c);
}

const answer = await this.pc.createAnswer();
await this.pc.setLocalDescription(answer);
await waitIceGatheringComplete(this.pc);

return {
sdp: this.pc.localDescription,
candidates: this.pendingCandidates,
};
}

async completeConnection(answerBundle) {
await this.pc.setRemoteDescription(answerBundle.sdp);
for (const c of answerBundle.candidates || []) {
await this.pc.addIceCandidate(c);
}
}

hangup() {
this.pc?.close();
this.localStream?.getTracks().forEach((t) => t.stop());
this.localVideo.srcObject = null;
this.remoteVideo.srcObject = null;
this.pc = null;
this.localStream = null;
}
}

四、ICE Candidate 交换

4.1 ICE Gathering 过程

setLocalDescription() 调用后,浏览器开始 ICE Gathering——收集所有可用的网络候选地址:

4.2 ICE Candidate 格式解析

一条 ICE Candidate 字符串包含丰富的网络信息:

candidate:842163049 1 udp 1677729535 192.168.1.100 54321 typ host
│ │ │ │ │ │ │
│ │ │ │ │ │ └─ 类型
│ │ │ │ │ └─ 端口
│ │ │ │ └─ IP 地址
│ │ │ └─ 优先级
│ │ └─ 传输协议 (udp/tcp)
│ └─ 组件 ID (1=RTP, 2=RTCP)
└─ foundation (候选唯一标识)
类型含义示例场景
host本机网卡地址同一局域网直连
srflxSTUN 反射的公网地址跨 NAT 但可穿透
relayTURN 中继地址对称 NAT / 企业防火墙
prflx对端反射(连通性检查中发现)浏览器自动产生

4.3 收集并发送 Candidate

// 收集并缓存 ICE Candidate
const pendingCandidates = [];

pc.onicecandidate = (event) => {
if (event.candidate) {
// 通过信令发送给对端
pendingCandidates.push(event.candidate);
signaling.send({
type: "candidate",
candidate: event.candidate.toJSON(),
});
} else {
console.log("ICE Gathering 完成,共", pendingCandidates.length, "个候选");
}
};

4.4 接收远端 Candidate

/**
* 对端收到 ICE Candidate 后添加
* 注意:可以在 setRemoteDescription 之前或之后到达
*/
async function handleRemoteCandidate(pc, candidate) {
try {
await pc.addIceCandidate(candidate);
} catch (err) {
// 常见:在 setRemoteDescription 之前收到 candidate
// 解决:缓存到一个队列,setRemoteDescription 后批量添加
console.warn("addIceCandidate 失败,可能需缓存:", err.message);
pendingRemoteCandidates.push(candidate);
}
}

// setRemoteDescription 后,处理缓存的 candidate
async function flushPendingCandidates(pc, candidates) {
for (const c of candidates) {
await pc.addIceCandidate(c);
}
candidates.length = 0;
}

4.5 非 Trickle 模式:等待 Gathering 完成

Ch2 的 Lab Demo 使用 非 Trickle 模式——等所有 Candidate 收集完毕后,一次性打包发送:

/**
* 等待 ICE Gathering 完成
* 适用于手工复制 JSON 的学习场景
*/
function waitIceGatheringComplete(pc) {
if (pc.iceGatheringState === "complete") {
return Promise.resolve();
}
return new Promise((resolve) => {
const check = () => {
if (pc.iceGatheringState === "complete") {
pc.removeEventListener("icegatheringstatechange", check);
resolve();
}
};
pc.addEventListener("icegatheringstatechange", check);
});
}

// Caller 侧:打包 Offer + 所有 Candidate
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitIceGatheringComplete(pc);

const bundle = JSON.stringify({
sdp: pc.localDescription,
candidates: pendingCandidates,
}, null, 2);
// → 复制到 Callee 的文本框
Trickle vs 非 Trickle
  • Trickle ICE(生产推荐):每个 Candidate 立即发送,连接更快建立
  • 非 Trickle(学习用):等 Gathering 完成后打包,适合手工复制 JSON

Ch3 的 WebSocket 信令服务器实现 Trickle ICE。


五、手工信令:Ch2 Lab 的复制粘贴方案

Ch2 不依赖服务器,用 两个浏览器 Tab + 一个文本框 模拟信令通道:

5.1 Lab 消息格式

{
"sdp": {
"type": "offer",
"sdp": "v=0\r\no=- 123456789 2 IN IP4 127.0.0.1\r\n..."
},
"candidates": [
{
"candidate": "candidate:1 1 udp 2130706431 192.168.1.100 54321 typ host",
"sdpMid": "0",
"sdpMLineIndex": 0
}
]
}

5.2 Lab 操作步骤

步骤Tab A (Caller)Tab B (Callee)
1选择角色「Caller」选择角色「Callee」
2点击「创建 Offer」
3复制文本框 JSON粘贴到文本框
4点击「创建 Answer」
5粘贴 Answer JSON复制文本框 JSON
6点击「完成连接」
7双方看到远端画面双方看到远端画面

这种手工方式足以理解 Offer/Answer + ICE 的完整流程。Ch3 将同样的消息通过 WebSocket 自动转发,不再需要复制粘贴。

5.3 信令消息与 Ch3 的对应关系

Ch2 手工操作Ch3 WebSocket 消息载荷
复制 Offer JSON{ type: "offer", sdp }SDP + candidates
复制 Answer JSON{ type: "answer", sdp }SDP + candidates
(打包在 bundle 中){ type: "candidate", candidate }单个 ICE Candidate

六、连接状态监控

WebRTC 提供了多层状态,从外到内:

6.1 ICE 连接状态

pc.oniceconnectionstatechange = () => {
const state = pc.iceConnectionState;
console.log("ICE:", state);

switch (state) {
case "checking":
// 正在尝试候选地址配对
showUI("正在连接…");
break;
case "connected":
// 找到可用路径,媒体开始传输
showUI("已连接");
break;
case "completed":
// 所有候选检查完毕(可能切换更优路径)
showUI("连接稳定");
break;
case "disconnected":
// 暂时断开,可能自动恢复
showUI("连接中断,尝试恢复…");
break;
case "failed":
// 所有候选都失败,需要 TURN 或重试
showUI("连接失败");
break;
case "closed":
showUI("连接已关闭");
break;
}
};

6.2 整体连接状态

pc.onconnectionstatechange = () => {
const state = pc.connectionState;
// new → connecting → connected → closed
// 或 new → connecting → failed → closed

if (state === "connected") {
console.log("P2P 连接完全建立(ICE + DTLS)");
}
if (state === "failed") {
console.log("连接失败,建议: 检查 TURN 配置 / 网络 / 防火墙");
// Ch5 将深入排查
}
};

6.3 状态监控最佳实践

function attachStateMonitor(pc, onStateChange) {
const states = {};

const update = () => {
states.signaling = pc.signalingState;
states.iceGathering = pc.iceGatheringState;
states.iceConnection = pc.iceConnectionState;
states.connection = pc.connectionState;
onStateChange(states);
};

pc.onsignalingstatechange = update;
pc.onicegatheringstatechange = update;
pc.oniceconnectionstatechange = update;
pc.onconnectionstatechange = update;

update(); // 初始状态
}

// Lab 中的状态面板
attachStateMonitor(pc, (states) => {
stateEl.textContent = [
`signaling: ${states.signaling}`,
`gathering: ${states.iceGathering}`,
`ice: ${states.iceConnection}`,
`connection: ${states.connection}`,
].join("\n");
});

6.4 连接建立后的时间线


七、Perfect Negotiation 模式

当双方同时调用 createOffer() 时,会产生 Glare(冲突)——两个 Offer 互相到达,signalingState 陷入混乱。

W3C 在 WebRTC 1.0 规范 中定义了 Perfect Negotiation 模式,通过角色分工解决冲突:

7.1 完整实现

/**
* Perfect Negotiation 协商管理器
* @param {RTCPeerConnection} pc
* @param {boolean} isPolite - 是否为 polite 角色
* @param {Function} signal - 发送信令消息的函数
*/
function setupPerfectNegotiation(pc, isPolite, signal) {
let makingOffer = false;
let ignoreOffer = false;
let isSettingRemoteAnswerPending = false;

// 当需要重新协商时触发(如 addTrack、removeTrack)
pc.onnegotiationneeded = async () => {
try {
makingOffer = true;
await pc.setLocalDescription(await pc.createOffer());
signal({ sdp: pc.localDescription });
} catch (err) {
console.error("negotiationneeded 失败:", err);
} finally {
makingOffer = false;
}
};

// 处理收到的信令消息
async function handleSignalMessage(msg) {
const readyForOffer =
!makingOffer &&
(pc.signalingState === "stable" || isSettingRemoteAnswerPending);

const offerCollision = msg.sdp?.type === "offer" && !readyForOffer;

ignoreOffer = !isPolite && offerCollision;
if (ignoreOffer) {
console.log("Impolite peer 忽略冲突 Offer");
return;
}

isSettingRemoteAnswerPending = msg.sdp?.type === "answer";

// Polite peer 遇到冲突时需要 rollback
if (offerCollision && isPolite) {
await pc.setLocalDescription({ type: "rollback" });
}

await pc.setRemoteDescription(msg.sdp);

if (msg.sdp?.type === "offer") {
await pc.setLocalDescription(await pc.createAnswer());
signal({ sdp: pc.localDescription });
}

isSettingRemoteAnswerPending = false;
}

return { handleSignalMessage };
}

7.2 角色分配策略

场景PoliteImpolite
1v1 通话Callee(后加入方)Caller(先发起方)
多人会议后加入 Room 的 peer先发布媒体的 peer
通用规则peerId 较大者peerId 较小者
Ch2 不需要 Perfect Negotiation

1v1 通话中 Caller 和 Callee 角色明确,不会同时发 Offer。Perfect Negotiation 在 Ch3 多人场景和 onnegotiationneeded 自动重协商时才必需。


八、资源清理与错误恢复

8.1 正确关闭 PeerConnection

/**
* 完整清理 — 挂断通话时调用
*/
function hangup(pc, localStream) {
// 1. 关闭 PeerConnection(触发 closed 状态)
pc?.close();

// 2. 停止所有本地轨道(释放摄像头/麦克风)
localStream?.getTracks().forEach((track) => track.stop());

// 3. 清空视频元素
localVideo.srcObject = null;
remoteVideo.srcObject = null;
}

// 页面关闭时自动清理
window.addEventListener("beforeunload", () => hangup(pc, localStream));

8.2 连接失败重试与 ICE Restart

pc.oniceconnectionstatechange = () => {
if (pc.iceConnectionState === "failed") {
console.log("ICE 失败,尝试 ICE Restart");
restartIce(pc);
}
};

async function restartIce(pc) {
const offer = await pc.createOffer({ iceRestart: true });
await pc.setLocalDescription(offer);
// 通过信令发送新 Offer — 对端 setRemoteDescription 后重新添加 candidate
signal({ type: "offer", sdp: pc.localDescription });
}

ICE Restart 会重新收集候选地址,但 保留 DTLS 会话(不断开加密通道)。适用于网络切换(WiFi → 4G)场景。


九、常见问题与踩坑指南

Q1: setRemoteDescription 报错 "InvalidStateError"

A: signalingState 不匹配。检查是否在 have-local-offer 时收到了第二个 Offer(glare)。使用 Perfect Negotiation 或确保只有 Caller 发 Offer。

Q2: 能看到本地画面但看不到远端画面

A: 三个排查点:

  1. 对端是否成功 addTrack 并在 setLocalDescription 之前完成
  2. ontrack 回调是否正确设置 remoteVideo.srcObject
  3. ICE 是否连通——检查 iceConnectionState 是否为 connected

Q3: addIceCandidate 报错 "Error processing ICE candidate"

A: 常见原因:

  • setRemoteDescription 之前收到 Candidate → 缓存后批量添加
  • Candidate 格式被 JSON 序列化破坏 → 使用 candidate.toJSON() 发送,原样还原
  • 连接已 closed → 忽略迟到的 Candidate

Q4: 同一台机器两个 Tab 能通,不同机器不通

A: 同一台机器时 ICE 使用 host candidate(本机 IP)直连。不同机器需要 STUN 发现公网地址,如果双方都在对称型 NAT 后面,host + srflx 都不够,需要 TURN 中继(Ch5)。

Q5: createOffer 和 addTrack 的顺序重要吗?

A: 必须先 addTrackcreateOffer,否则 Offer 中不包含媒体描述,对端无法协商编解码器。

Q6: 为什么需要 STUN 服务器?

A: STUN 让浏览器发现自己的公网 IP:Port(Server Reflexive Candidate)。没有 STUN,跨 NAT 时双方只有私有地址,无法直连。STUN 是免费的;TURN 是付费中继(Ch5 / Ch14)。

Q7: onnegotiationneeded 什么时候触发?

A: 添加/移除 Track、修改 transceiver 方向、ICE Restart 时。1v1 首次连接不会触发(因为手动 createOffer),但添加屏幕共享(Ch1 §5.3)时会触发。

Q8: localDescription 和 currentLocalDescription 有什么区别?

A: localDescription 是最后一次成功 setLocalDescription 的值。currentLocalDescription 是浏览器当前实际使用的描述(rollback 后会不同)。通常使用 localDescription 即可。

Q9: 如何确认 DTLS 握手成功?

A: 当 connectionState 变为 connected 时,ICE 和 DTLS 都已成功。也可在 chrome://webrtc-internals 查看 DTLS 状态和 SRTP 密钥信息。


十、实战 Lab

10.1 运行 Ch02 Demo

cd examples/webrtc-lab/client/ch02-p2p-basic
npx serve .
# 打开 http://localhost:3000

10.2 练习清单

#练习预期观察
1两个 Tab 分别选 Caller / Callee状态面板显示角色
2Caller 点击「创建 Offer」文本框出现 JSON bundle;signalingState = have-local-offer
3复制 JSON 到 Callee,点击「创建 Answer」Callee 出现 Answer JSON;signalingState = stable
4复制 Answer 回 Caller,点击「完成连接」双方 iceConnectionStateconnected
5双方看到远端视频画面ontrack 触发,remoteVideo 有画面
6打开 chrome://webrtc-internals观察 ICE pair 选中过程、DTLS 握手、SRTP 统计

10.3 扩展挑战

  1. Trickle ICE:改造 Demo,每个 onicecandidate 立即显示,不等 Gathering 完成
  2. ICE 状态面板:实时展示四维状态(signaling / gathering / ice / connection)
  3. 自动重连iceConnectionState === "failed" 时自动 restartIce
  4. 准备 Ch3:将 sendSignal 抽象为接口,为 WebSocket 接入做准备
  5. 跨网络测试:两台不同设备通过 STUN 连接,观察 candidate 类型变化

10.4 Demo 核心代码导读

// examples/webrtc-lab/client/ch02-p2p-basic/main.js(节选)

// 非 Trickle:等 ICE Gathering 完成后打包
document.getElementById("startBtn").onclick = async () => {
await getMedia();
createPeerConnection();
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
await waitIceGatheringComplete(pc); // 关键:等待所有 candidate

sdpBox.value = JSON.stringify({
sdp: pc.localDescription,
candidates: pendingCandidates, // 一次性打包
}, null, 2);
};

// Callee 侧:解析 bundle,设置远端描述 + 添加 candidates
document.getElementById("answerBtn").onclick = async () => {
const msg = JSON.parse(sdpBox.value);
createPeerConnection();
await pc.setRemoteDescription(msg.sdp);
for (const c of msg.candidates || []) {
await pc.addIceCandidate(c);
}
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
await waitIceGatheringComplete(pc);
// ... 输出 Answer bundle
};

// Caller 侧:收到 Answer 后完成连接
document.getElementById("connectBtn").onclick = async () => {
const msg = JSON.parse(sdpBox.value);
await pc.setRemoteDescription(msg.sdp);
for (const c of msg.candidates || []) {
await pc.addIceCandidate(c);
}
};

十一、从手工信令到 WebSocket:Ch3 预告

本章用复制粘贴完成了信令交换的全流程。生产中不可能让用户手动复制 JSON——你需要一个 信令服务器

Ch3 将实现:

  • WebSocket 信令服务器(examples/webrtc-lab/signaling/
  • Room 加入/离开与 peer 管理
  • Trickle ICE 实时转发
  • 信令消息协议与安全考量

信令服务器 永远看不到 SRTP 媒体内容——它只传递 SDP 文本和 ICE Candidate JSON。媒体带宽不经过信令服务器。


十二、本章小结

要点内容
核心 APIRTCPeerConnection + addTrack + ontrack
协商模型JSEP Offer/Answer:createOffersetLocalDescription → 信令 → setRemoteDescription
ICEonicecandidate 产出 → 信令 → addIceCandidate 消费
状态监控signalingState / iceGatheringState / iceConnectionState / connectionState 四层
冲突处理Perfect Negotiation:polite 让出,impolite 忽略
信令WebRTC 标准外,必须自行实现——Curious 历史 解释了原因
下一步Ch3 WebSocket 信令服务器 替代手工复制

系列导航

章节主题状态
0架构全景与协议栈地图✅ 已发布
1浏览器媒体 API 与设备管理✅ 已发布
2第一个 P2P 视频通话✅ 已发布
3信令服务器设计与会话状态机✅ 已发布
4SDP 解剖与媒体协商✅ 已发布
5ICE、STUN、TURN 与 NAT 穿透✅ 已发布
6Data Channel 与 SCTP over DTLS✅ 已发布
7DTLS 握手与 SRTP 加密体系✅ 已发布
8RTP/RTCP 媒体传输与 QoS✅ 已发布
9音视频编解码与 Simulcast 入门✅ 已发布
10带宽估计与拥塞控制 GCC✅ 已发布
11Simulcast、SVC 与选择性订阅✅ 已发布
12SFU/MCU/Mesh 架构与 Pion 实战✅ 已发布
13调试工具链与可观测性✅ 已发布
14TURN 集群部署与多区域扩展✅ 已发布
15Capstone 生产级视频会议系统✅ 已发布

References

Logo
RainLib

Exploring the frontiers of technology, design, and distributed systems. Building tools for the future developers.

Suggestions & Feedback

© 2026 RainLib. Built for the Future.
All rights reserved.
System Normal