9、媒体协商与SDP:SDP协议结构详解、Offer/Answer交换过程、SDP的修改与协商(如添加/移除流)、BUNDLE与RTCP-mux

聊到WebRTC,有个东西你绕不开——SDP。说白了,它就是媒体协商的“谈判桌”。

我刚开始接触WebRTC时,看到SDP那密密麻麻的文本,头都大了。后来做多了才发现,它其实就几个关键字段在起作用。今天咱们就把这张“谈判桌”彻底拆开看看。

9.1 SDP协议结构详解

SDP全称Session Description Protocol,会话描述协议。它不是WebRTC专属,但WebRTC把它用到了极致。

一个典型的SDP长这样:

v=0
o=- 1234567890 1234567890 IN IP4 0.0.0.0
s=-
t=0 0
a=group:BUNDLE audio video
m=audio 9 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 110 112 113 126
c=IN IP4 0.0.0.0
a=rtpmap:111 opus/48000/2
a=rtcp-mux
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 102 122 127 121 125 107 108 109 124 120 123 119 114 115 116
c=IN IP4 0.0.0.0
a=rtpmap:96 VP8/90000
a=rtcp-mux

看着乱,其实结构很清晰。我习惯把它分成三层:

层级 字段 含义
会话层 v=, o=, s=, t= 版本、会话ID、会话名、时间
媒体层 m=, c= 媒体类型、端口、传输协议、编码格式
属性层 a= 扩展属性,如rtpmap、rtcp-mux、ssrc

每个m=行代表一个媒体流。音频一个m=,视频一个m=,数据通道也是一个m=。嗯,这里要注意:m=后面的数字是端口,WebRTC里通常写9,因为实际端口由ICE决定。

核心要点:SDP的本质就是告诉对方“我能收什么格式的媒体,我期望用什么方式传输”。

9.2 Offer/Answer交换过程

Offer/Answer是SDP交换的标准流程。我把它叫做“三次握手”的变种——只不过这次握的是媒体能力的手。

流程很简单:

  1. Offer方(发起方)创建SDP,描述自己支持的媒体能力
  2. Answer方(应答方)收到后,匹配双方能力,生成自己的SDP
  3. 双方各自设置远端SDP,开始传输

代码里是这样做的:

// 发起方创建Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 通过信令发送给远端

// 应答方收到Offer后
await pc.setRemoteDescription(offer);
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// 通过信令发送answer给发起方

// 发起方收到Answer后
await pc.setRemoteDescription(answer);

我曾经踩过一个坑:createOffer必须在setLocalDescription之前调用,而且不能重复调用。有一次我在setLocalDescription之后又调了一次createOffer,结果浏览器直接报错。后来养成习惯,每次创建前先检查signalingState。

避坑指南:setLocalDescription和setRemoteDescription的顺序不能乱。先setLocal再setRemote,这是铁律。我见过有人搞反了,结果协商一直失败。

9.3 SDP的修改与协商

实际项目中,媒体流不是一成不变的。比如用户中途关掉摄像头,或者想添加屏幕共享。这时候就需要重新协商。

重新协商的流程和首次一样,只是SDP内容变了。我常用的场景:

  • 添加流:调用addTrack后,重新createOffer
  • 移除流:调用removeTrack后,重新createOffer
  • 修改编码:通过修改SDP中的rtpmap或fmtp行

举个例子,添加屏幕共享:

// 获取屏幕共享流
const screenStream = await navigator.mediaDevices.getDisplayMedia({video: true});
const screenTrack = screenStream.getVideoTracks()[0];

// 添加到PeerConnection
pc.addTrack(screenTrack, screenStream);

// 触发重新协商
const newOffer = await pc.createOffer({iceRestart: false});
await pc.setLocalDescription(newOffer);
// 发送给远端...

这里有个细节:重新协商时,iceRestart参数要不要设?我建议除非网络切换,否则别设。设了ICE会重新选路,增加延迟。

注意:重新协商会打断正在进行的媒体传输。如果你在通话中频繁添加/移除流,用户可能会感觉到卡顿。我一般会加个防抖,等用户操作完再触发协商。

9.4 BUNDLE与RTCP-mux

这两个东西,说白了就是“省端口”的优化手段。

BUNDLE:把多个媒体流(音频、视频、数据)合并到同一个ICE传输通道上。没有BUNDLE的话,每个媒体流各占一个端口,ICE要维护多个连接,资源消耗大。

BUNDLE在SDP里长这样:

a=group:BUNDLE audio video

这行告诉对方:音频和视频走同一个传输通道。浏览器默认都支持BUNDLE,但如果你自己写信令,记得加上。

RTCP-mux:把RTP和RTCP合并到同一个端口。正常情况下,RTP走一个端口,RTCP走另一个端口(通常是RTP端口+1)。RTCP-mux让它们共用端口,省一个UDP端口。

SDP里这样表示:

a=rtcp-mux

我做过一个测试:没有BUNDLE和RTCP-mux时,一个音频+视频的会话需要4个端口(音频RTP、音频RTCP、视频RTP、视频RTCP)。开启后只需要1个端口。在移动端或者NAT穿透场景下,这差别太大了。

经验之谈:BUNDLE和RTCP-mux现在已经是WebRTC的标配了。如果你看到SDP里没有这两个属性,那八成是浏览器版本太老或者信令服务器没处理好。我建议在创建PeerConnection时,显式设置一下:

const config = {
  bundlePolicy: 'max-bundle',
  rtcpMuxPolicy: 'require'
};

9.5 知识体系总览

下面这张图,是我自己梳理的SDP协商全流程。你看一眼就能明白整个脉络:

SDP媒体协商全流程 发起方 (Offer) 应答方 (Answer) 1. createOffer() 2. setLocalDescription(offer) 信令通道传输Offer 3. setRemoteDescription(offer) 4. createAnswer() 5. setLocalDescription(answer) 信令通道传输Answer 6. setRemoteDescription(answer) ✅ 协商完成,开始传输媒体 BUNDLE + RTCP-mux

你看,整个流程其实就6步。发起方创建Offer,应答方生成Answer,最后双方设置完毕。BUNDLE和RTCP-mux在SDP里用a=行声明,浏览器会自动处理。

最后说一句:SDP这东西,你不需要背每个字段。记住m=、a=、c=这三类,再理解Offer/Answer的交换逻辑,就够用了。真遇到问题,打开浏览器控制台看SDP,一行一行对照着查,慢慢就熟了。


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