【P2P音视频通信系统】WebRTC 之 SDP 详解
系列文章:
【P2P音视频通信系统】之项目实现详解
【P2P音视频通信系统】之呼叫完整时序图
【P2P音视频通信系统】之STUN服务详解
【P2P音视频通信系统】之TURN 服务详解
【P2P音视频通信系统】WebRTC 之 SDP 详解
【P2P音视频通信系统】WebRTC 之 ICE 详解
【P2P音视频通信系统】WebRTC ICE 候选类型详解:对等反射候选者(Peer Reflexive Candidate)
【P2P音视频通信系统】之信令服务器详解
【P2P音视频通信系统】信令服务器之TCP与QUIC选型对比
【P2P音视频通信系统】之 WebRTC Android平台 aar 下载
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 实现
// 创建 Offer(发起呼叫时调用)funcreateOffer(){ executor.execute{// 设置约束条件:要求接收音频和视频val constraints =MediaConstraints().apply{ mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio","true")) mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo","true"))}// 创建 Offer peerConnection?.createOffer(object: SdpObserver {overridefunonCreateSuccess(sdp: SessionDescription){// Offer 创建成功,设置本地描述 peerConnection?.setLocalDescription(object: SdpObserver {overridefunonSetSuccess(){// 本地描述设置成功,发送 Offer 给对端onSdpToSend("offer", sdp.description)}}, sdp)}}, constraints)}}// 处理远程 Offer 并创建 Answer(收到呼叫时调用)privatefunhandleRemoteOffer(senderId: String, sdp: String){// 验证 SDP 格式if(!sdpManager.validateSdp(sdp))return executor.execute{// 创建 SessionDescription 对象val sessionDescription = sdpManager.createSessionDescription("offer", sdp)// 设置远程描述 peerConnection?.setRemoteDescription(object: SdpObserver {overridefunonSetSuccess(){// 远程描述设置成功,创建 AnswercreateAnswer()}}, sessionDescription)}}5. SDP 类型判断
5.1 如何判断 SDP 是 Offer 还是 Answer
// 本项目中的判断方法funparseSdpType(sdp: String): String {returnif(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 验证
// 本项目中的 SDP 验证funvalidateSdp(sdp: String): Boolean {// 检查 SDP 是否为空if(sdp.isEmpty()){ Log.e(TAG,"[sdp] SDP内容为空")returnfalse}// 检查是否包含版本行(最基本的 SDP 格式检查)if(!sdp.contains("v=")){ Log.e(TAG,"[sdp] SDP格式无效,缺少版本行")returnfalse}returntrue}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)