WebRTC关键模块创建时机:从黑屏到卡顿的排查思路
做 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 之前完成。
我常用的推流初始化顺序是这样的:
- 调用 getUserMedia 拿到本地媒体对象。
- 创建 RTCPeerConnection,并设置 onicecandidate、ontrack、onconnectionstatechange 回调。
- 遍历本地流里的 track,逐个调用 pc.addTrack(track, localStream)。
- 按照业务需要调用 createDataChannel 创建数据通道。
- 调用 pc.createOffer 生成 SDP,setLocalDescription,通过信令发送 offer 给对端。
- 收到对端 answer 后调用 setRemoteDescription。
- 边收集边发送 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 生命周期彻底捋清楚,再用事件和状态机把每一步约束住,再复杂的推流拉流场景也能变得可控。
更多推荐
所有评论(0)