在搭建 WebRTC 应用时,开发者经常会遇到一个看似简单、实则影响架构的问题:接收端(callee)应该在收到对方的 SDP offer 之后再创建 RTCPeerConnection,还是在信令通道建立时就提前创建好?
这两种做法都能跑通,但在资源占用、连接速度、状态管理等方面差异不小。本文先对比两种方式的核心差异,再补充几个实际开发中容易踩坑的细节,最后给出场景化的选型建议。
方式 1:收到 SDP 后再创建 RTCPeerConnection(按需创建)
流程
- 接收端通过信令通道收到
offer。 - 此时才
new RTCPeerConnection(config)。 - 调用
setRemoteDescription(offer)。 - 创建
answer、setLocalDescription(answer),并通过信令通道发回。
示例代码
let peerConnection;const pendingCandidates = []; // 用于 buffer 提前到达的 candidate
async function handleOffer(offer) { peerConnection = new RTCPeerConnection(config);
peerConnection.onicecandidate = event => { if (event.candidate) { sendToSignalingServer({ type: "candidate", candidate: event.candidate }); } };
peerConnection.ontrack = event => { // 把远端的媒体流挂到 video 元素上 remoteVideo.srcObject = event.streams[0]; };
peerConnection.onconnectionstatechange = () => { if (["failed", "disconnected", "closed"].includes(peerConnection.connectionState)) { cleanup(); } };
await peerConnection.setRemoteDescription(new RTCSessionDescription(offer));
// 处理在 setRemoteDescription 之前到达的 ICE candidate for (const c of pendingCandidates) { await peerConnection.addIceCandidate(c); } pendingCandidates.length = 0;
const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer);
sendToSignalingServer({ type: "answer", answer: peerConnection.localDescription });}
async function handleRemoteCandidate(candidate) { if (peerConnection && peerConnection.remoteDescription) { await peerConnection.addIceCandidate(candidate); } else { pendingCandidates.push(candidate); }}优点
- 资源高效:没有通话时不占用 ICE agent、STUN/TURN 连接等资源。
- 适应性强:可以根据 offer 内容动态决定
iceServers、bundlePolicy等配置(例如不同的房间走不同的 TURN)。 - 清理逻辑简单:没有”空连接”需要管理。
缺点
- 首次建连略慢:从收到 offer 到开始 ICE 收集存在一小段串行延迟。
- 需要 buffer ICE candidate:信令服务器可能先转发对端的 candidate 再转发 offer,必须妥善处理时序。
方式 2:提前创建 RTCPeerConnection,等待 SDP
流程
- 信令通道(WebSocket / SSE)一建立,立刻
new RTCPeerConnection(config)。 - 收到
offer后直接setRemoteDescription→createAnswer→setLocalDescription。
示例代码
const peerConnection = new RTCPeerConnection(config);
peerConnection.onicecandidate = event => { if (event.candidate) { sendToSignalingServer({ type: "candidate", candidate: event.candidate }); }};
peerConnection.ontrack = event => { remoteVideo.srcObject = event.streams[0];};
async function handleOffer(offer) { await peerConnection.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await peerConnection.createAnswer(); await peerConnection.setLocalDescription(answer); sendToSignalingServer({ type: "answer", answer: peerConnection.localDescription });}优点
- 建连更快:ICE 收集可以与信令并行(虽然在没有 remote description 之前作用有限)。
- 适合长连接复用:在房间型应用、SFU 客户端、远程协作工具中,连接生命周期较长,预先创建更自然。
缺点
- 资源闲置:即使没人来连,
RTCPeerConnection也会持有内部状态。如果配置了 TURN,TURN 分配甚至会在setLocalDescription后产生流量。 - 状态管理更复杂:需要超时清理、失败重建、避免在错误状态下复用。
- 配置不够灵活:连接参数必须在 offer 到达前就确定。
容易被忽略的几个技术细节
不管选哪种方式,下面这些点都值得在代码里显式处理。
1. ICE candidate 的时序问题
WebRTC 的 trickle ICE 机制下,candidate 可以在 SDP 之后陆续到达,但信令服务器并不保证 offer 一定先于 candidate 送达接收端。如果接收端在没有 remote description 的情况下调用 addIceCandidate,浏览器会抛错。
解决办法就是上面示例里的 buffer 模式:先攒着,等 setRemoteDescription 完成再统一 addIceCandidate。
2. ontrack 与 addTrack / addTransceiver
原始笔记的代码只展示了协商,没有处理媒体本身。实际开发中:
- 接收端需要
peerConnection.ontrack = ...来拿到远端流。 - 如果接收端也要发送本地流(双向通话),需要
peerConnection.addTrack(track, stream)或显式addTransceiver,并且要在createAnswer之前完成。
3. 连接状态监听与清理
peerConnection.onconnectionstatechange = () => { console.log("connection state:", peerConnection.connectionState);};
peerConnection.oniceconnectionstatechange = () => { console.log("ice state:", peerConnection.iceConnectionState);};当状态变为 failed 或 disconnected 时,根据业务决定是触发 ICE restart 还是直接 peerConnection.close()。否则连接会一直挂着,浏览器内存和网络资源得不到释放。
4. Perfect Negotiation 模式
如果你的场景中双方都可能主动发起 offer(例如随时切换摄像头、添加 screen share 触发 renegotiation),强烈建议采用 W3C 推荐的 Perfect Negotiation 模式:
- 给一端标记
polite,另一端标记impolite。 - 通过
signalingState与makingOffer标志检测 glare(双方同时发 offer)。 - polite 端在冲突时回滚自己的 offer,impolite 端忽略对方的 offer。
这个模式天然兼容方式 2(预先创建),并且能优雅处理任意时刻的 renegotiation。
5. TURN 凭证与配置时效性
如果你用的是带时效性的 TURN 凭证(例如 Twilio、Coturn 的 REST API 模式),方式 2 提前创建连接的话,凭证在等待 offer 到达时可能已经过期。这种场景下方式 1 更安全,或者在方式 2 中实现凭证刷新逻辑。
怎么选?场景化建议
| 场景 | 推荐方式 | 理由 |
|---|---|---|
| 1 对 1 视频通话(短时、按需) | 方式 1 | 资源按需占用,配置灵活 |
| 房间型应用 / 多人会议客户端 | 方式 2 | 长连接复用,预先准备 ICE |
| SFU / MCU 客户端 | 方式 2 | 连接长期存在,需要快速接入新流 |
| 云游戏 / 远程桌面 | 方式 2 | 对建连延迟敏感,连接长期持有 |
| 客服/在线咨询入口 | 方式 1 | 大部分访客不会真的发起通话,避免无谓资源消耗 |
| 移动端弱网/省电场景 | 方式 1 | 减少后台 ICE/TURN 流量与电量消耗 |
总结
- **方式 1(按需创建)**是大多数 1 对 1 通话的合理默认值,资源利用率高、配置灵活,缺点是首次建连略慢、需要 buffer ICE candidate。
- **方式 2(提前创建)**适合长连接、对建连延迟敏感、或需要随时 renegotiation 的场景,但要付出额外的状态管理成本。
- 不管选哪种,都要把 ICE candidate buffer、
ontrack处理、连接状态清理 这三件事做对。 - 如果应用涉及双向 renegotiation,直接套用 Perfect Negotiation 模式可以省掉很多边界情况的代码。
架构选型没有银弹,关键是把每种方式背后的资源/延迟权衡想清楚,再结合业务的连接生命周期做决定。