做 WebRTC 调试的人,迟早会撞上一个问题:demo 在自己电脑上跑得好好的,一旦搬进业务工程,就成了“偶发黑屏”“有时有声音有时没声音”“接通特别慢”。我排查过不少这类问题,最后发现很大一部分不是信令协议写错了,也不是服务器配置有问题,而是关键模块的创建时机不对。

这里说的 WebRTC 关键模块,指的是 PeerConnection、本地流、Track、DataChannel、ICE、DTLS、SRTP 这些参与一次实时通信会话的组件。它们不是一个函数里全都 new 出来就行,而是必须在协商和连接建立这条时间线上“按时”出现。早一点、晚一点,最终呈现出来的效果可能差很远。这篇文章我会把自己在推流、拉流以及和 FreeSWITCH 这类网关对接时踩过的坑和总结出的顺序讲清楚,适合正在调 WebRTC 音视频链路、又不想只停留在 demo 阶段的开发者参考。

1. 先想清楚:WebRTC 的关键模块到底有哪些

1.1 一次通话中真正参与工作的角色

很多人把 WebRTC 理解成“打开浏览器就能视频通话”,但实际上一个最简单的通话也需要多个模块配合。从上层往下看,大致有这几类:

底层是传输相关模块,包括 ICE agent(负责寻找可达路径)、DTLS transport(负责密钥协商和加密通道)、SRTP 加密链路。这些模块平时不会出现在业务代码里,但它们的触发时机直接决定了“能不能通”。

中间是媒体协商与传输模块,包括 SDP(会话描述)、RTCRtpSender、RTCRtpReceiver、RTCRtpTransceiver。它们负责描述我们想发什么、想收什么,以及对端有没有能力接收。再往上是媒体源和出口模块,包括本地采集的 MediaStream、MediaStreamTrack,以及远端媒体在 ontrack 事件里暴露出来的流。

还有一个很容易被忽略的是 RTCDataChannel,它不承载音视频,但很多信令扩展、文件传输和游戏控制都靠它。DataChannel 比较特殊,它必须在 SDP 协商之前创建,否则这次协商里根本不会带上 data 这个媒体行。

1.2 创建时机敏感的根源:SDP 是“快照”不是“实时状态”

理解了模块清单,接下来就要理解为什么时机这么敏感。WebRTC 的协商过程,本质上是通过交换 SDP 把双方的媒体能力、传输方式、加密信息“固化”下来。

SDP 一旦生成并发送给对端,就代表你对这次通话的承诺:这通电话里有几路音频、几路视频、要不要 data channel、用哪些编码、IP 端口是多少。如果你在生成 SDP 之后再往 PeerConnection 里塞一个 track 或新建 DataChannel,抱歉,对端拿到的旧 SDP 根本不知道它的存在。

也就是说,模块创建时机不是代码风格问题,而是协商的一致性约束。错过窗口不会立刻报错,但会出现“对端收到了数据却解码不出来”“我这边发起 datachannel 对端却一直没收到 open”这类诡异现象。

2. 创建时机拆解:每个模块该在哪个时间点出现

2.1 本地媒体:尽量早采集,但别为了早而早

本地媒体流通常通过 getUserMedia 获取。很多教程会先拿流,再创建 PeerConnection,再把 track 往连接里塞。这个顺序在实际项目中也适用,原因是采集本身可能需要用户授权,而授权弹窗在部分浏览器里要求由用户手势触发。

如果等到信令已经走到 createOffer 才去拉流授权,用户体验会非常糟糕:对方可能已经等了很久,你这边浏览器还在“等待摄像头授权”。我自己在做网页端呼叫中心时,会先让用户完成设备选择和授权,然后再把流缓存起来,等呼叫开始时直接使用。这个模式在推流场景尤其好用。

但还有两种情况需要调整时机。一是纯拉流观看场景,不需要本地采集,但如果你想用“入会后再开摄像头”这种交互,那 getUserMedia 反而应该推迟到用户点击开摄像头那一刻。二是如果使用类似 simulcast 或复杂转码需求,需要在创建 PeerConnection 前就确定多个编码层和带宽策略,这时媒体采集提前反而更好规划。

2.2 PeerConnection 和 Transceiver:搭建媒体容器的时间点

