本篇是 WebRTC 101 系列 第 2 篇,讲连接建立的地基:为什么要有信令、Offer/Answer 怎么谈、SDP 怎么读。原理为主,代码点到为止。
建立连接(上):信令与 SDP —— 先"约好",再"打通"
第 1 篇我们拿到了本地轨道,但它还只在你机器里。要真正开始通话,双方要先做两件事:
- 协商(signaling):交换"我有什么、想用什么格式"的会话描述;
- 打通(ICE):在协商结果之上,找到一条实际能通的网络路径。
这一篇只讲 1(协商),下一篇讲 2(打洞)。先记住一句贯穿全系列的话:
WebRTC 的 API 里没有信令。 谁是谁、怎么交换描述,规范完全不规定——这留给开发者。浏览器只负责"给我一份描述、吃进一份描述",怎么传递这份描述是你的活。
1. 为什么信令必须"自己搭"
RTCPeerConnection 两端在互联网上彼此匿名:没有"对方 IP 多少"(那是第 3 篇 ICE 的事),更没有"谁想跟谁通话"的社交层。信令要回答两个问题:
- 发现:对方在哪、怎么找到他(房间号 / 用户 id / 呼叫);
- 协商:把本端的会话描述安全地送到对方手里,再把对方的送回来。
常见的信令载体就是现成的消息通道:WebSocket / HTTP 轮询 / 长连接 / 甚至二维码扫一扫。房间、鉴权、状态(空闲/忙)都是你自己的产品逻辑。正因为"信令怎么传"没被标准化,才有无数服务端方案(自建 WebSocket 服务、SIP、XMPP、云厂商的信令服务……)——这也是第 6 篇选型要纠结的地方。
2. JSEP 把"协商"规范成什么
IETF 的 JSEP(RFC 9429) 是整个协商的机制骨架,一句话概括:
浏览器是信令状态机,但它不替你做信令——应用负责选 Offer/Answer 的发起时机、负责把描述在两端间搬来搬去;浏览器负责生成/消费描述、并根据协商结果干活。
规范里你只需要关心 4 个方法 + 1 个事件:
| API | 作用 |
|---|---|
pc.createOffer() | 生成一份"我想怎么连"的 offer 描述 |
pc.createAnswer() | 收到对方 offer 后,生成"我同意怎么连"的 answer |
pc.setLocalDescription(desc) | 把我这份描述生效(生成本地 SDP + 触发候选收集) |
pc.setRemoteDescription(desc) | 吃进对方的描述 |
pc.onicecandidate / addIceCandidate | 候选地址的"边发现边传"(trickle) |
关键时序规则(很多人第一次写就卡在这):先 setLocalDescription,再取 offer/answer 或候选——浏览器只有在本地描述设置后才会开始 ICE 收集,你太早抓 candidate 会抓空。
3. Offer/Answer:一次"谈成"的流程
最朴素(也是第 3 篇会真正打洞的)过程:
A 信令通道(你自己搭) B
├─ createOffer() │
├─ setLocalDescription(offer) │
├─ (本地 ICE 开始收集候选) │
├────── offer ───────────────────────────────────────────→
│ setRemoteDescription(offer)
│ createAnswer()
│ setLocalDescription(answer)
│ | (B 的 ICE 也开始收集)
│←────────────────────────── answer ─────────────────────
│─ setRemoteDescription(answer) │
│←────────────────── 互相 trickle 候选 ──────────────────→
│(两边 addIceCandidate,ICE 开始连通性探测,见第 3 篇) │
└────────────────────── 然后 DTLS/SRTP(见第 4 篇)───────┘
信令交换的只是文本描述(offer/answer 是字符串、candidate 是小 JSON),所以它跑在 WebSocket 上毫无压力,跑在"你把 A 的输出复制粘贴给 B"上也行——协商本身不挑载体。本地没有信令服务器时,用一个页面里建两个 RTCPeerConnection 让它们互为对端,是最小可验证实验(后面实操篇会放完整代码)。
4. 读一份 SDP:不用背,知道"每个 m= 是一路流"
offer/answer 的正文就是 SDP 文本。挑典型字段拆解(省略号表示省略中间行):
v=0
o=- 461173133 2 IN IP4 127.0.0.1 ; 会话 id/版本(不用管)
s=- ; 会话名
t=0 0 ; 时间,0 0 = 永久有效
a=group:BUNDLE 0 1 ; 把多路媒体塞进同一条传输(见下)
a=ice-ufrag:xxxx a=ice-pwd:yyyy ; ICE 的短期凭证(第3篇用)
a=fingerprint:sha-256 AA:BB:... ; DTLS 证书指纹(第4篇握手验真)
m=audio 9 UDP/TLS/RTP/SAVPF 111 63 9 ; 第1路媒体: 音频, 候选协议/载荷编号
a=mid:0
a=sendrecv ; 方向: 双向
a=rtpmap:111 opus/48000/2 ; 载荷111 = Opus 48k 双声道
a=fmtp:111 minptime=10;useinbandfec=1 ; Opus 参数: 丢包隐藏等
m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 ; 第2路媒体: 视频, 多个候选载荷
a=mid:1
a=rtpmap:96 VP8/90000 ; 载荷96 = VP8 (RTP时钟90k)
a=rtpmap:97 rtx/90000 a=fmtp:97 apt=96 ; rtx = 丢包重传伴随流
a=rtcp-fb:96 nack ; 请求NACK反馈(第4篇丢包恢复)
三个最容易产生顿悟的细节:
m=audio/m=video每行就是"一路流"。实际是一路m=对应一个 transceiver(收发器)。你addTrack一次,SDP 里就多一路m=;增删轨道后要重新协商(renegotiation)。m=行里的端口是9(占位),不是真实传输端口。真实端口在 ICE 候选里(第 3 篇)——这是新手最容易误读的地方。a=group:BUNDLE:默认所有媒体(音频+视频+数据)被捆到同一条 ICE/DTLS 传输上,省端口、省连接。这解释了为什么你抓包通常只看到一条 UDP 流。
5. 协商是"求交集"不是"复制"
Offer 端列出所有它支持的编解码与参数(payload type),Answer 端选它能接受的子集返回。所以最终启用的编解码 = 两边能力交集。看 answer 里的 a=rtpmap 就知道协商出了哪个 codec(经常是双方都默认的 Opus/VP8)。这一点对排查"为什么视频黑屏但音频通"很重要:可能只是 video 的 m= 没协商出交集(方向或编解码不匹配)。
6. 连接状态机:看这几个状态就够了
浏览器暴露两个状态,别混:
pc.iceConnectionState:底层传输通道的状态;pc.connectionState:整条 PeerConnection(含 ICE+DTLS+协商)的高层状态。
connectionState: new → connecting → connected ⇄ disconnected → failed / closed
iceConnectionState: new → checking → connected ⇄ completed → failed / closed
调试口诀:iceConnectionState 卡在 checking = 网络层还没通(去看第 3 篇);connected 之后又 disconnected = 网络抖动/路径断了,ICE 会尝试恢复;变成 failed = 没救,需要重建。
7. 本篇结论(能带走的一句话们)
- 信令是你自己的消息通道,规范只管"交换描述",不管"怎么交换";
- 最小闭环 = createOffer → 传出去 → setRemoteDescription → createAnswer → 传回来 → setRemoteDescription;
- 先
setLocalDescription再取 offer/candidate; - 读 SDP:
m=是一路流、端口 9 是占位、BUNDLE把它们捆成一条传输; m=里的 payload type 是协商出的编解码集合,answer 是交集。
参考与来源
- IETF:RFC 9429 JSEP(取代 RFC 8829)、RFC 8825 架构、RFC 3264 Offer/Answer
- MDN:WebRTC 连接流程与信令
- 上一篇 媒体采集、下一篇 NAT 与 ICE