跳到主要内容

本篇是 WebRTC 101 系列 第 2 篇,讲连接建立的地基:为什么要有信令、Offer/Answer 怎么谈、SDP 怎么读。原理为主,代码点到为止。

建立连接(上):信令与 SDP —— 先"约好",再"打通"

第 1 篇我们拿到了本地轨道,但它还只在你机器里。要真正开始通话,双方要先做两件事:

  1. 协商(signaling):交换"我有什么、想用什么格式"的会话描述;
  2. 打通(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. 本篇结论(能带走的一句话们)

  1. 信令是你自己的消息通道,规范只管"交换描述",不管"怎么交换";
  2. 最小闭环 = createOffer → 传出去 → setRemoteDescription → createAnswer → 传回来 → setRemoteDescription;
  3. setLocalDescription 再取 offer/candidate;
  4. 读 SDP:m= 是一路流、端口 9 是占位、BUNDLE 把它们捆成一条传输;
  5. m= 里的 payload type 是协商出的编解码集合,answer 是交集。

参考与来源