WebRTC核心链路:从MediaStreamTrack采集到addTrack进RTCPeerConnection
2026/9/18 22:36:44 网站建设 项目流程

在WebRTC开发里,“pc”几乎是每个人嘴边挂着的缩写,它的全名是RTCPeerConnection。做音视频通话、直播连麦、屏幕共享,核心动作就那几个:采集、编码、传输、解码、渲染。而“一条Track是如何被添加进pc的”,这个看似普通的问题,实际上是整个媒体链路真正开始流动的起点。很多刚接触WebRTC的同事,卡住的往往不是后面的RTP打包或者带宽估计,反而是最开始这几行代码:怎么把摄像头采集出来的画面,真正“送”到对端去。这篇内容就围绕这个问题,把从采集到轨道进pc的完整过程拆开讲清楚,同时也为系列后续的“媒体流发送”打好基础。

这篇文章适合谁看?准备上手WebRTC的前端工程师,或者刚接触实时音视频、对RTCPeerConnection内部机制还不熟悉的后端同学。你会了解到MediaStream和Track之间的关系、addTrack()到底做了什么、远端是怎么通过ontrack把轨道接住的,以及整个过程中最容易踩的坑长什么样。

1. 先把“轨道”和“流”这两个概念理清楚

1.1 MediaStream只是容器,Track才是真正的数据源

很多人一开始会把MediaStream当成“真正的媒体数据”,但严格来说它只是一个容器。一个MediaStream里可以同时装多条MediaStreamTrack,每条Track负责一类数据:音频轨和视频轨。真正承载数据、最终进入编码器和RTP打包流程的,是Track本身。

打个比方:MediaStream像是一个多路转接头,Track才是线材里正在传输的信号。你拿navigator.mediaDevices.getUserMedia()采集本地设备,拿到的对象直接打印出来会发现它有一个getTracks()方法,返回的数组里装着该流的所有轨道。这个容器的意义在于,它能帮你把关联的音频轨和视频轨组织在一起,方便后续统一管理。

你要把媒体发送给对端,核心操作是把Track逐条交给RTCPeerConnection,而不是把整个MediaStream直接丢进去。新版浏览器早就废弃了pc.addStream(stream)这种把整个流塞进去的老写法,规范推荐的做法就是pc.addTrack(track, stream)。第二个参数stream是可选的历史遗留参数,它的作用主要是为了在协商时告诉对端这条轨道的归属,而不是把整个流的媒体一起传过去。

1.2 为什么会有track、stream两层的设计

从使用者的角度看,音频和视频在延迟要求、编码方式、接收处理上都完全不同。音频需要低延迟、抖动缓冲,视频则更关注帧率和码率控制。如果把一整路“音视频融合流”当作一个整体来处理,双方都很难做精细控制。

所以规范层就把粒度拆到Track级别:每条Track独立编码、独立成RTP流、独立进行带宽分配。MediaStream则负责表达“这些Track在业务上属于同一路画面”,比如一个摄像头画面加一段麦克风声音,远端用stream.getVideoTracks()stream.getAudioTracks()就能分别拿取。这个设计在屏幕共享场景里更明显:一条屏幕轨道加一条麦克风轨道,共享端把它们放进同一个MediaStream里,接收端才能把声音和画面作为同一个“展示内容”看待。

2. Track从哪里来:getUserMedia()的采集细节

2.1 获取本地音视频轨的标准走法

要从真实设备拿到Track,代码非常短:

const stream = await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720, frameRate: 30 }, audio: { echoCancellation: true, noiseSuppression: true } }); const videoTrack = stream.getVideoTracks()[0]; const audioTrack = stream.getAudioTracks()[0];

拿到的videoTrack是一个MediaStreamTrack实例,它的kind"video"readyState"live"。调用getUserMedia()的时候,浏览器会弹权限框,用户允许之后才真正开始采集。

