1. SDP 概述
1.1 什么是 SDP
SDP (Session Description Protocol) 全称是"会话描述协议"。简单来说,SDP 就像是两个人打电话之前互相交换的"名片",告诉对方自己支持什么功能、用什么方式通信。
生活中的类比
想象一下你要和一个新认识的朋友建立视频通话:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 生活中的"SDP交换"类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
你(A) 朋友(B)
│ │
│ "你好,我支持以下功能:" │
│ - 可以视频通话 │
│ - 可以语音通话 │
│ - 我的网络地址是 xxx │
│ - 我使用 VP8 视频编码 │
│ ────────────────────────────────────────────────────────>│
│ │
│ "收到!我支持以下功能:" │
│ - 视频通话 OK │
│ - 我也用 VP8 │
│ - 我的地址是 yyy │
│ <────────────────────────────────────────────────────────│
│ │
│ ═════════════════════════════════════════════════════════
│ 双方确认了共同的能力,开始通话
│ ═════════════════════════════════════════════════════════
SDP 就是这个"功能介绍名片"的标准化格式,让不同厂商、不同平台的设备能够互相理解对方的能力。
为什么需要 SDP?
在 WebRTC 视频通话中,双方需要协商很多事情:
| 需要协商的内容 | 通俗解释 | SDP 中的体现 |
|---|---|---|
| 音频编解码器 | 双方用什么格式压缩音频 | Opus、PCMA、PCMU 等 |
| 视频编解码器 | 双方用什么格式压缩视频 | VP8、VP9、H.264 等 |
| 网络地址 | 双方通过什么地址连接 | ICE 候选者 |
| 加密方式 | 如何保证通话安全 | DTLS 指纹 |
| 媒体方向 | 是发送、接收还是双向 | sendrecv、sendonly 等 |
1.2 SDP 在 WebRTC 中的作用
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 在 WebRTC 通话中的位置 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
呼叫方 A 信令服务器 被呼叫方 B
│ │ │
│ │ │
│ ┌─────────────────────┐ │ │
│ │ 第一步:创建 Offer │ │ │
│ │ │ │ │
│ │ Offer 就像是 A 的 │ │ │
│ │ "简历",告诉 B: │ │ │
│ │ - 我支持哪些编码 │ │ │
│ │ - 我的网络地址 │ │ │
│ │ - 我的安全证书 │ │ │
│ └─────────────────────┘ │ │
│ │ │
│ 发送 Offer ──────────────────────────────────>│ │
│ │ │
│ │ 转发 Offer ─────────────────>│
│ │ │
│ │ ┌──────────┴──────────┐
│ │ │ 第二步:创建 Answer │
│ │ │ │
│ │ │ Answer 是 B 的回复:│
│ │ │ - 我选择 VP8 编码 │
│ │ │ - 我的网络地址 │
│ │ │ - 确认安全连接 │
│ │ └─────────────────────┘
│ │ │
│ │ 返回 Answer │
│ 接收 Answer <─────────────────────────────────│ <─────────────────────────────│
│ │ │
│ ┌─────────────────────┐ │ │
│ │ 第三步:确认连接 │ │ │
│ │ │ │ │
│ │ A 收到 Answer 后 │ │ │
│ │ 知道了 B 的选择 │ │ │
│ │ 开始建立 P2P 连接 │ │ │
│ └─────────────────────┘ │ │
│ │ │
▼ ▼ ▼
简单总结:SDP 是 WebRTC 通话的"握手协议",双方通过交换 SDP 来达成一致,然后才能开始真正的音视频传输。
1.3 SDP 的四大核心作用
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 的四大核心作用 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 媒体协商 - "我们用什么格式通话?" │
│ │
│ 想象两个人要对话,首先得确定用什么语言: │
│ - A 说:我会说中文、英文、日文 │
│ - B 说:我会中文和英文,那我们用中文吧 │
│ │
│ SDP 中:A 列出所有支持的编解码器,B 选择一个双方都支持的 │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2️⃣ 网络协商 - "我们怎么找到对方?" │
│ │
│ 就像寄快递需要知道地址: │
│ - A 告诉 B:我的地址是 xxx,你可以通过这些路径找到我 │
│ - B 告诉 A:我的地址是 yyy,你可以这样连接我 │
│ │
│ SDP 中:通过 ICE 候选者交换网络地址信息 │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 3️⃣ 安全协商 - "我们怎么保证通话安全?" │
│ │
│ 就像两个人约定暗号: │
│ - A 发送自己的"指纹"(证书哈希值) │
│ - B 验证指纹后,建立加密连接 │
│ │
│ SDP 中:通过 DTLS 指纹确保通信安全 │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 4️⃣ 能力协商 - "我们能做什么?" │
│ │
│ 确定双方的能力边界: │
│ - 是否支持视频? │
│ - 是否只接收不发送? │
│ - 带宽限制是多少? │
│ │
│ SDP 中:通过媒体方向属性和带宽参数描述 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2. SDP 结构详解
2.1 SDP 整体结构
SDP 文件看起来像一堆乱码,但其实结构很清晰。可以把 SDP 想象成一份"会议议程":
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 结构类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
一份会议议程的结构:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ 会议基本信息(会话级别) │ │
│ │ │ │
│ │ 会议名称:产品讨论会 │ │
│ │ 发起人:张三 │ │
│ │ 时间:2024年1月1日 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ │
│ │ 议题一:音频讨论(媒体级别) │ │ 议题二:视频演示(媒体级别) │ │
│ │ │ │ │ │
│ │ 讨论内容:音频编码选择 │ │ 演示内容:视频编码选择 │ │
│ │ 参与方式:双向交流 │ │ 参与方式:双向交流 │ │
│ │ │ │ │ │
│ └─────────────────────────────────────┘ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
对应的 SDP 结构:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ ┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ │ Session Level (会话级别) │ │
│ │ │ │
│ │ v= (协议版本) → 会议格式版本 │ │
│ │ o= (会话发起者) → 发起人信息 │ │
│ │ s= (会话名称) → 会议名称 │ │
│ │ t= (会话时间) → 会议时间 │ │
│ │ │ │
│ └─────────────────────────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ┌───────────────────┴───────────────────┐ │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ ┌─────────────────────────────────────┐ │
│ │ Media Level (媒体级别) #1 │ │ Media Level (媒体级别) #2 │ │
│ │ │ │ │ │
│ │ m=audio ... (音频媒体描述) │ │ m=video ... (视频媒体描述) │ │
│ │ a=rtpmap:... (编解码器) │ │ a=rtpmap:... (编解码器) │ │
│ │ a=sendrecv (双向通信) │ │ a=sendrecv (双向通信) │ │
│ │ │ │ │ │
│ └─────────────────────────────────────┘ └─────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
关键理解:
- 会话级别:描述整个通话的基本信息,对所有媒体流都适用
- 媒体级别:描述具体的音频或视频流,每个媒体流有自己的配置
2.2 SDP 行类型详解
SDP 的每一行都有特定含义,格式为 类型=值。下面用通俗的方式解释每一行:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 行类型通俗解释 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
会话级别行 (Session Level) - 描述整个通话的基本信息:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 类型 │ 名称 │ 通俗解释 │ 是否必需 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ v= │ 版本 │ "这是第几版的格式?" │ 必需(目前固定为 0) │
│ o= │ 发起者 │ "谁发起的这个通话?" │ 必需 │
│ s= │ 会话名称 │ "这次通话叫什么名字?" │ 必需(通常用 "-" 表示无名称) │
│ t= │ 时间 │ "通话什么时候开始和结束?" │ 必需(0 0 表示永久) │
│ c= │ 连接信息 │ "用什么地址连接?" │ 可选 │
│ a= │ 属性 │ "有什么特殊要求?" │ 可选 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
媒体级别行 (Media Level) - 描述具体的音频或视频流:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 类型 │ 名称 │ 通俗解释 │ 是否必需 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ m= │ 媒体描述 │ "这是什么媒体?用什么编码?" │ 必需 │
│ c= │ 连接信息 │ "这个媒体用什么地址?" │ 可选 │
│ a= │ 属性 │ "这个媒体有什么特性?" │ 可选 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2.3 完整 SDP 示例解析
下面是一个真实的 SDP 示例,我们逐行解读:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 完整 SDP 示例及逐行解读 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
═══════════════════════════════════════════════════════════════════════════════════════════════
会话级别部分 - 描述整个通话的基本信息
═══════════════════════════════════════════════════════════════════════════════════════════════
v=0
└── 【协议版本】SDP 格式版本号,目前固定为 0,表示使用标准 SDP 格式
通俗理解:就像文件的版本号,告诉接收方"这是第几版的格式"
o=- 1234567890 1234567890 IN IP4 192.168.1.100
└── 【会话发起者】
├── - : 用户名(WebRTC 中通常为空,用 - 表示)
├── 1234567890 : 会话 ID(唯一标识这次通话,相当于"会议编号")
├── 1234567890 : 会话版本(每次 SDP 变更递增,相当于"修订版本号")
├── IN : 网络类型(IN = Internet)
├── IP4 : 地址类型(IPv4)
└── 192.168.1.100 : 发起者的 IP 地址
通俗理解:就像会议邀请函上的"发起人信息"
s=-
└── 【会话名称】WebRTC 中通常没有具体名称,用 - 表示
通俗理解:这次通话的名字,WebRTC 不需要命名,所以用 - 占位
t=0 0
└── 【会话时间】0 0 表示永久会话,没有固定的开始和结束时间
通俗理解:通话什么时候开始和结束?0 0 表示"随时可以开始,没有截止时间"
a=group:BUNDLE 0 1
└── 【BUNDLE 分组】将音频(mid:0)和视频(mid:1)复用到同一个连接
好处:只需要一个端口,简化 NAT 穿透
通俗理解:把音频和视频"打包"在一起传输,就像把多个文件打包成一个 ZIP
a=msid-semantic: WMS localStream
└── 【MSID 语义】定义媒体流的标识方式
WMS = WebRTC Media Stream
localStream = 媒体流的 ID
通俗理解:告诉对方"我的媒体流叫什么名字"
═══════════════════════════════════════════════════════════════════════════════════════════════
媒体级别部分 - 音频媒体描述
═══════════════════════════════════════════════════════════════════════════════════════════════
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 103 104 9 0 8 106 105 13 110 112 113 126
└── 【音频媒体描述】
├── audio : 媒体类型是音频
├── 9 : 端口号(ICE 场景中为占位符,实际端口由 ICE 确定)
│ 通俗理解:9 是一个特殊的"丢弃"端口,表示"实际端口待定"
├── UDP/TLS/RTP/SAVPF : 传输协议(安全的 RTP over UDP)
│ 通俗理解:用 UDP 传输,加上 TLS 加密,使用 RTP 协议
└── 111 63 103... : 支持的音频格式列表(Payload Type)
通俗理解:这些数字是"菜单编号",对应不同的音频编码
c=IN IP4 0.0.0.0
└── 【连接信息】IP 地址为 0.0.0.0,表示由 ICE 协商确定实际地址
通俗理解:地址还没确定,等 ICE 协商后再填
a=rtcp:9 IN IP4 0.0.0.0
└── 【RTCP 端口】RTCP 控制通道的端口
通俗理解:RTCP 是用来传输控制信息的通道,比如"我收到了多少数据包"
a=ice-ufrag:abcd
└── 【ICE 用户名】用于 ICE 连接认证,相当于"登录名"
通俗理解:ICE 连接需要验证身份,这是用户名
a=ice-pwd:abcdefghijklmnopqrstuvwx
└── 【ICE 密码】用于 ICE 连接认证,相当于"登录密码"
通俗理解:配合用户名使用,验证对方身份
a=ice-options:trickle
└── 【ICE 选项】支持 Trickle ICE(逐步发送 ICE 候选者)
通俗理解:不用等所有地址都收集完再发送,收集到一个就发一个
a=fingerprint:sha-256 12:34:56:78:90:AB:CD:EF...
└── 【DTLS 指纹】DTLS 证书的哈希值,用于验证连接安全
通俗理解:这是"数字身份证"的指纹,确保连接不被冒充
a=setup:actpass
└── 【DTLS 角色】actpass 表示可以接受任何角色
Offer 中用 actpass,Answer 中会确定为 active 或 passive
通俗理解:"我可以主动连接你,也可以等你连接我"
a=mid:0
└── 【媒体标识】这个音频流的 ID 是 0,用于 BUNDLE 关联
通俗理解:给音频流一个编号,方便在 BUNDLE 中识别
a=sendrecv
└── 【媒体方向】sendrecv 表示既能发送也能接收音频
通俗理解:双向通话,既能说也能听
a=rtcp-mux
└── 【RTCP 复用】RTP 和 RTCP 使用同一个端口
通俗理解:数据和控制信息走同一个通道,减少端口占用
a=rtpmap:111 opus/48000/2
└── 【编解码器映射】Payload Type 111 对应 Opus 编解码器
├── 48000 : 采样率 48kHz(每秒采样 48000 次)
└── 2 : 双声道(立体声)
通俗理解:编号 111 对应 Opus 音频编码,音质很好
a=fmtp:111 minptime=10;useinbandfec=1
└── 【编解码器参数】Opus 的具体配置
├── minptime=10 : 最小打包时间 10ms
│ 通俗理解:每 10ms 打包一次音频数据
└── useinbandfec=1 : 启用带内前向纠错
通俗理解:丢包时可以恢复部分数据,不需要重传
a=ssrc:12345678 cname:user1
a=ssrc:12345678 msid:localStream audioTrack
└── 【SSRC 标识】同步源标识符,用于标识这个音频流
12345678 是随机生成的唯一 ID
通俗理解:这是音频流的"身份证号"
═══════════════════════════════════════════════════════════════════════════════════════════════
媒体级别部分 - 视频媒体描述
═══════════════════════════════════════════════════════════════════════════════════════════════
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101 127 124 125
└── 【视频媒体描述】
├── video : 媒体类型是视频
└── 96 97 98... : 支持的视频格式列表
通俗理解:这些是支持的视频编码"菜单编号"
a=mid:1
└── 【媒体标识】这个视频流的 ID 是 1
通俗理解:给视频流一个编号,方便在 BUNDLE 中识别
a=rtpmap:96 VP8/90000
└── 【编解码器映射】Payload Type 96 对应 VP8 编解码器
90000 是视频的标准时钟频率
通俗理解:编号 96 对应 VP8 视频编码,Google 开发的免费编码
a=rtpmap:100 H264/90000
a=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
└── 【H.264 编解码器】
├── profile-level-id=42e01f : 使用 Constrained Baseline Profile
│ 通俗理解:这是硬件加速支持最好的 H.264 配置
└── packetization-mode=1 : 非交错模式
通俗理解:最常用的打包方式,兼容性最好
a=ssrc:87654321 cname:user1
a=ssrc:87654321 msid:localStream videoTrack
└── 【SSRC 标识】视频流的唯一标识符
通俗理解:这是视频流的"身份证号"
3. 关键 SDP 字段详解
3.1 会话级别字段详解
v= (Version) - 协议版本
v=0
通俗解释: 这就像文件的版本号。目前 SDP 协议只有一个版本,所以永远写 v=0。
为什么需要: 如果将来 SDP 格式升级,接收方可以根据版本号知道如何解析。
o= (Origin) - 会话发起者
o=<username> <sess-id> <sess-version> <nettype> <addrtype> <unicast-address>
示例:o=- 1234567890 1234567890 IN IP4 192.168.1.100
通俗解释: 这就像会议邀请函上的"发起人信息"。
| 字段 | 示例值 | 通俗解释 |
|---|---|---|
| username | - | 发起者用户名,WebRTC 中通常为空 |
| sess-id | 1234567890 | 会话唯一标识,相当于"会议编号" |
| sess-version | 1234567890 | 会话版本,每次 SDP 变更递增 |
| nettype | IN | 网络类型,IN = Internet |
| addrtype | IP4 | 地址类型,IP4 或 IP6 |
| unicast-address | 192.168.1.100 | 发起者的 IP 地址 |
重要提示: sess-version 每次重新协商时必须递增,这样对方才知道这是新的 SDP。
s= (Session Name) - 会话名称
s=-
通俗解释: 会话的名称。WebRTC 中通常没有具体名称,用 - 表示。
t= (Timing) - 会话时间
t=<start-time> <stop-time>
示例:t=0 0
通俗解释: 会话的开始和结束时间。
0 0表示永久会话,没有固定的开始和结束时间- WebRTC 视频通话通常是
t=0 0
a=group:BUNDLE - BUNDLE 分组
a=group:BUNDLE 0 1
通俗解释: 把音频和视频"打包"在一起传输。
为什么需要 BUNDLE?
没有 BUNDLE 时:
┌─────────────────┐ ┌─────────────────┐
│ 音频端口 │ │ 视频端口 │
│ 50000 │ │ 50002 │
└────────┬────────┘ └────────┬────────┘
│ │
│ 需要两个端口,NAT 穿透更复杂
│ │
▼ ▼
使用 BUNDLE 后:
┌─────────────────────────────────────────┐
│ 音频和视频共用一个端口 │
│ 50000 │
└────────────────────┬────────────────────┘
│
│ 只需要一个端口,NAT 穿透更简单
│
▼
通俗理解: BUNDLE 就像把多个快递打包成一个包裹,只需要一个地址就能送达。
3.2 媒体级别字段详解
m= (Media) - 媒体描述
m=<media> <port> <proto> <fmt> ...
示例:m=audio 9 UDP/TLS/RTP/SAVPF 111 63 103 104 9 0 8
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 99 100 101
通俗解释: 这一行告诉对方"我要发送什么类型的媒体,用什么协议,支持哪些编码格式"。
| 字段 | 示例值 | 通俗解释 |
|---|---|---|
| media | audio/video | 媒体类型:音频或视频 |
| port | 9 | 端口号(ICE 场景中为占位符) |
| proto | UDP/TLS/RTP/SAVPF | 传输协议(安全 RTP) |
| fmt | 111 63 103... | 支持的格式列表(Payload Type) |
关于端口号 9:
- 在 WebRTC 中,实际端口由 ICE 协商确定
- SDP 中的端口号只是一个占位符
- 9 是一个特殊的"丢弃"端口,表示"待定"
a=ice-ufrag / a=ice-pwd - ICE 认证
a=ice-ufrag:abcd
a=ice-pwd:abcdefghijklmnopqrstuvwx
通俗解释: ICE 连接的"用户名和密码"。
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 认证类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
ICE 连接检查就像"敲门":
客户端 A 客户端 B
│ │
│ "你好,我是 abcd │
│ 密码是 abcdefgh... │
│ 我可以进来吗?" │
│ ───────────────────────────────>│
│ │
│ "密码正确,请进!" │
│ <───────────────────────────────│
│ │
ice-ufrag = 用户名
ice-pwd = 密码
a=fingerprint - DTLS 指纹
a=fingerprint:sha-256 12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF...
通俗解释: 这是 DTLS 证书的"指纹",用于验证连接的安全性。
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ DTLS 指纹类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
就像验证身份证真伪:
┌─────────────────┐ ┌─────────────────┐
│ 客户端 A │ │ 客户端 B │
│ │ │ │
│ 我的证书指纹: │ │ │
│ 12:34:56:78... │ ──────────────────>│ 收到指纹 │
│ │ │ │
│ │ │ 验证证书 │
│ │ │ 计算哈希值 │
│ │ │ 对比指纹 │
│ │ │ │
│ │ "指纹匹配, │ │
│ │ 连接安全!" │ │
│ │ <──────────────────│ │
└─────────────────┘ └─────────────────┘
通俗理解: fingerprint 就像身份证的防伪标识,对方可以通过这个指纹验证你的身份。
a=setup - DTLS 角色
a=setup:actpass (Offer 中)
a=setup:active (Answer 中)
a=setup:passive (Answer 中)
通俗解释: 确定 DTLS 连接的"主动方"和"被动方"。
| 值 | 含义 | 使用场景 |
|---|---|---|
| actpass | 既可以主动连接,也可以等待连接 | Offer 中使用 |
| active | 主动发起连接 | Answer 中使用 |
| passive | 等待对方连接 | Answer 中使用 |
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ DTLS 角色确定 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
Offer (A 发送): Answer (B 回复):
a=setup:actpass a=setup:active
"我可以主动连接, "我来主动连接你"
也可以等你连接"
结果:B 作为 DTLS 客户端,主动连接 A
通俗理解: 就像两个人握手,总得有一个人先伸出手。setup 决定了谁先伸手。
a=mid - 媒体标识
a=mid:0 (音频)
a=mid:1 (视频)
通俗解释: 给每个媒体流一个"编号",用于 BUNDLE 关联。
a=group:BUNDLE 0 1 ← 把 mid:0 和 mid:1 打包在一起
m=audio ...
a=mid:0 ← 音频的编号是 0
m=video ...
a=mid:1 ← 视频的编号是 1
通俗理解: 就像给每个包裹贴上标签,方便识别。
a=sendrecv / sendonly / recvonly - 媒体方向
a=sendrecv - 发送和接收
a=sendonly - 只发送
a=recvonly - 只接收
a=inactive - 不活跃
通俗解释: 告诉对方这个媒体流的方向。
| 方向 | 通俗解释 | 使用场景 |
|---|---|---|
| sendrecv | 双向通话 | 正常的视频通话 |
| sendonly | 只发不收 | 直播场景 |
| recvonly | 只收不发 | 观看直播 |
| inactive | 暂停 | 暂时静音/黑屏 |
通俗理解: 就像电话的"听筒"和"话筒",sendrecv 表示两个都用,sendonly 表示只用话筒,recvonly 表示只用听筒。
3.3 编解码器相关字段详解
a=rtpmap - RTP 映射
a=rtpmap:<payload type> <encoding name>/<clock rate>[/<encoding parameters>]
示例:
a=rtpmap:111 opus/48000/2 # Opus 音频,48kHz,双声道
a=rtpmap:96 VP8/90000 # VP8 视频,90kHz
a=rtpmap:100 H264/90000 # H.264 视频,90kHz
通俗解释: 把 Payload Type(数字编号)映射到具体的编解码器。
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Payload Type 映射类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
就像菜单上的编号:
编号 菜品名称
──── ────────
111 → Opus 音频(48kHz 立体声)
96 → VP8 视频
100 → H.264 视频
m=audio ... 111 63 103 ← 这些数字就是"菜单编号"
m=video ... 96 98 100 ← 对应不同的编解码器
Payload Type 范围:
- 0-95:静态分配(标准规定)
- 96-127:动态分配(WebRTC 使用)
a=fmtp - 格式参数
a=fmtp:<payload type> <format specific parameters>
示例:
a=fmtp:111 minptime=10;useinbandfec=1
a=fmtp:100 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f
通俗解释: 编解码器的"详细配置参数"。
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ fmtp 参数解释 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
Opus 音频参数 (a=fmtp:111 ...):
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ minptime=10 │ 最小打包时间 10ms │
│ │ 通俗理解:每 10ms 打包一次音频数据 │
│ │ 打包时间越长,延迟越高,但效率也越高 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ useinbandfec=1 │ 启用带内前向纠错 │
│ │ 通俗理解:丢包时可以恢复部分数据,不需要重传 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
H.264 视频参数 (a=fmtp:100 ...):
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ level-asymmetry-allowed=1 │ 允许编解码使用不同的级别 │
│ │ 通俗理解:发送和接收可以使用不同的编码质量 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ packetization-mode=1 │ 非交错模式(单 NALU) │
│ │ 通俗理解:最常用的打包方式,兼容性最好 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ profile-level-id=42e01f │ 编码配置标识 │
│ │ 42 = Constrained Baseline Profile │
│ │ 通俗理解:这是硬件加速支持最好的 H.264 配置 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
a=rtcp-fb - RTCP 反馈
a=rtcp-fb:<payload type> <feedback type>
示例:
a=rtcp-fb:96 goog-remb # 接收端估计最大比特率
a=rtcp-fb:96 transport-cc # 传输拥塞控制
a=rtcp-fb:96 ccm fir # 关键帧请求
a=rtcp-fb:96 nack # 丢包重传请求
a=rtcp-fb:96 nack pli # 图像丢失指示
通俗解释: 告诉对方"如果出现问题,怎么通知我"。
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ RTCP 反馈机制类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
就像快递追踪系统:
┌─────────────────┐ ┌─────────────────┐
│ 发送方 A │ │ 接收方 B │
│ │ │ │
│ 发送视频数据 │ ──────────────────>│ 接收视频数据 │
│ │ │ │
│ │ │ 发现丢包! │
│ │ │ │
│ "请重发第 5 帧"│ <──────────────────│ 发送 NACK │
│ │ │ │
│ 重发数据 │ ──────────────────>│ 收到重发数据 │
│ │ │ │
│ │ │ 画面卡住了! │
│ │ │ │
│ 发送关键帧 │ <──────────────────│ 发送 PLI 请求 │
│ │ │ │
└─────────────────┘ └─────────────────┘
常见反馈类型:
- nack: 丢包重传请求(通俗理解:"我漏收了,请重发")
- pli: 图像丢失指示,请求关键帧(通俗理解:"画面坏了,发个新关键帧")
- fir: 完整内部请求,请求关键帧
- remb: 接收端带宽估计(通俗理解:"我的网络能承受这么多数据")
- transport-cc: 传输拥塞控制(通俗理解:"网络堵了,慢点发")
3.4 SSRC 和 MSID 相关字段
a=ssrc:12345678 cname:user1
a=ssrc:12345678 msid:localStream audioTrack
a=ssrc:12345678 mslabel:localStream
a=ssrc:12345678 label:audioTrack
通俗解释: SSRC 是媒体流的"身份证号",MSID 是媒体流的"名字"。
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SSRC 和 MSID 关系 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
MediaStream (localStream) - 媒体流容器
│
├── MediaStreamTrack (audioTrack) - 音频轨道
│ │
│ └── SSRC: 12345678 - 同步源标识符(随机生成的唯一 ID)
│ │
│ ├── cname: user1 - 规范名称,用于关联多个 SSRC
│ └── msid: localStream audioTrack - 媒体流和轨道的关联
│
└── MediaStreamTrack (videoTrack) - 视频轨道
│
└── SSRC: 87654321 - 另一个唯一 ID
│
├── cname: user1 - 相同的 cname,表示属于同一个用户
└── msid: localStream videoTrack
通俗理解:
- MediaStream = 一个"通话会话"(就像一个文件夹)
- MediaStreamTrack = 会话中的"音频"或"视频"(就像文件夹里的文件)
- SSRC = 每个轨道的唯一编号(就像文件的 ID)
- cname = 标识这些轨道属于同一个人(就像文件的作者)
4. Offer/Answer 模型
4.1 什么是 Offer/Answer
Offer/Answer 是 SDP 交换的标准模式,就像两个人谈判:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Offer/Answer 生活类比 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
场景:两个人商量去哪里吃饭
A (Offer 发起方) B (Answer 回复方)
│ │
│ "我提议去吃火锅或者烧烤 │
│ 时间可以是中午或晚上 │
│ 你觉得怎么样?" │
│ ───────────────────────────────────────────────>│
│ │
│ "我觉得火锅不错 │
│ 时间定在晚上吧" │
│ <───────────────────────────────────────────────│
│ │
│ ════════════════════════════════════════════════
│ 达成一致:火锅,晚上
│ ════════════════════════════════════════════════
SDP 中:
- Offer = A 列出所有支持的编解码器(相当于"菜单")
- Answer = B 选择一个共同支持的编解码器(相当于"点菜")
4.2 Offer/Answer 详细流程
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Offer/Answer 详细流程 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
呼叫方 A (Caller) 被呼叫方 B (Callee)
│ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 步骤 1: createOffer() │ │
│ │ │ │
│ │ 创建 Offer SDP,包含: │ │
│ │ - A 支持的所有编解码器(相当于"菜单") │ │
│ │ - A 的 ICE 候选者(相当于"地址") │ │
│ │ - A 的 DTLS 指纹(相当于"身份证") │ │
│ │ - a=setup:actpass (角色未定) │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 步骤 2: setLocalDescription(offer) │ │
│ │ │ │
│ │ 将 Offer 设置为本地描述 │ │
│ │ 开始收集 ICE 候选者 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 发送 Offer ─────────────────────────────────────────>│
│ │
│ ┌──────────────────┴──────────────────┐
│ │ 步骤 3: setRemoteDescription(offer) │
│ │ │
│ │ 将 A 的 Offer 设置为远程描述 │
│ │ 知道了 A 的能力和地址 │
│ └─────────────────────────────────────┘
│ ┌─────────────────────────────────────┐
│ │ 步骤 4: createAnswer() │
│ │ │
│ │ 创建 Answer SDP,包含: │
│ │ - 选择一个共同支持的编解码器 │
│ │ - B 的 ICE 候选者 │
│ │ - B 的 DTLS 指纹 │
│ │ - a=setup:active (确定角色) │
│ └─────────────────────────────────────┘
│ ┌─────────────────────────────────────┐
│ │ 步骤 5: setLocalDescription(answer) │
│ │ │
│ │ 将 Answer 设置为本地描述 │
│ └─────────────────────────────────────┘
│ │
│ 接收 Answer <─────────────────────────────────────────│
│ │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 步骤 6: setRemoteDescription(answer) │ │
│ │ │ │
│ │ 将 B 的 Answer 设置为远程描述 │ │
│ │ 知道了 B 的选择和地址 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ ═══════════════════════════════════════════════════════════════════════════════════
│ SDP 协商完成!
│ 双方知道了:
│ - 使用什么编解码器
│ - 对方的网络地址
│ - 如何建立安全连接
│
│ 开始 ICE 连接检查 → 建立 P2P 连接 → 开始传输媒体
│ ═══════════════════════════════════════════════════════════════════════════════════
│ │
▼ ▼
4.3 Offer 和 Answer 的关键区别
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Offer 与 Answer 的关键区别 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 编解码器列表 │
│ │
│ Offer: m=audio ... 111 63 103 104 9 0 8 │
│ 列出所有支持的编解码器(相当于"菜单") │
│ │
│ Answer: m=audio ... 111 │
│ 只选择一个编解码器(相当于"点菜") │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 2️⃣ DTLS 角色 │
│ │
│ Offer: a=setup:actpass │
│ "我可以主动连接,也可以等你连接" │
│ │
│ Answer: a=setup:active │
│ "我来主动连接你"(角色确定了) │
│ │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ 3️⃣ 创建时机 │
│ │
│ Offer: 发起呼叫时创建 │
│ A 想要呼叫 B,所以 A 创建 Offer │
│ │
│ Answer: 收到 Offer 后创建 │
│ B 收到 A 的 Offer 后,创建 Answer 回复 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
4.4 本项目中的 Offer/Answer 实现
kotlin
// 创建 Offer(发起呼叫时调用)
fun createOffer() {
executor.execute {
// 设置约束条件:要求接收音频和视频
val constraints = MediaConstraints().apply {
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
}
// 创建 Offer
peerConnection?.createOffer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription) {
// Offer 创建成功,设置本地描述
peerConnection?.setLocalDescription(object : SdpObserver {
override fun onSetSuccess() {
// 本地描述设置成功,发送 Offer 给对端
onSdpToSend("offer", sdp.description)
}
}, sdp)
}
}, constraints)
}
}
// 处理远程 Offer 并创建 Answer(收到呼叫时调用)
private fun handleRemoteOffer(senderId: String, sdp: String) {
// 验证 SDP 格式
if (!sdpManager.validateSdp(sdp)) return
executor.execute {
// 创建 SessionDescription 对象
val sessionDescription = sdpManager.createSessionDescription("offer", sdp)
// 设置远程描述
peerConnection?.setRemoteDescription(object : SdpObserver {
override fun onSetSuccess() {
// 远程描述设置成功,创建 Answer
createAnswer()
}
}, sessionDescription)
}
}
5. SDP 类型判断
5.1 如何判断 SDP 是 Offer 还是 Answer
kotlin
// 本项目中的判断方法
fun parseSdpType(sdp: String): String {
return if (sdp.contains("a=setup:actpass")) "offer" else "answer"
}
原理:
-
Offer 中
a=setup:actpass(角色未定) -
Answer 中
a=setup:active或a=setup:passive(角色已定)┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 类型判断示例 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘Offer SDP 片段:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ ... │
│ a=setup:actpass ← 包含 actpass,所以是 Offer │
│ ... │
└─────────────────────────────────────────────────────────────────────────────────────────────┘Answer SDP 片段:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ ... │
│ a=setup:active ← 是 active 或 passive,所以是 Answer │
│ ... │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
5.2 SDP 验证
kotlin
// 本项目中的 SDP 验证
fun validateSdp(sdp: String): Boolean {
// 检查 SDP 是否为空
if (sdp.isEmpty()) {
Log.e(TAG, "[sdp] SDP内容为空")
return false
}
// 检查是否包含版本行(最基本的 SDP 格式检查)
if (!sdp.contains("v=")) {
Log.e(TAG, "[sdp] SDP格式无效,缺少版本行")
return false
}
return true
}
6. 编解码器详解
6.1 音频编解码器
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ WebRTC 音频编解码器 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 编解码器 │ PT │ 采样率 │ 比特率 │ 特点 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ Opus │ 111 │ 48kHz │ 6-510 kbps │ ⭐ 首选!高质量,自适应比特率 │
│ PCMU │ 0 │ 8kHz │ 64 kbps │ G.711 μ-law,北美标准,兼容性好 │
│ PCMA │ 8 │ 8kHz │ 64 kbps │ G.711 A-law,欧洲标准 │
│ G.722 │ 9 │ 16kHz │ 64 kbps │ 宽带音频,音质更好 │
│ CN │ 13 │ 8kHz │ - │ 舒适噪声,静音时播放背景音 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
Opus 编解码器详解
Opus 是 WebRTC 的首选音频编解码器,因为它:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Opus 编解码器优势 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
1️⃣ 比特率范围极广:6-510 kbps
┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ 窄带语音 (6-8 kbps) ←→ 宽带语音 (16-32 kbps) ←→ 高品质音乐 (64-510 kbps) │ │
│ │ │
│ 通俗理解:网络差时降低比特率保证通话,网络好时提高音质 │ │
└─────────────────────────────────────────────────────────────────────────────────────┘ │
2️⃣ 低延迟:5-66.5ms
┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ 实时通话需要低延迟,Opus 可以做到 5ms 的超低延迟 │ │
│ 通俗理解:说话后对方几乎立刻就能听到 │ │
└─────────────────────────────────────────────────────────────────────────────────────┘ │
3️⃣ 支持前向纠错 (FEC)
┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ 在丢包时可以恢复部分数据,不需要重传 │ │
│ a=fmtp:111 useinbandfec=1 │ │
│ 通俗理解:丢了数据包也能恢复,通话质量更稳定 │ │
└─────────────────────────────────────────────────────────────────────────────────────┘ │
4️⃣ 支持不连续传输 (DTX)
┌─────────────────────────────────────────────────────────────────────────────────────┐ │
│ 静音时不发送数据,节省带宽 │ │
│ 通俗理解:你不说话时就不发数据,省流量 │ │
└─────────────────────────────────────────────────────────────────────────────────────┘ │
6.2 视频编解码器
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ WebRTC 视频编解码器 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 编解码器 │ PT │ 特点 │ 推荐场景 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ VP8 │ 96 │ Google 开源,软件编解码效率高 │ 通用场景 │
│ VP9 │ 98 │ VP8 升级版,压缩率更高 │ 带宽有限场景 │
│ H.264 │ 100 │ 硬件加速支持好,移动设备友好 │ 移动设备 ⭐ │
│ AV1 │ 35 │ 最新编解码器,压缩率最高 │ 高清视频 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
VP8 vs H.264 如何选择?
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ VP8 vs H.264 选择指南 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
VP8 优势:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ✅ 完全免费开源,无专利问题 │
│ ✅ 软件编解码效率高,CPU 占用低 │
│ ✅ 所有浏览器都支持 │
│ ✅ 屏幕共享效果好 │
│ │
│ 通俗理解:适合电脑端,不依赖硬件加速 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
H.264 优势:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ ✅ 硬件加速支持广泛(手机、电脑都有专用芯片) │
│ ✅ 移动设备省电 │
│ ✅ 与传统视频系统兼容性好 │
│ ✅ 编码延迟低 │
│ │
│ 通俗理解:适合手机端,有硬件加速,省电 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
选择建议:
┌─────────────────────────────────────────────────────────────────────────────────────────┐
│ 📱 移动设备优先选择 H.264(省电、硬件加速) │
│ 💻 桌面端可以选择 VP8(软件编解码效率高) │
│ 🖥️ 屏幕共享选择 VP8 或 VP9 │
│ 🌐 需要最大兼容性时,同时支持 VP8 和 H.264 │
└─────────────────────────────────────────────────────────────────────────────────────────┘
6.3 编解码器协商过程
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 编解码器协商过程示例 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
呼叫方 A 被呼叫方 B
│ │
│ Offer: │
│ m=video ... 96 98 100 127 │
│ │
│ "我支持以下视频编解码器: │
│ - VP8 (PT=96) │
│ - VP9 (PT=98) │
│ - H.264 (PT=100) │
│ - H.264 High Profile (PT=127)" │
│ │
│ ─────────────────────────────────────────────────────────>│
│ │
│ B 支持的编解码器: │
│ - VP8 ✓ │
│ - H.264 ✓ │
│ - VP9 ✗ │
│ │
│ 选择第一个共同支持的: │
│ VP8 (PT=96) │
│ │
│ Answer: │
│ m=video ... 96 │
│ │
│ "我选择 VP8" │
│ <─────────────────────────────────────────────────────────│
│ │
│ ═══════════════════════════════════════════════════════════
│ 协商结果:双方使用 VP8 编解码器
│ 通俗理解:A 提供菜单,B 点菜,双方达成一致
│ ═══════════════════════════════════════════════════════════
7. SDP 与 ICE 的关系
7.1 ICE 候选者在 SDP 中的表示
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 候选者格式 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
a=candidate:<foundation> <component-id> <transport> <priority> <connection-address> <port> typ <candidate-type> [raddr <related-address> rport <related-port>]
示例:
a=candidate:1 1 UDP 2122260223 192.168.1.100 5000 typ host
a=candidate:2 1 UDP 1686052607 203.0.113.10 12345 typ srflx raddr 192.168.1.100 rport 5000
a=candidate:3 1 UDP 41885439 198.51.100.10 60000 typ relay raddr 198.51.100.10 rport 60000
通俗解释:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ ICE 候选者通俗解释 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
a=candidate:1 1 UDP 2122260223 192.168.1.100 5000 typ host
分解解释:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 字段 │ 值 │ 通俗解释 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ foundation │ 1 │ 候选者标识符 │
│ component-id │ 1 │ 1=RTP, 2=RTCP │
│ transport │ UDP │ 传输协议 │
│ priority │ 2122260223 │ 优先级(数字越大优先级越高) │
│ connection-address│ 192.168.1.100 │ IP 地址 │
│ port │ 5000 │ 端口号 │
│ typ │ host │ 候选者类型 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
候选者类型:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 类型 │ 通俗解释 │ 优先级 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ host │ 本地地址(局域网 IP) │ 最高 ⭐⭐⭐ │
│ │ 通俗理解:你家里的地址 │ 优先尝试直连 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ srflx │ 服务器反射地址(公网 IP) │ 中 ⭐⭐ │
│ │ 通俗理解:通过 STUN 获取的公网地址│ host 不通时尝试 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ prflx │ 对等反射地址(动态发现) │ 中 ⭐⭐ │
│ │ 通俗理解:对方告诉你的地址 │ 意外发现的可达地址 │
├─────────────────────────────────────────────────────────────────────────────────────────────┤
│ relay │ 中继地址(TURN 服务器) │ 最低 ⭐ │
│ │ 通俗理解:通过中转服务器转发 │ 其他方式都不通时使用 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
7.2 Trickle ICE
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ Trickle ICE 机制 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
传统方式(等待所有候选者):
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ A 收集候选者 ──────────────────────────────> 发送完整 SDP(包含所有候选者) │
│ │ │
│ │ 等待... │
│ │ 等待... │
│ │ 等待... │
│ │ 收集完成! │
│ │ │
│ 问题:需要等待所有候选者收集完成,连接建立慢 │
│ 通俗理解:等所有快递都到了才一起发货,太慢了 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
Trickle ICE(逐步发送):
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ A 收集候选者: │
│ │ │
│ ├── 收集到 host 候选者 ──────────────────> 立即发送 │
│ │ 通俗理解:本地地址找到了,先发过去 │
│ │ │
│ ├── 收集到 srflx 候选者 ─────────────────> 立即发送 │
│ │ 通俗理解:公网地址找到了,再发过去 │
│ │ │
│ └── 收集到 relay 候选者 ─────────────────> 立即发送 │
│ 通俗理解:中转地址找到了,最后发过去 │
│ │
│ 优势:可以立即开始连接检查,加快连接建立速度 │
│ 通俗理解:有一个地址就发一个,不用等 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
SDP 中标识支持 Trickle:
a=ice-options:trickle
8. SDP 重新协商
8.1 什么时候需要重新协商
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 需要重新协商的场景 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
1️⃣ 添加/移除媒体轨道
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 场景:通话过程中开启/关闭摄像头 │
│ │
│ 关闭摄像头: │
│ a=sendrecv → a=recvonly(只接收视频,不发送) │
│ 通俗理解:我看不到你了,但我还能听到你 │
│ │
│ 开启摄像头: │
│ a=recvonly → a=sendrecv(恢复双向视频) │
│ 通俗理解:现在又能视频了 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2️⃣ 切换媒体方向
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 场景:从双向通话变为单向直播 │
│ │
│ 主播端:a=sendrecv → a=sendonly(只发送) │
│ 观众端:a=sendrecv → a=recvonly(只接收) │
│ │
│ 通俗理解:主播说话,观众听,观众不能说话 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
3️⃣ ICE 重启
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 场景:网络切换(WiFi → 4G) │
│ │
│ 需要重新收集 ICE 候选者,建立新的网络连接 │
│ │
│ 通俗理解:换了个网络,地址变了,需要重新告诉对方 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
4️⃣ 屏幕共享
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 场景:开始/停止屏幕共享 │
│ │
│ 添加新的视频轨道(屏幕共享流) │
│ │
│ 通俗理解:在视频通话基础上增加一个"桌面画面" │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
8.2 重新协商流程
重新协商的流程与初始协商相同,都是 Offer/Answer 模式:
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ 重新协商流程 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
发起方 A 接收方 B
│ │
│ 1. 修改本地状态(如关闭摄像头) │
│ │
│ 2. createOffer() │
│ 生成新的 Offer SDP │
│ (o= 行的 sess-version 递增) │
│ │
│ 3. setLocalDescription(newOffer) │
│ │
│ 4. 发送新 Offer ─────────────────────────────────────────>│
│ │
│ 5. setRemoteDescription(newOffer)
│ │
│ 6. createAnswer() │
│ │
│ 7. setLocalDescription(newAnswer)
│ │
│ 8. 接收新 Answer <─────────────────────────────────────────│
│ │
│ 9. setRemoteDescription(newAnswer) │
│ │
│ ═══════════════════════════════════════════════════════════
│ 重新协商完成,新的媒体配置生效
│ ═══════════════════════════════════════════════════════════
9. SDP 调试技巧
9.1 如何阅读 SDP 日志
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 日志阅读技巧 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
本项目中的 SDP 日志输出:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ [sdp][本地] SDP信息 | 类型: offer | 行数: 85 │
│ [sdp][本地] 音频编解码器: a=rtpmap:111 opus/48000/2 │
│ [sdp][本地] 视频编解码器: a=rtpmap:96 VP8/90000 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
快速检查要点:
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 1️⃣ 检查编解码器协商结果 │
│ - Offer 和 Answer 的 m= 行是否匹配? │
│ - 双方是否选择了相同的编解码器? │
│ 通俗理解:确认双方"说同一种语言" │
│ │
│ 2️⃣ 检查 ICE 凭证 │
│ - ice-ufrag 和 ice-pwd 是否正确? │
│ - ICE 候选者是否正确交换? │
│ 通俗理解:确认双方能"找到对方" │
│ │
│ 3️⃣ 检查 DTLS 设置 │
│ - fingerprint 是否存在? │
│ - setup 角色是否正确? │
│ 通俗理解:确认双方能"安全连接" │
│ │
│ 4️⃣ 检查媒体方向 │
│ - sendrecv/sendonly/recvonly 是否符合预期? │
│ 通俗理解:确认双方"能说能听" │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
9.2 常见问题排查
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 常见问题排查 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
问题 1: 视频无法播放,只有音频
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 可能原因: │
│ - 编解码器协商失败(双方没有共同支持的视频编解码器) │
│ - H.264 profile-level-id 不兼容 │
│ │
│ 排查方法: │
│ 1. 检查 Offer 中的 m=video 行 │
│ 2. 检查 Answer 中的 m=video 行 │
│ 3. 确认双方有共同支持的编解码器 │
│ │
│ 通俗理解:就像两个人说不同的语言,无法沟通 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
问题 2: ICE 连接一直处于 CHECKING 状态
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 可能原因: │
│ - ICE 候选者没有正确交换 │
│ - STUN/TURN 服务器配置错误 │
│ - 网络不通 │
│ │
│ 排查方法: │
│ 1. 检查 SDP 中的 a=candidate 行 │
│ 2. 检查 STUN/TURN 服务器是否可达 │
│ 3. 检查防火墙设置 │
│ │
│ 通俗理解:就像快递找不到收件地址 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
问题 3: DTLS 连接失败
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 可能原因: │
│ - fingerprint 不匹配 │
│ - setup 角色冲突 │
│ │
│ 排查方法: │
│ 1. 检查 SDP 中的 a=fingerprint 行 │
│ 2. 检查 a=setup 是否正确(Offer: actpass, Answer: active/passive) │
│ │
│ 通俗理解:就像身份证验证失败 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
问题 4: 对方听不到我的声音
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 可能原因: │
│ - 媒体方向设置错误(sendrecv 被设置为 recvonly) │
│ - 音频轨道未正确添加 │
│ │
│ 排查方法: │
│ 1. 检查 SDP 中的 a=sendrecv 行 │
│ 2. 检查本地音频轨道是否已添加到 PeerConnection │
│ │
│ 通俗理解:话筒没开,或者对方把耳朵堵上了 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
10. SDP 最佳实践
10.1 处理建议
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 处理最佳实践 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
1️⃣ 不要手动修改 SDP
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ ❌ 错误做法:手动拼接或修改 SDP 字符串 │
│ ✅ 正确做法:使用 WebRTC API 生成和处理 SDP │
│ │
│ 原因:SDP 格式复杂,手动修改容易出错 │
│ 通俗理解:不要自己造轮子,用现成的工具 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2️⃣ 正确处理 SDP 交换顺序
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 正确顺序: │
│ 1. createOffer/createAnswer │
│ 2. setLocalDescription │
│ 3. 发送 SDP 给对端 │
│ 4. 对端 setRemoteDescription │
│ │
│ 通俗理解:先准备好自己的信息,再发给对方 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
3️⃣ 处理 ICE 候选者时机
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ ICE 候选者在 setLocalDescription 后开始收集 │
│ 应该在远程描述设置后才能添加远程 ICE 候选者 │
│ │
│ 通俗理解:等对方准备好了,再告诉他你的地址 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
4️⃣ 实现重新协商
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ 当需要改变媒体配置时,触发重新协商: │
│ - 添加/移除媒体轨道 │
│ - 改变媒体方向 │
│ - 网络切换时重启 ICE │
│ │
│ 通俗理解:情况变了就重新商量 │
│ │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
10.2 性能优化
┌──────────────────────────────────────────────────────────────────────────────────────────────┐
│ SDP 性能优化建议 │
└──────────────────────────────────────────────────────────────────────────────────────────────┘
1️⃣ 使用 BUNDLE
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 将音频和视频复用到同一个连接,减少端口占用,简化 NAT 穿透 │
│ 通俗理解:一个快递包裹装所有东西,省事 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
2️⃣ 使用 Trickle ICE
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ 逐步发送 ICE 候选者,加快连接建立速度 │
│ 通俗理解:找到一个地址就发一个,不用等 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
3️⃣ 选择合适的编解码器
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - 移动设备优先选择 H.264(硬件加速) │
│ - 桌面端可以选择 VP8(软件编解码效率高) │
│ - 音频首选 Opus(高质量、自适应) │
│ 通俗理解:根据设备选择最合适的编码方式 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
4️⃣ 启用 RTCP 反馈
┌─────────────────────────────────────────────────────────────────────────────────────────────┐
│ - nack: 丢包重传 │
│ - pli/fir: 关键帧请求 │
│ - remb/transport-cc: 拥塞控制 │
│ 通俗理解:建立反馈机制,有问题及时沟通 │
└─────────────────────────────────────────────────────────────────────────────────────────────┘
11. 参考资料
11.1 相关 RFC 文档
| RFC 编号 | 标题 | 说明 |
|---|---|---|
| RFC 4566 | SDP: Session Description Protocol | SDP 协议规范 |
| RFC 3264 | An Offer/Answer Model with SDP | Offer/Answer 模型 |
| RFC 8866 | SDP: Session Description Protocol | SDP 协议更新版 |
| RFC 5245 | Interactive Connectivity Establishment (ICE) | ICE 协议 |
| RFC 8839 | SDP Offer/Answer Procedures for ICE | ICE 的 SDP 处理 |
11.2 本项目相关文件
| 文件路径 | 说明 |
|---|---|
| SdpManager.kt | SDP 管理,包含验证、类型判断、缓存处理 |
| WebRTCClient.kt | WebRTC 客户端,包含 Offer/Answer 创建和处理 |
| IceCandidateManager.kt | ICE 候选者管理 |
| PeerConnectionManager.kt | PeerConnection 管理 |
本系列文章:
【音视频通话系统】之架构详解
【音视频通信系统】之呼叫完整时序图
【音视频通信系统】之STUN服务详解
【音视频通信系统】之TURN 服务详解
【音视频通信系统】WebRTC ICE 候选类型详解:对等反射候选者(Peer Reflexive Candidate)