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交换的标准流程。我把它叫做“三次握手”的变种——只不过这次握的是媒体能力的手。
流程很简单:
- Offer方(发起方)创建SDP,描述自己支持的媒体能力
- Answer方(应答方)收到后,匹配双方能力,生成自己的SDP
- 双方各自设置远端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协商全流程。你看一眼就能明白整个脉络:
你看,整个流程其实就6步。发起方创建Offer,应答方生成Answer,最后双方设置完毕。BUNDLE和RTCP-mux在SDP里用a=行声明,浏览器会自动处理。
最后说一句:SDP这东西,你不需要背每个字段。记住m=、a=、c=这三类,再理解Offer/Answer的交换逻辑,就够用了。真遇到问题,打开浏览器控制台看SDP,一行一行对照着查,慢慢就熟了。
公众号:蓝海资料掘金营,微信deep3321