要注意的是,getUserMedia()返回Promise,用户拒绝授权或者设备不可用都会走reject分支。实际项目里必须处理NotAllowedErrorNotFoundErrorNotReadableError这几类典型错误。很多新手在采集失败时直接抛异常导致白屏,就是因为漏了权限被拒时的提示处理。

2.2 轨道状态切换:从live到ended

Track有一个很重要的属性readyState,它只有两个值:liveended。正常情况下采集状态为live,一旦设备被拔出、系统权限被收回,或者你主动调用track.stop(),它会变成ended

这里有个实践经验:当你结束了通话或停止共享屏幕时,一定要主动stop()掉采集轨,否则摄像头指示灯会一直亮着,麦克风也在后台采集,移动端尤其明显,用户会立刻发现系统的录音图标还在顶部挂着。这在做屏幕共享时尤其容易漏:共享结束后忘了停掉轨道,浏览器底部会一直提示“页面正在共享屏幕”,用户下次点击会非常疑惑。

2.3 不一定要本地采集,Track也可以来自其他来源

getUserMedia()只是Track最常见的来源,但不是唯一来源。屏幕共享场景用的是getDisplayMedia(),本地文件、Canvas实时绘制、Web Audio处理后的输出,都可以通过captureStream()方法得到对应的MediaStreamTrack。所以在做WebRTC开发时,可以把Track理解成一个“统一的数据出口”,不管内部数据是摄像头、屏幕还是Canvas,交给RTCPeerConnection之后,下层的处理逻辑完全一致。

3. 轨道进入RTCPeerConnection:addTrack()到底做了什么

3.1 一条addTrack()背后的三个隐藏动作

现在到重点部分。把轨道添加进pc,代码只有一行:

const sender = pc.addTrack(videoTrack, stream);

表面上看只是把轨道的归属交给pc,但底层至少同时发生了三件事。

第一,pc内部为这条轨道创建了一个RTCRtpSender,Senders负责后续的编码和RTP发送。第二,pc监听这条Track的状态,当Track内容变化、参数变化时,触发内部处理逻辑。第三,pc在内部标记这条轨道的收发方向为sendrecv,并准备在下一次协商时把这条轨道的信息写入SDP。

addTrack()的返回值sender不是摆设。通过它可以做很多后续操作,比如动态切换轨道内容:

await sender.replaceTrack(newVideoTrack);

这是直播场景中切换摄像头非常核心的手段,不需要重新协商,对端画面会平滑切换。另外,sender.getParameters()能获取当前编码参数,sender.setParameters()能修改码率上限、分辨率等参数,这些在后续文章里会展开。

3.2 关于transceiver:addTrack背后的“收发一体”设计

新版WebRTC还有一个比RTCRtpSender更容易被忽略的概念,就是RTCRtpTransceiver。每个RTCRtpSender都对应一个RTCRtpTransceiver,而transceiver内部除了Sender,还会管理对应的接收端。

为什么要这样设计?因为WebRTC的媒体协商是双向的:一方声明要发一条视频轨,另一方可能也有视频轨要发回来。为了让双方能够在同一组媒体协商描述里表达“我这个sendrecv方向和你的sendrecv方向是配对的”,SDP中就需要一个统一的m-line来承载双方的收发描述。transceiver就是这组m-line在浏览器内部的实体。

你在WebRTC里调用pc.addTrack()时,浏览器会创建或复用transceiver,并用sender封装发送侧逻辑。如果直接想创建一个不绑定轨道的transceiver,规范也允许:

const transceiver = pc.addTransceiver('video', { direction: 'recvonly' });

这对纯接收端的场景非常有用,比如拉流播放页面,就可以用这种方式提前声明要接收视频轨。

3.3 addTrack与negotiationneeded事件

addTrack()之后,pc内部会检测到协商状态变化,紧接着就会触发negotiationneeded事件。这个事件的意思是:本地SDP已经和当前媒体状态不一致了,需要重新进行offer/answer交换。

