18、重协商(Re-negotiation):触发重协商的场景、setLocalDescription的再次调用、动态添加/移除轨道

重协商,说白了就是让两个 PeerConnection 重新坐下来,再谈一次。嗯,这个场景在实际项目中太常见了。我记得刚做 WebRTC 那会儿,以为连接建立好就万事大吉了,结果产品经理过来说:「我们要支持动态开关摄像头。」好嘛,重协商就这么来了。

什么时候需要重协商?

触发重协商的场景其实就几类,我帮你梳理一下:

  • 动态添加/移除轨道:比如用户中途打开麦克风、关闭摄像头。这是最常见的触发场景。
  • 编解码器偏好变化:比如你想从 VP8 切到 H.264,或者调整码率。
  • ICE 重新收集:网络切换了(WiFi 切 4G),需要重新收集候选者。
  • DTMF 或数据通道:有些实现里,创建数据通道也会触发重协商。

核心原则:只要你想改变媒体流的结构或参数,大概率需要重协商。

你想想看,为什么不能直接改?因为 SDP 是双方协商的结果。你这边改了,对面不知道,那不就乱套了?所以必须走一轮新的 Offer/Answer 交换。

setLocalDescription 的再次调用

重协商的核心操作,就是再次调用 setLocalDescription。很多人以为这玩意儿只在初始化时用一次,其实不是的。

流程大概是这样的:

  1. 你调用 pc.addTrack()pc.removeTrack()
  2. 然后调用 pc.createOffer() 生成新的 Offer。
  3. 再调用 pc.setLocalDescription(offer) 设置本地描述。
  4. 通过信令把 Offer 发给远端。
  5. 远端调用 pc.setRemoteDescription(offer),然后 pc.createAnswer(),再 pc.setLocalDescription(answer)
  6. 你这边收到 Answer 后,调用 pc.setRemoteDescription(answer)

嗯,这里要注意:setLocalDescription 是可以多次调用的。每次调用都会替换掉之前的本地描述。我在项目中遇到过有人担心会「覆盖」掉什么,其实不会,它本来就是设计成可重入的。

避坑指南:千万不要在 setLocalDescription 还没完成(Promise 还没 resolve)时,又去调下一次。我曾经因为这个踩过坑,导致 SDP 状态混乱,浏览器直接报 InvalidStateError

动态添加/移除轨道的正确姿势

动态添加轨道,代码其实很简单:

// 添加轨道
const stream = await navigator.mediaDevices.getUserMedia({ video: true });
const track = stream.getVideoTracks()[0];
const sender = pc.addTrack(track, stream);

// 触发重协商
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 然后通过信令发送 offer...

移除轨道也类似:

// 移除轨道
const senders = pc.getSenders();
for (const sender of senders) {
  if (sender.track && sender.track.kind === 'video') {
    pc.removeTrack(sender);
    break;
  }
}

// 触发重协商
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 发送 offer...

我个人习惯把重协商的逻辑封装成一个函数,比如 negotiate(),这样每次改完轨道直接调它就行,不用重复写 createOffer 和 setLocalDescription。

小技巧:可以用 pc.onnegotiationneeded 事件来监听是否需要重协商。但要注意,这个事件在某些浏览器上触发时机不太一致,我建议还是手动控制更靠谱。

重协商的完整流程图

下面这张图展示了重协商的完整流程,从添加轨道到 SDP 交换完成:

重协商(Re-negotiation)完整流程 本地端(Local) 远端(Remote) 1. addTrack() / removeTrack() 2. createOffer() 3. setLocalDescription(offer) 信令通道 4. setRemoteDescription(offer) 5. createAnswer() 6. setLocalDescription(answer) 信令通道 7. setRemoteDescription(answer) ✅ 重协商完成

重协商的注意事项

重协商看着简单,但坑不少。我列几个常见的:

问题 说明 建议
并发重协商 如果多次触发重协商,可能导致 SDP 状态错乱 加一个锁或队列,串行执行
轨道状态不一致 添加轨道后还没协商完,远端可能收不到数据 ontrack 事件触发后再操作
ICE 重启 重协商时可能触发 ICE 重启,导致短暂断流 评估业务场景,必要时用 iceRestart 参数
编解码器不匹配 新添加的轨道可能不支持远端已有的编解码器 提前检查 getCapabilities()

重要提醒:重协商不是免费的。每次重协商都会产生信令开销,而且可能引起短暂的媒体中断。所以不要频繁触发,比如用户快速开关摄像头时,最好加个防抖。

实际项目中的经验

我在做一个多人视频会议项目时,遇到过一个问题:用户 A 打开摄像头,触发重协商,结果用户 B 那边也同时在操作麦克风,两边同时发 Offer,导致信令冲突。后来我们加了一个「协商锁」,同一时间只允许一个人发起重协商,问题就解决了。

还有一个坑是 addTrackremoveTrack 的调用顺序。如果你先 remove 再 add,中间可能会有一个短暂的无轨道状态。我建议先 add 再 remove,或者用 replaceTrack 来替换轨道,这样更平滑。

个人习惯:我一般会在 setLocalDescription 之后加一个超时处理。如果 5 秒内没收到远端的 Answer,就主动取消这次重协商,避免死等。

嗯,重协商这块内容其实不难,但细节很多。你只要记住:改轨道 -> 发 Offer -> 等 Answer,这个循环就对了。剩下的就是处理各种边界情况,比如网络断开、用户快速操作等等。

最后说一句,setLocalDescription 的再次调用,说白了就是告诉浏览器:「嘿,我的媒体流变了,帮我生成新的 SDP。」浏览器会帮你处理好一切,你只需要保证调用时机正确就行。


公众号:蓝海资料掘金营,微信 deep3321