3、设置本地描述:setLocalDescription方法、本地SDP的生成与状态机、ICE角色与候选者收集触发
好,我们接着往下走。上一节我们聊了如何创建 Offer 和 Answer,生成了 SDP 字符串。但生成归生成,浏览器还没真正把它“当回事”。
你得告诉浏览器:“嘿,这就是我本地的媒体描述,请把它设置好。”
这个动作,就是 setLocalDescription。我个人觉得,这是整个协商流程里最容易出 bug 的地方之一。咱们今天把它彻底讲透。
3.1 setLocalDescription 到底干了什么?
先看一个最典型的调用方式:
const pc = new RTCPeerConnection();
// 创建 Offer
const offer = await pc.createOffer();
// 设置本地描述
await pc.setLocalDescription(offer);
console.log(pc.localDescription.sdp); // 此刻 SDP 才真正生效
你可能会问:“我都 createOffer 了,SDP 不就有了吗?为什么还要 setLocalDescription?”
嗯,这个问题我当年也困惑过。createOffer 只是生成了一个“提案”,它还没有被 PeerConnection 采纳。只有调用了 setLocalDescription,浏览器才会:
- 把 SDP 存入
pc.localDescription属性 - 根据 SDP 内容初始化媒体引擎(编码器、解码器、SSRC 等)
- 触发 ICE 代理开始收集候选者
- 更新 PeerConnection 的内部状态机
说白了,createOffer 是“写草稿”,setLocalDescription 是“正式提交”。
重要: 如果你不调 setLocalDescription,直接拿着 SDP 字符串通过信令发给对方,对方是没法用的。因为你的浏览器根本没准备好接收媒体。
3.2 本地 SDP 的生成与状态机
WebRTC 内部维护了一个严格的状态机。这个状态机控制着你什么时候能做什么操作。我画了一张图,帮你理清脉络:
状态机其实不复杂。核心就三个状态:stable、have-local-offer、have-remote-offer。你每次调用 setLocalDescription 或 setRemoteDescription,状态就会发生转移。
举个例子:
- 初始是
stable。你调setLocalDescription(offer),状态变成have-local-offer。 - 对方发来 Answer,你调
setRemoteDescription(answer),状态回到stable。 - 反过来,如果先收到对方的 Offer,你调
setRemoteDescription(offer),状态变成have-remote-offer。 - 然后你创建 Answer,调
setLocalDescription(answer),状态又回到stable。
注意: 你不能在 have-local-offer 状态下再次调用 setLocalDescription(offer)。浏览器会抛出一个 InvalidStateError。我曾经在调试一个重协商场景时,忘了检查当前状态,结果花了整整一个下午才定位到这个问题。
3.3 ICE 角色与候选者收集触发
调完 setLocalDescription 之后,另一件大事发生了:ICE 代理被激活。
ICE(Interactive Connectivity Establishment)负责帮你找到一条能通到对方的网络路径。它有两种角色:
| 角色 | 说明 | 谁担任 |
|---|---|---|
| controlling | 控制方,负责最终选择哪条候选者路径 | 发起 Offer 的一方(通常) |
| controlled | 被控制方,听从控制方的决策 | 应答 Answer 的一方(通常) |
你可能会问:“角色是怎么确定的?”
嗯,这其实是在 setLocalDescription 时决定的。Offer 方默认成为 controlling,Answer 方默认成为 controlled。但你可以通过 iceControlling 选项手动覆盖,不过我个人不建议这么做——除非你有非常特殊的场景。
角色确定后,ICE 代理开始收集候选者(candidates)。候选者就是你的机器可能被外界访问到的网络地址。收集过程大致分三步:
- Host 候选者:本机网卡的 IP 地址。这个最快,几乎瞬间就能拿到。
- Server Reflexive 候选者:通过 STUN 服务器获取的公网 IP 和端口。这需要发一个 STUN 请求出去,等响应回来。
- Relay 候选者:通过 TURN 服务器获取的中继地址。这是最后的手段,延迟最高,但穿透率最强。
收集到的候选者会通过 onicecandidate 回调逐个通知你:
pc.onicecandidate = (event) => {
if (event.candidate) {
// 通过信令发送给远端
signaling.send({
type: 'candidate',
candidate: event.candidate
});
} else {
// candidate 为 null,表示收集完毕
console.log('所有候选者已收集完成');
}
};
小技巧: 我习惯在 setLocalDescription 之后立即监听 onicecandidate。有些同学会提前监听,结果发现漏掉了最开始几个候选者。记住:候选者收集是在 setLocalDescription 之后才触发的,所以先监听再设置,顺序别搞反。
3.4 一个完整的本地描述设置流程
把上面所有知识点串起来,一个标准的 Offer 方流程大概是这样的:
// 1. 创建 PeerConnection
const pc = new RTCPeerConnection({
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:turn.example.com:3478',
username: 'user',
credential: 'pass'
}
]
});
// 2. 监听候选者(先监听!)
pc.onicecandidate = (event) => {
if (event.candidate) {
signaling.send({ type: 'candidate', candidate: event.candidate });
}
};
// 3. 监听连接状态变化
pc.oniceconnectionstatechange = () => {
console.log('ICE 状态:', pc.iceConnectionState);
};
// 4. 创建并设置本地描述
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 5. 通过信令发送 Offer
signaling.send({ type: 'offer', sdp: pc.localDescription.sdp });
你看,步骤很清晰。但有一个细节我当年踩过坑:千万不要在 setLocalDescription 完成之前发送 SDP。虽然 await 能保证顺序,但如果你用了回调风格,就很容易出问题。
核心要点回顾:
setLocalDescription让 SDP 正式生效,触发媒体引擎初始化- 状态机控制操作合法性,
stable→have-local-offer→stable是标准路径 - ICE 角色在设置本地描述时确定,controlling 负责最终选路
- 候选者收集在
setLocalDescription之后自动触发,先监听再设置
好了,这一节的内容就到这。下一节我们会聊 setRemoteDescription,也就是如何处理对方发来的 SDP。到时候你会发现,很多思路是相通的。