RTCPeerConnection 是 WebRTC 的根容器,所有媒体协商和传输状态都由它管理。它创建得越早,其他模块就越早能挂进去。但过早创建会有空转成本吗?现代浏览器里的 PeerConnection 在没开始协商时资源占用很低,所以不需要过度担心。

一个容易忽略的地方是 RTCRtpTransceiver。你可以先直接 addTrack,也可以先 addTransceiver 手动指定方向,比如纯发、纯收或双向。很多人在拉流时忘了 addTransceiver,导致 createOffer 生成的 SDP 里一个媒体行都没有,对端根本没法给你推流。

我建议把“在 createOffer 之前检查 transceiver”作为一条铁律。哪怕你是从一个项目改造来的,只要目标场景是纯拉流,也要显式 addTransceiver('video', { direction: 'recvonly' })。这样生成出来的 offer 才会包含你希望接收的媒体类型,而不是凭浏览器心情决定。

2.3 Track 和 DataChannel:必须在 createOffer 前确定

这是整个创建时机体系里最容易踩雷的部分,尤其 DataChannel。先说 Track:如果你想让本次通话包含本地摄像头画面,必须在执行 createOffer 前把所有 track 添加进 PeerConnection。addTrack 会让浏览器自动创建对应的 sender 和 transceiver,并在后续生成的 offer 里带上对应的媒体描述。如果你在交换完 SDP 后才 addTrack,那就必须重新走一次协商流程,否则对端不会收到新增的媒体类型。

DataChannel 更苛刻。很多人以为调用 createDataChannel 之后就万事大吉,只要等它 open 就行。但如果没有在 setLocalDescription 之前执行,浏览器生成的 SDP 里就不会包含应用媒体行(application m-line)。信令交换完成后,DataChannel 的 onopen 永远不触发,而且不会报错。这个坑非常隐蔽。

我在做多端控制信令时,一度以为是 NAT 穿透问题,排查了很久才发现只是 createDataChannel 调用晚于 createOffer。最后调整顺序,把 DataChannel 创建放在 createOffer 之前,一切正常。

2.4 ICE、DTLS 和 SRTP:看起来是自动的一环,其实有隐藏前提

ICE candidate 的收集在 setLocalDescription 之后自动开始,并不需要我们手动“创建”。但有一个关键前提:你必须把 remoteDescription 设置好,才能处理对端回来的 candidate。

实践中经常会看到两种情况:一是 onicecandidate 回调里拿到 candidate 后,立刻通过信令发给对端,对端立刻调用 addIceCandidate,此时对端的 remoteDescription 可能还没设置,于是 candidate 丢失。二是对端把 candidate 发回来得比你本地 setRemoteDescription 要早,你同样面临丢失。

解决办法是维护一个小小的待处理队列。只要当前 PeerConnection 的 remoteDescription 为空,就把收到的 candidate 暂存起来,等 setRemoteDescription 成功后再逐条 addIceCandidate。这也是很多人容易忽略的“创建时机”问题:不是 PeerConnection 没创建,而是处理 candidate 时远端描述还没有就绪。

DTLS 和 SRTP 则完全由浏览器内部在 ICE 连通后自动创建。你能做的主要是检查连接状态变化,而不是手动创建。只要 SDP 里的 setup 字段顺序不乱、证书指纹和协商一致,DTLS 握手就会自动完成。这里对“时机”的要求是:不要过早丢弃对端的 SDP 或证书指纹,最好先等 setRemoteDescription 结束。

下面我用一张表总结常用模块和推荐时机:

模块 推荐创建/触发时机 错过时机会怎样
getUserMedia 数据流 用户授权后、协商前提前准备好 呼叫接起慢,授权被浏览器拦截
RTCPeerConnection 接收呼叫或发起呼叫时的第一步 后续模块没有挂载对象
MediaStreamTrack(本地) createOffer/setLocalDescription 前 生成 SDP 中缺少音视频描述
RTCRtpTransceiver createOffer 之前,特别是纯拉流场景 offer 里缺少对应媒体行,远端无法推流
RTCDataChannel setLocalDescription 之前 协商完成后 open 永远不触发
ICE candidate 处理 remoteDescription 设置完成后 candidate 被丢弃,连接失败
DTLS/SRTP 通道 ICE 连通后自动创建 不需要手动创建,只需监听状态