pc.onnegotiationneeded = async () => { const offer = await pc.createOffer(); await pc.setLocalDescription(offer); // 然后通过信令把offer发送给对端 signaling.send({ type: 'offer', sdp: offer }); };

这个流程是WebRTC里最容易出问题的地方之一。很多人在addTrack()之后立刻createOffer(),但negotiationneeded是异步触发的,在事件回调里创建offer才是规范的姿势。如果自己在外部手动触发协商,要注意避免重复协商:一个可靠的模式是维护一个makingOffer的布尔标志,防止上一次协商没结束又发起新一轮offer。

实践中最典型的错误,是在信令回调里收到answer之后没有检查pc.signalingState,导致状态机混乱。加一个保护逻辑会稳很多:

async function handleAnswer(desc) { if (pc.signalingState !== 'stable') { await Promise.resolve(); // 等待当前协商完成 } await pc.setRemoteDescription(desc); }

4. 轨道进pc之后,SDP里发生了什么

4.1 m-line与媒体描述

当你执行完createOffer()setLocalDescription()之后,拿到的SDP里就会出现和Track一一对应的媒体描述行。

一个视频轨道会对应一段以m=video开头的描述,里面包含媒体格式(codec)、传输地址、SSRC等信息。如果你只添加了视频轨而没有音频轨,SDP里就不会出现m=audio行。这就是为什么主播端打开摄像头后,对端才看得到画面,而如果只发画面不采集音频,对端SDP里也不会收到音频相关的m-line。

这里有个很容易让业务方困惑的现象:很多时候WebRTC连接建立成功后,页面控制台显示“连接成功”,但是对端就是没有画面。问题往往出在协商时两端媒体描述不一致。比如本地addTrack()了视频轨,但创建offer后又调用了pc.setDirection()或者改了transceiver的direction,导致SDP里的媒体方向和实际期望不同,远端解析m-line后没有创建接收器,自然不会触发ontrack

4.2 方向(direction)字段的语义

SDP里每个m-line都带有一个a=sendonlya=recvonlya=sendrecv这样的方向属性。方向属性是协商双方共同决定的:本地offer写sendrecv,远端answer如果同意接受媒体,通常也会给一个sendrecvrecvonly作为应答。

方向属性对应到API层面就是transceiver的direction字段。你可以这样主动设置:

pc.addTransceiver('video', { direction: 'sendonly' });

上述代码表示“我只发不收”。这在推流端很常用,比如设备端把摄像头画面推送到服务器,它不需要接收对端视频,就可以把direction设置为sendonly,避免白白给自己分配接收通道和带宽。反过来,播放端就用recvonly

调节direction不是随便调的。改完方向之后同样需要重新协商,否则对端拿到的还是旧的SDP描述,双方的实际媒体收发能力就对不上。

4.3 SSRC与Track的一一对应

SDP里还有一个关键信息是SSRC(同步源标识符),它用来唯一标识一路RTP流。每个Track被添加进pc后,浏览器会自动分配一组SSRC。对端收到RTP包时,靠SSRC区分这是哪条轨道的数据,再映射到对应Track上。

这就解释了为什么很多调试场景里,用pc.getSenders()打印sender列表,再和SDP里的a=ssrc对得上。如果SDP中的SSRC和实际发送的SSRC不一致,对端可能直接丢掉这些包,表现就是“收到了RTP但渲染不出来”。好在浏览器底层通常不会出这种错,但排查问题时知道这个对应关系,能少走很多弯路。

5. 对端是怎么收到这条Track的:ontrack触发链路

5.1 从answer到Track的完整链路

本地添加Track并完成协商后,对端的RTCPeerConnection会在合适的时机触发ontrack事件,把收到的媒体轨交给业务代码。

这个“合适的时机”并不是收到RTP包的时候,而是在setRemoteDescription()成功之后。因为远端的offer/answer SDP里已经包含了媒体描述,浏览器解析出m-line和媒体方向后,就知道本地应该创建接收用的Track了。

一个标准的接收端代码是这样写的:

pc.ontrack = (event) => { const [remoteTrack] = event.streams[0].getTracks(); if (remoteTrack.kind === 'video') { remoteVideo.srcObject = event.streams[0]; } };

event.streams来源于发送方在addTrack(track, stream)时传入的那个stream对象。如果发送方没有传第二个参数,event.streams可能为空数组,这时依然可以通过event.track拿到远程轨道,再手动构造一个MediaStream:

pc.ontrack = (event) => { if (event.track.kind === 'video') { const stream = new MediaStream([event.track]); remoteVideo.srcObject = stream; } };

5.2 为什么ontrack可能在iceConnectionState之前触发

很多入门者会以为要等iceConnectionState变成connected之后,媒体数据才能到达。实际上,媒体协商和ICE连通性是两个独立的过程。ontrack通常是在SDP协商完成后就触发了,而ICE还在进行中。也就是说,ontrack触发只代表媒体轨道“逻辑上”建立好了,但RTP包真正能传过来,还得等ICE通道通了才行。

这个细节很实用:调试时如果看到ontrack已经触发,但页面黑屏、没有声音,优先去看候选者和ICE连通状态,而不是一遍遍重新协商。

5.3 不要忘记处理track的ended状态

远程Track同样是MediaStreamTrack,也有readyStateended状态变化。当远端停止发送、挂断或者轨道被替换时,远程Track可能会进入ended状态。在真实项目中,监听这个状态能帮我们做出合理的UI反馈:

remoteTrack.onended = () => { // 显示“对方已关闭摄像头”或者清理播放器 };

如果不监听这个事件,往往会出现“画面冻住”的假象,其实是远端已经停流了好几秒。

6. 跑通全流程:一个最简一收一发示例

6.1 发送端完整逻辑

假设现在有两个RTCPeerConnection,A作为发送端、B作为接收端。为了演示方便,我们不接真实信令服务器,直接在同一个页面里用两个pc对接,这样能更清楚地看到轨道是怎么一路走过去的。

A端逻辑如下:

const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); const pcA = new RTCPeerConnection({ iceServers: [] }); localStream.getTracks().forEach((track) => { pcA.addTrack(track, localStream); }); pcA.onicecandidate = (e) => { if (e.candidate) { pcB.addIceCandidate(e.candidate); } };

B端逻辑如下:

const pcB = new RTCPeerConnection({ iceServers: [] }); pcB.ontrack = (event) => { const [stream] = event.streams; const videoEl = document.getElementById('remoteVideo'); videoEl.srcObject = stream; }; pcB.onicecandidate = (e) => { if (e.candidate) { pcA.addIceCandidate(e.candidate); } };

最后通过offer/answer完成协商:

const offer = await pcA.createOffer(); await pcA.setLocalDescription(offer); await pcB.setRemoteDescription(offer); const answer = await pcB.createAnswer(); await pcB.setLocalDescription(answer); await pcA.setRemoteDescription(answer);

在一个页面里看到A端摄像头画面通话B端播放,这个demo就算跑通了。它的意义在于验证一个链路:getUserMedia采集Track,addTrack进pcA,SDP协商到pcB,pcB触发ontrack,播放到video标签。这个链路是所有WebRTC音视频应用的基础。

6.2 一个容易被忽略的时序问题

上面的demo中,我故意省略了信令传输延迟。真实环境中,offer和answer的交换需要经过服务器,而这个网络延迟会导致一个典型问题:如果对端在收到offer时还没有准备好处理SDP,或者offer到达时pc已经被重新协商过,就会抛出“InvalidStateError”。

解决思路是,始终让对端在signalingStatestable时再调用setRemoteDescription()。如果当前处于非stable状态,说明有协商正在进行,需要排队或等待上一个协商完成。不要试图用pc.restartIce()之类的操作强行打断,这会引入更多bug。

6.3 用setTimeout模拟真实网络时序

为了更贴近真实场景,你可以在demo里给交换SDP的过程加一点延迟:

const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); await pcA.setLocalDescription(offer); await sleep(100); await pcB.setRemoteDescription(offer); await pcB.setLocalDescription(answer); await sleep(100); await pcA.setRemoteDescription(answer);

这种做法能暴露出不少“本地瞬间完成时完全没问题,但一有网络延迟就崩”的时序问题,强烈建议你在试用新API时把这段加上。

7. 常见报错与排查技巧实录

7.1 “Failed to execute 'addTrack' on 'RTCPeerConnection'”

这个问题经常出现在pc已经处于closed状态,或者Track已经被stop()之后。逻辑上,你不能往一个已经关闭的pc里添加轨道,也不能把已结束的Track作为媒体来源。

遇到这个报错,先检查调用顺序:确保pc还没有关闭、Track的readyState还是live。还有一种比较隐蔽的情况:同一个Track被同时添加到多个pc里。虽然规范允许这样做,但某些浏览器版本对同一Track的多pc复用处理得不好,可能出现异常表现。稳妥的做法是每个pc单独采集一份流,或者用replaceTrack()动态注入。

7.2 ontrack没有被触发

这是WebRTC新手最容易遇到的现象。多半是以下原因之一:

第一,发送端添加了Track但没有完成协商,或者offer里的媒体描述被清空了。排查方法:打印SDP,确认里面有m=video行。第二,接收端在setRemoteDescription()之前没有注册ontrack事件处理器。第三,使用了旧的addStream()接口,新浏览器虽然兼容,但ontrack事件语义和旧事件模型有差异,容易出现“stream有、track却有undefined”的问题。

7.3 音频正常、视频黑屏

音频正常说明链路和协商都没问题,问题一般出在视频渲染上。检查一下video标签是否有autoplay属性,浏览器为了防自动播放策略,无声音视频可能无法自动播放。另外,确认video.srcObject绑定的对象里确实包含视频轨,而不是只包含音频轨。

还有一种常见情况:你把video标签的宽高设成了0,或者CSS把元素隐藏了,看起来就像黑屏。

7.4 画面卡住但连接没有断

视频画面冻结,但iceConnectionState仍然是connected,这种情况多半是远端停止发送了。先看远端是否调用了sender.replaceTrack(null),或者track.stop()。如果是静态画面卡住,也有可能是网络丢包严重但没有触发重协商,此时建议检查丢包统计和getStats()接口。

getStats()能帮你看到每个sender的丢包字节数:

const stats = await pc.getStats(); stats.forEach((report) => { if (report.type === 'inbound-rtp' && report.kind === 'video') { console.log('packetsLost:', report.packetsLost); } });

丢包持续增长时,就要检查网络带宽和拥塞控制策略,这已经是后续文章要展开的深层话题了。

8. 关于这个系列接下来我想做的事

这条Track从采集到进pc,只是媒体流发送的第一站。后面还有编码器参数协商、RTP打包发送、带宽估计与拥塞控制、丢包重传、码率自适应等一系列问题。写这一篇的时候,我刻意把addTrack的底层动作写得比官方文档更“重”一些,是因为我发现很多开发者在排查问题时,对Track、Sender、Transceiver这三者的职责边界不清楚,导致一旦现象异常,不知道应该查SDP、查sender还是查transceiver。

我自己在早期的项目里就吃过这个亏:有一次远端一直不触发ontrack,我怀疑是ICE候选者没到达,日志打了一层又一层,最后才发现是本地addTrack之后没有走negotiationneeded流程,offer里根本没带上视频m-line。自那以后,每接一个新版本浏览器或者新项目,我都会第一时间打印SDP,看看媒体描述有没有如预期出现。这个小习惯帮我定位了很多“看起来玄学”的问题。

如果你正准备写自己的第一版WebRTC应用,希望你读完这篇后,至少有一个清晰的手感:想把媒体送出去,先拿到Track,然后addTrack进pc,跟着协商,最后对端ontrack接住。链路不复杂,但每条链路的每一步都有坑。后续我会继续把发送端剩下的环节一个个拆开讲,包括如何控制码率、如何选择编码器、如何处理动态带宽变化,这些都是“能通”和“能商用”之间最核心的距离。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询