WebRTC 全景实战 (2):第一个 P2P 视频通话
"跑通第一个通话,比读完十页 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、媒体收发 |
| JSEP | JavaScript Session Establishment Protocol | RFC 8829,定义浏览器如何用 Offer/Answer 交换 SDP |
| Offer | — | 发起方的会话描述,包含媒体能力、ICE 参数、DTLS 指纹 |
| Answer | — | 应答方的会话描述,确认选择的编解码器与网络参数 |
| SDP | Session Description Protocol | 文本格式的会话描述,详见 Ch4 |
| ICE | Interactive Connectivity Establishment | NAT 穿透协议,通过候选地址配对找到最优路径,详见 Ch5 |
| ICE Candidate | — | 一个可用的网络地址(IP:Port + 类型:host/srflx/relay) |
| 信令 | Signaling | 在 PeerConnection 之外传递 SDP 和 ICE Candidate 的控制通道 |
| Trickle ICE | — | 边收集 ICE Candidate 边发送,而非等全部收集完毕 |
| Glare | 冲突 | 双方同时发送 Offer 导致的协商冲突 |
| Perfect Negotiation | — | W3C 推荐的无冲突协商模式,通过 polite/impolite 角色解决 glare |
| 信令状态 | signalingState | stable → have-local-offer → have-remote-offer → closed |
| ICE 连接状态 | iceConnectionState | new → checking → connected → completed / failed |
| 连接状态 | connectionState | 综合 ICE + DTLS 的整体状态:connecting → connected → closed |
| Unified Plan | — | 现代 SDP 语义,每个 m-line 对应一个 transceiver(Plan B 已废弃) |
| Transceiver | RTCRtpTransceiver | 绑定 sender + receiver 的媒体通道,Unified Plan 的核心单元 |
一、RTCPeerConnection 在协议栈中的位置
RTCPeerConnection 是浏览器暴露给开发者的「黑盒」——你负责交换 SDP 和 ICE Candidate(信令),浏览器自动完成 ICE 连通性检查、DTLS 握手、SRTP 密钥协商和媒体传输。
1.1 开发者职责 vs 浏览器职责
| 开发者负责(信令层) | 浏览器自动完成(媒体层) |
|---|---|
| 创建 / 转发 Offer / Answer | ICE 候选收集与连通性检查 |
| 转发 ICE Candidate | STUN 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 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 | 本机网卡地址 | 同一局域网直连 |
srflx | STUN 反射的公网地址 | 跨 NAT 但可穿透 |
relay | TURN 中继地址 | 对称 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 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 角色分配策略
| 场景 | Polite | Impolite |
|---|---|---|
| 1v1 通话 | Callee(后加入方) | Caller(先发起方) |
| 多人会议 | 后加入 Room 的 peer | 先发布媒体的 peer |
| 通用规则 | peerId 较大者 | peerId 较小者 |
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: 三个排查点:
- 对端是否成功
addTrack并在setLocalDescription之前完成 ontrack回调是否正确设置remoteVideo.srcObject- 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: 必须先 addTrack 再 createOffer,否则 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 | 状态面板显示角色 |
| 2 | Caller 点击「创建 Offer」 | 文本框出现 JSON bundle;signalingState = have-local-offer |
| 3 | 复制 JSON 到 Callee,点击「创建 Answer」 | Callee 出现 Answer JSON;signalingState = stable |
| 4 | 复制 Answer 回 Caller,点击「完成连接」 | 双方 iceConnectionState → connected |
| 5 | 双方看到远端视频画面 | ontrack 触发,remoteVideo 有画面 |
| 6 | 打开 chrome://webrtc-internals | 观察 ICE pair 选中过程、DTLS 握手、SRTP 统计 |
10.3 扩展挑战
- Trickle ICE:改造 Demo,每个
onicecandidate立即显示,不等 Gathering 完成 - ICE 状态面板:实时展示四维状态(signaling / gathering / ice / connection)
- 自动重连:
iceConnectionState === "failed"时自动restartIce - 准备 Ch3:将
sendSignal抽象为接口,为 WebSocket 接入做准备 - 跨网络测试:两台不同设备通过 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。媒体带宽不经过信令服务器。
十二、本章小结
| 要点 | 内容 |
|---|---|
| 核心 API | RTCPeerConnection + addTrack + ontrack |
| 协商模型 | JSEP Offer/Answer:createOffer → setLocalDescription → 信令 → setRemoteDescription |
| ICE | onicecandidate 产出 → 信令 → addIceCandidate 消费 |
| 状态监控 | signalingState / iceGatheringState / iceConnectionState / connectionState 四层 |
| 冲突处理 | Perfect Negotiation:polite 让出,impolite 忽略 |
| 信令 | WebRTC 标准外,必须自行实现——Curious 历史 解释了原因 |
| 下一步 | Ch3 WebSocket 信令服务器 替代手工复制 |
系列导航
章节 主题 状态 0 架构全景与协议栈地图 ✅ 已发布 1 浏览器媒体 API 与设备管理 ✅ 已发布 2 第一个 P2P 视频通话 ✅ 已发布 3 信令服务器设计与会话状态机 ✅ 已发布 4 SDP 解剖与媒体协商 ✅ 已发布 5 ICE、STUN、TURN 与 NAT 穿透 ✅ 已发布 6 Data Channel 与 SCTP over DTLS ✅ 已发布 7 DTLS 握手与 SRTP 加密体系 ✅ 已发布 8 RTP/RTCP 媒体传输与 QoS ✅ 已发布 9 音视频编解码与 Simulcast 入门 ✅ 已发布 10 带宽估计与拥塞控制 GCC ✅ 已发布 11 Simulcast、SVC 与选择性订阅 ✅ 已发布 12 SFU/MCU/Mesh 架构与 Pion 实战 ✅ 已发布 13 调试工具链与可观测性 ✅ 已发布 14 TURN 集群部署与多区域扩展 ✅ 已发布 15 Capstone 生产级视频会议系统 ✅ 已发布