3. 推流和拉流场景的完整实操对照

3.1 推流:先准备好本地流,再进入协商流程

典型的 WebRTC 推流场景,比如摄像头推给 SFU、推给另一个浏览器,以及某些“直播”类型的单路上行。这个场景里,本地采集通常在建立 PeerConnection 之前完成。

我常用的推流初始化顺序是这样的:

  1. 调用 getUserMedia 拿到本地媒体对象。
  2. 创建 RTCPeerConnection,并设置 onicecandidate、ontrack、onconnectionstatechange 回调。
  3. 遍历本地流里的 track,逐个调用 pc.addTrack(track, localStream)。
  4. 按照业务需要调用 createDataChannel 创建数据通道。
  5. 调用 pc.createOffer 生成 SDP,setLocalDescription,通过信令发送 offer 给对端。
  6. 收到对端 answer 后调用 setRemoteDescription。
  7. 边收集边发送 ICE candidate,并处理远端 candidate。

这几个顺序里,第 1 步和第 3 步的间隔可以做点文章。如果设备授权本身就慢,我们可以先创建 PeerConnection 并开始 ICE 收集,但不要生成 offer,等本地流准备就绪后再 addTrack 并 createOffer。Ice gathering 和媒体采集可以并行,这个细节能帮助你在弱网环境下减少建连时间。

3.2 拉流:不是我方没数据就行,而是要明确“想收什么”

拉流看远端视频时,容易犯一个错误:以为不需要加任何媒体,只要信令交换完远端就会把画面推过来。实际上,除非对端是早有准备的 SFU,并且协商方式很特殊,否则你的 offer 或 answer 里需要明确包含音频、视频的接收方向。

所以即便是纯拉流,我也建议在创建 PeerConnection 后先 addTransceiver。

const pc = new RTCPeerConnection(config);
pc.ontrack = (event) => {
  // 远端媒体到达时,把流绑定到 video 元素
  remoteVideo.srcObject = event.streams[0];
};

// 明确声明要接收音频和视频
pc.addTransceiver('audio', { direction: 'recvonly' });
pc.addTransceiver('video', { direction: 'recvonly' });

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// 发送 offer 给推流端

这段代码的价值在于,addTransceiver 先告诉浏览器“本端希望接收什么媒体”,随后生成的 SDP 才会有对应媒体描述。如果你省略这两行,很多浏览器会生成一个空媒体协商,导致对端即使有流也发不过来。

在推拉流同时存在的互动场景,则要区分好方向。比如浏览器作为 SFU 的一个端,既上传本地摄像头,也接收远端合成流,那么最好使用两个 PeerConnection 分别负责上行和下行,或者在一个 PeerConnection 里使用 sendrecv 的方向并小心处理重协商。

3.3 一个最小可跑通的 WebRTC demo:用代码把“时机”串起来

前面讲的是概念,下面我给一个最小但完整的浏览器双端 demo 的关键部分。假设已经有信令服务器帮你转发 offer/answer/candidate,忽略服务端细节,只看创建时机。

async function startCall(localStream) {
  const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });

  // 回调必须先注册,否则容易丢失事件
  pc.onicecandidate = (e) => {
    if (e.candidate) signaling.send({ type: 'candidate', candidate: e.candidate });
  };
  pc.ontrack = (e) => {
    remoteVideo.srcObject = e.streams[0];
  };

  // 本地采集到的 track 在 createOffer 之前添加
  localStream.getTracks().forEach((track) => {
    pc.addTrack(track, localStream);
  });

  // DataChannel 也在 createOffer 前创建
  const dc = pc.createDataChannel('chat');

  // 创建并发送 offer
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  signaling.send({ type: 'offer', sdp: pc.localDescription });

  // 收到远端 answer 后设置远端描述
  signaling.onmessage = async (msg) => {
    if (msg.type === 'answer') {
      await pc.setRemoteDescription(msg.sdp);
    } else if (msg.type === 'candidate') {
      // 如果 remoteDescription 还没设置,先缓存到队列
      if (pc.remoteDescription) {
        await pc.addIceCandidate(msg.candidate);
      } else {
        pendingCandidates.push(msg.candidate);
      }
    }
  };

  return pc;
}

这个 demo 看起来简单,但它把几个关键时机都固定住了:先回调,再添加媒体,再 DataChannel,再 createOffer。实际项目里我还会在 setRemoteDescription 成功后立即把缓存 candidate 补交上去。

await pc.setRemoteDescription(msg.sdp);
while (pendingCandidates.length) {
  const candidate = pendingCandidates.shift();
  await pc.addIceCandidate(candidate).catch((e) => console.warn(e));
}

对于另一个对端,收到 offer 后要回答,同样要遵守“answer 之前把本端想收的媒体、想发的媒体都准备好”。否则 answer 里漏了视频媒体行,视频就黑屏。

3.4 和 FreeSWITCH 这类网关互通时,SDP 顺序更要守规矩

做 WebRTC 并不总在浏览器之间进行,很多时候要接到 FreeSWITCH、SFU 或传统 SIP 网关。FreeSWITCH 的 WebRTC 配置本质上也是让 SIP 侧的媒体描述和 WebRTC 的 DTLS-SRTP 互相适配。这种场景对创建时机的敏感度会比浏览器之间更高。

因为 FreeSWITCH 在处理 SDP 协商时有自己严格的解析逻辑,如果你在 setLocalDescription 之后又新增 Track,或者 DataChannel 创建晚于 createOffer,它可能直接认为这次会话不支持媒体。我遇到过几次很典型的问题:

信令流程完全正常,但 FreeSWITCH 侧打印的错误总是“no audio stream”“no video stream”。后来调试发现,客户端生成的 offer 里没有包含音频 RTP 映射,原因就是我在 createOffer 后才把 audioTrack 添加进去。这会让 FreeSWITCH 以为呼叫方不想发送音频。

另外 FreeSWITCH 对 ICE 的支持范围也比较固定。如果客户端创建的 PeerConnection 里没有显式加入与 FreeSWITCH 匹配的 codec 参数,或者 DTLS 指纹生成顺序不对,媒体路径还是建立不起来。这提醒我们,无论用 Verto 还是 SIP over WebSocket,都要先把媒体协商做好准备,再进入 offer/answer 交换,而不是边协商边往 PeerConnection 里塞东西。

4. 模块创建时机引发的故障与弱网卡顿的关联

4.1 我踩过的几个高发问题速查

现象 可能原因 修改时机
本地有画面,远端一直黑屏 offer 创建后才 addTrack 或 setLocalDescription 前未添加 把 addTrack 放到 createOffer 前
DataChannel 的 open 一直不触发 createDataChannel 晚于 setLocalDescription 把 createDataChannel 移到 createOffer 前
对端收到 ICE candidate 但连接失败 addIceCandidate 调用时 remoteDescription 为空 等 setRemoteDescription 后再处理 candidate
纯拉流时没有远端画面 没有 addTransceiver 指定接收方向 在 createOffer 前 addTransceiver('video', { direction: 'recvonly' })
通话中新增一路视频无效 添加 track 后没有重新协商 需要调用 createOffer 发新 SDP
FreeSWITCH 互通时媒体缺失 添加媒体时机晚于首次协商 确保本地音视频轨在 createOffer 前完全就位

这些问题的共性,就是模块出现的位置偏离了协商时间线。排查时,不要先怀疑网络,先打印本地 SDP 和对端 SDP,看看媒体行是否完整。

4.2 为什么说“重建连接”比“创建时机混乱”更伤弱网表现

弱网卡顿是 WebRTC 里绕不开的话题。很多人一遇到卡顿就想着调整码率、开拥塞控制,但忽略了模块创建时机对弱网体验的影响。

弱网下最怕的场景是:连接状态一变成 disconnected,就在回调里销毁旧 PeerConnection 再 new 一个新的。因为重新创建 PeerConnection 意味着要重新收集 ICE、重新 DTLS 握手、重新产生新指纹,整个过程在一个丢包环境里会非常慢,甚至永远无法完成。相比之下,WebRTC 原本设计的 ICE restart 流程要轻量得多。

ICE restart 本身就是一次重新协商。它要求你调用 createOffer 并带上 iceRestart: true 参数,然后走一遍正常的 setLocalDescription、信令发送、setRemoteDescription 流程。这个过程中,PeerConnection 还是同一个对象,DTLS 连接也能被复用,握手成本大大降低。

所以,模块创建时机在这里的作用是:不要把 PeerConnection 的创建当作“万能重启键”。如果你的架构里允许重建对象,一定要想清楚什么情况下才销毁它。弱网优化第一步,往往是控制住重建的冲动。

我还发现,很多客户端在获取本地流不顺利时会反复调用 getUserMedia,每一轮都会创建新的 MediaStream 对象,却忘了先清理旧的 track。这样在一段时间后,PeerConnection 里积累了多个音频源,导致带宽被重复占用,弱网卡顿反而更严重。

4.3 弱网场景下,模块创建可以做的三件实事

第一,尽早创建 PeerConnection。不要等“音视频流全部拿到”才创建。因为 ICE candidate 收集不需要媒体轨,提前创建 PeerConnection 并触发 ICE 收集,可以让网络探测和权限弹窗并行。在弱网条件下,这能省下一大段建连时间。

第二,用 addTransceiver 而不是等流到了再 addTrack。如果你确定入会后会推视频,可以预先用 addTransceiver('video', { direction: 'sendrecv' }) 占住媒体位。后续即使本地摄像头还没启动,也不影响协商结果,等媒体轨真正可用时再用 sender.replaceTrack 替换。

第三,重协商时做最小变更。比如需要临时关掉视频再打开,不要直接销毁连接重新 new,而是在原 PeerConnection 上操作 sender 的 track,再重新协商。这种做法配合丢包重传和带宽估计策略,能让弱网下的卡顿频率明显降低。

5. 项目落地时我采用的一套经验做法

5.1 把“创建时机”画成状态图,而不是靠记忆

刚开始做 WebRTC 项目时,我喜欢在代码注释里写明“先加 track 再 createOffer”。后来代码多了,这种注释很容易被拷贝错。于是我把 PeerConnection 的生命周期抽象成几个状态:Initialized、Negotiating、Connected、Reconnecting、Closed,并且明确规定在哪个状态允许执行哪类操作。

比如在 Initialized 状态下,允许 addTrack、createDataChannel、addTransceiver,但不允许处理需要远端描述的操作。只有进入 Negotiating 之后才允许 createOffer、setLocalDescription、信令发送。这样能避免很多因为回调竞态导致的顺序错乱。

5.2 关键节点日志不能省:SDP 和媒体轨数量必须打出来

排查询题时最遗憾的不是没日志,而是日志刚好没打关键节点。我现在的项目里,PeerConnection 的所有状态变更都会打出来,包括 signalingState、iceConnectionState、connectionState。每次 createOffer 和 setRemoteDescription 后,也会打出一份 SDP 摘要:有几路 audio、几路 video、有没有 application 媒体行。

这些摘要能帮我快速判断是不是模块创建时机问题。比如我发现 offer 里缺少 audio,那我不用去看完整 SDP,就知道是在 createOffer 前本地音频轨没有添加成功。DataChannel 是否创建成功,也可以通过在 setLocalDescription 后检查 SDP 中是否包含 application 字符串来判断。

5.3 把媒体设备和会话连接解耦,避免“时机”被业务卡住

业务逻辑对媒体状态的影响很容易忽略。比如某些页面支持先登录、再授权摄像头、最后呼叫。如果登录接口慢,用户点完呼叫后已经过去好几秒,这时才去 getUserMedia,就拉长了从点击到振铃的时间。

我后来改成:在用户进入呼叫页面的瞬间就创建好 PeerConnection,预先通过 addTransceiver 占好媒体位。摄像头权限是否开启,不与 PeerConnection 的创建绑定。实际开启摄像头后用 sender.replaceTrack 替换,不需要重新 createOffer。这样媒体设备何时就绪,完全不影响“会话媒体能力”的协商。

同样的思路也适用于 DataChannel。如果业务需要在通话中传输信令扩展,我会在 PeerConnection 创建后的第一时间就创建 DataChannel,而不是等用户点击某个按钮再创建。这样能保证数据通道始终存在,避免二次协商引发的状态复杂度。

很多看起来是网络问题、服务器问题的 WebRTC 故障,顺着模块创建时机这条线查下去,都会找到让人哭笑不得的答案。我个人的经验是,先把自己的 PeerConnection 生命周期彻底捋清楚,再用事件和状态机把每一步约束住,再复杂的推流拉流场景也能变得可控。

Logo

邀请您加入社区

更多推荐