ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

WebRTC中Track与Stream的区别:容器与数据源详解

WebRTC中Track与Stream的区别:容器与数据源详解 先问大家一个问题当你写下pc.addTrack(track, localStream)的时候第二个参数stream到底起了什么作用如果你答不上来或者认为MediaStream本身就是一个装着音视频数据的“管子”那说明你还没有绕出 WebRTC 里 Track 与 Stream 的坑。我这些年做音视频项目见过太多把这两者混为一谈的代码——有人把getUserMedia返回的stream整个塞进video.srcObject就完事也有人说“我把流停掉了啊”结果只调了stream.stop()这种根本不存在的 API真正让设备熄灯的其实是track.stop()。这篇我会从 API、SDP、RTP 底层三个层面把 Track 与 Stream 的本质掰开讲清楚为什么 Stream 只是容器、Track 才是真正的媒体数据源并给出可直接抄作业的点对点示例和排查经验。适合刚入门 WebRTC 的开发者也适合那些被“流断了”“流没声音”之类问题折腾过的人。看完你至少能回答这几个问题addTrack 的第二个参数为什么不是多余的远端 ontrack 里的event.streams到底哪儿来的以及为什么删掉一条轨道远端看到的状态和你想象的不太一样。1. 先搞清楚一条流和一条轨到底谁在承载数据1.1 一句话本质Stream 是容器Track 是数据源很多从传统视频流媒体转过来的人天然会把“流”理解成一路连续的视频数据于是第一次接触 WebRTC 时会觉得MediaStream就是那根“水管”。但 WebRTC 里的设计并不是这样。更准确地说MediaStream只是一个逻辑分组它的职责是“把若干条轨道打包成一个整体”真正在采集、编码、封装 RTP 包的是MediaStreamTrack。打个比方一个摄像头是饮水机一个麦克风是水壶你往杯子里倒水杯子就是MediaStream。饮水机和水壶可以单独存在杯子只是一个组合工具。没有杯子的饮用水仍然是水但你要端给客人喝杯子能让你一次端走“纯净水柠檬水一点冰块”而且告诉客人“这是一杯饮料”。同理摄像头视频轨和麦克风音频轨没有任何物理上的关联但把它们放进同一个MediaStream接收端就可以按照这个分组去渲染和播放。理解这一点是本文的地基。后面要讲的所有 API 行为、SDP 结构、远端事件参数都是在围绕“谁是真数据谁是标签”这两件事展开。1.2 从 getUserMedia 返回值看两者关系你调用navigator.mediaDevices.getUserMedia时浏览器内部做的其实是三件事打开设备、创建媒体源、把媒体源包装成MediaStreamTrack并塞进一个MediaStream。所以返回值看起来像一个“视频文件”实际上它是一个只有分组的壳。试试这段代码你会看得特别清楚const stream await navigator.mediaDevices.getUserMedia({ video: { width: 1280, height: 720, frameRate: 30 }, audio: { echoCancellation: true, noiseSuppression: true } }); console.log(stream.constructor.name); // MediaStream console.log(stream.getTracks().length); // 2一视频一音频取决于设备 console.log(stream.getVideoTracks()); // [MediaStreamTrack ...] console.log(stream.getAudioTracks()); // [MediaStreamTrack ...] console.log(stream.active); // true表示容器内存在 live 的轨道关键问题来了stream.active这个属性经常被误解。它只表示“当前至少有一条 readyState 为 live 的轨道”但它不是一个开关你不能用stream.active false来停止采集设置了也没效果。真正决定设备是否在工作要去看每条轨道的状态和enabled标志。轨道上有三个属性最容易混淆readyState只有live和ended两种轨道资源是否已经在传输/捕获它由浏览器内部决定。设备拔掉、页面失去权限、调用stop()后状态才会变化。muted表示“这一刻没有数据可用”例如麦克风被系统静音、摄像头被别的应用独占但轨道并没有结束随时可能恢复。enabled一个“软开关”设置enabled false后数据内容会被替换成黑帧/静音帧但设备仍然在采集CPU 和带宽依然在消耗只是内容被遮挡了。这三者的区别直接决定你调试时看哪个字段、用什么 API 去控制采集。很多新手把enabled false当成“省电模式”其实完全不是。1.3 为什么容器这个概念不能省既然 Track 才是数据源那 Streaming 分组还有什么意义答案是为了“业务语义”。一个MediaStream可以包含一路视频和一路音频接收端拿到后可以直接把它塞进一个video标签它也可以包含两路视频和一路音频接收端拿到后可以按组处理字幕、画中画、录制混流。在实际的多人会议里每个参会者通常把自己的摄像头和麦克风组合成一个MediaStream房间里的逻辑是“一个用户对应一个流”。远程端在收到该用户的轨道时把它们还原到同一个Stream分组中上层只关心“用户 A 的流”而不用去维护一堆零散的 Track 映射关系。这个概念在 SDP 里通过 MSID 来承载后面第二章会看到具体长相。所以容器不是多余的“包装纸”它是 WebRTC 把多条并行媒体组织给上层使用的基本单位。不夸张地说你看不懂ontrack里的event.streams数组就到不了能自如管理多路音视频的水平。2. 在 RTCPeerConnection 里Track 和 Stream 是如何被“打包”传输的2.1 addTrack 的第二个参数到底在干什么老版本 WebRTC 里有个addStream()现在已经废弃了官方推荐用addTrack()。但很多人只是机械地照抄示例代码写pc.addTrack(track, localStream)时根本不知道localStream用来干嘛。这个第二个参数的作用是告诉浏览器“这条轨道归到哪个 Stream 分组下面”。浏览器会把Stream.id和Track.id写入本地 SDP 的amsid行。对端在收到 SDP 和 RTP 包后可以通过这个 MSID 还原出“某条轨道属于哪个 Stream”。另一个细节addTrack其实可以传多个 Stream也就是说一条轨道可以同时属于多个流组pc.addTrack(videoTrack, streamA); // 最常见用法 pc.addTrack(videoTrack, streamA, streamB); // 同一条轨同时挂在两个流下但注意这里说的是“可以在不同分组里引用同一个 Track”不代表你可以把同一条 Track 通过addTrack在同一个RTCPeerConnection里添加两次。如果对同一个 Track 调用两次addTrack规范明确会抛出InvalidTrackError。原因是 SDP 里不能出现两条相同的 m-line 对应同一个轨道资源发送端也无法区分你要发的到底是哪一份。这种设计的好处是分组信息完全通过信令面和维护在 SDP 里不影响 RTP 媒体面。媒体面只认 SSRC 和 Track 的关系而分组信息只存在于会话协商阶段。2.2 一轨一 m-lineUnified Plan 下的轨道与流映射2018 年之前Chrome 默认用的是 Plan B 协议一条 m-line 可以塞多个 Track用assrc-group和多个аssrc行来区分。Plan B 实现简单但扩展性差尤其是遇到 simulcast、多路视频混流时非常别扭。现在所有主流浏览器都转向 Unified Plan每条轨道独占一个 m-line映射关系变成“一个 m-line ↔ 一个 Track ↔ 一个流或不挂流”。下面对比一下 SDP 里的长相。Plan B 大致是这样mvideo 9 UDP/TLS/RTP/SAVPF 96 assrc:111 cname:abc assrc:222 cname:abcUnified Plan 则是每个视频轨一个 m-line每行里会带明确的 MSID 信息mvideo 9 UDP/TLS/RTP/SAVPF 96 amsid:streamA track1 assrc:111 msid:streamA track1 assrc:111 cname:abc mvideo 9 UDP/TLS/RTP/SAVPF 96 amsid:streamA track2 assrc:222 msid:streamA track2 assrc:222 cname:abc你看amsid:streamID trackID这行就是容器和轨道的“户口本”。至于同一个流下有多条轨道那就让多个 m-line 都写上同一个streamID。对端解析 SDP 时就会把轨道 1 和轨道 2 归到同名的streamA下。为什么要推行这种“一人一位”的模型因为媒体能力协商变得非常灵活。每条 m-line 可以独立声明sendrecv、sendonly、recvonly、inactive四种方向而 Plan B 很难精细控制“这一路只能收、这一路只能发、这一路要暂停发送但保留接收”。如果你以后要动态增减轨道或者做大小流Unified Plan 几乎是必须的。2.3 远端如何通过 ontrack 还原 Track 和 Stream 关系当对端收到媒体时不会像“一个文件下载完成”那样一次性通知你而是每条 m-line 协商成功后触发一次ontrack事件。事件参数里最重要的三个字段是event.track、event.streams和event.receiverpc.ontrack (event) { const remoteTrack event.track; // MediaStreamTrack来自远端 const remoteStreams event.streams || []; // MediaStream 数组 const remoteReceiver event.receiver; // RTCRtpReceiver const stream remoteStreams.find(s s.id.includes(streamA)); if (stream) { videoEl.srcObject stream; // 直接把流挂上去 } console.log(收到远端轨道, remoteTrack.kind, remoteTrack.id, readyState:, remoteTrack.readyState); console.log(它所属的流, remoteStreams.map(s s.id)); };这里有一个 WebRTC 入门用户非常容易踩的坑ontrack触发时event.streams数组有可能不是空的但如果你只是要渲染视频直接把event.track单独做成一个 MediaStream 也能播放// 如果一定要用单条 track可以手工包一个 Stream const singleStream new MediaStream([event.track]); videoEl.srcObject singleStream;但这样会丢失轨道原本的“分组语义”。在一个多人会议里你收到的可能是两个人各自的一频轨和视频轨它们各自有不同的 stream id 和 track id。如果你不按 stream 分组想“把 A 的视频和 A 的音频绑定在一起”就要自己维护一张表代码很快就臭了。所以耳听为虚眼见为实——直接在event.streams基础上做业务分组是最省心的写法。3. 实操从本地采集到双端互通看清 Track/Stream 的完整生命周期3.1 本地采集与轨道控制枚举、启停、clone先看轨道枚举和控制。拿到MediaStream后你常用的是getTracks()拿到所有轨道再用getVideoTracks()和getAudioTracks()按类型筛选。但注意这几个方法返回的都是“引用”你修改的是原 Track不是在操作副本。实际调试时可以用getSettings()看当前分辨率、帧率、设备 IDconst [videoTrack] stream.getVideoTracks(); const settings videoTrack.getSettings(); console.log(settings.width, settings.height, settings.frameRate, settings.deviceId);然后再讲两个特别容易搞混的操作。第一个是enabled false和stop()的区别。enabled false之后摄像头指示灯常常还亮着因为采集器并没有停止只是 WebRTC 栈在发送时用黑帧替代了真实画面。这对业务很有用比如“临时不出镜但不想重新获取设备权限”就可以把视频轨enabled置为 false等会再置为 true 就能恢复。而stop()是真的把采集破坏了之后 track 的 readyState 变成 ended再想恢复只能重新getUserMedia。第二个是clone()。videoTrack.clone()会返回一条新的 Track它和原轨道共享一个采集源。这意味着你改 clone 的enabled不会影响原轨但 clone 出来的轨道也占着一份发送带宽如果两条轨都在 peer connection 里且都编码发送码率是叠加的。我踩着过的实际坑是有人想用 clone 做“本地预览 远端发送”双份结果本地预览的分辨率和远端发送不同结果把带宽吃了两倍。正确做法是本地预览用原轨发送用原轨或 clone 后再按需要调整编码参数而不是无脑 clone 后塞进 peer connection。3.2 点对点连接一个可直接运行的最小互连示例搭建点对点连接时最标准的流程是本地采集 - 创建 RTCPeerConnection -addTrack- 创建 Offer - 交换 SDP - ICE 交换 - 对端ontrack接收。这里给一个用 Browser BroadcastChannel 做信道的简化全流程示例适合大家在本地开两个页面测试。发起方const pc new RTCPeerConnection({ iceServers: [] }); const localStream await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); localStream.getTracks().forEach(track { // 关键把两条轨道都挂到同一个 localStream 下 pc.addTrack(track, localStream); }); pc.onicecandidate (e) { if (e.candidate) { channel.postMessage({ type: candidate, candidate: e.candidate }); } }; const offer await pc.createOffer(); await pc.setLocalDescription(offer); channel.postMessage({ type: offer, sdp: pc.localDescription });接收方const pc new RTCPeerConnection({ iceServers: [] }); pc.ontrack (event) { const [remoteStream] event.streams || []; if (remoteStream) { remoteVideo.srcObject remoteStream; } console.log(track kind:, event.track.kind, readyState:, event.track.readyState); }; pc.onicecandidate (e) { if (e.candidate) { channel.postMessage({ type: candidate, candidate: e.candidate }); } }; async function onOffer(desc) { await pc.setRemoteDescription(desc); const answer await pc.createAnswer(); await pc.setLocalDescription(answer); channel.postMessage({ type: answer, sdp: pc.localDescription }); }跑起来之后接收方的ontrack通常触发两次一次video一次audio两次event.streams里都是同一个MediaStreamstream id 相同。把这个现象打印到控制台看一眼你对“Track 和 Stream 的关系”就会有肌肉记忆。3.3 用 SDP 和 webrtc-internals 验证映射关系代码写完了怎么证明 Stream 和 Track 确实在底层被打包得明明白白两个办法。第一个直接在控制台看localDescription.sdpconst sdp pc.localDescription.sdp; const msidLines sdp.split(\n).filter(line line.includes(amsid)); console.log(msidLines.join(\n));你会看到类似这样amsid:8f5a5c1a-6f2d-4b3e-9a3d-123456789abc 14a9e5f2-2f42-4d15-9f31-abcdefabcdef amsid:8f5a5c1a-6f2d-4b3e-9a3d-123456789abc 66a5f5b5-7a44-44aa-9d12-xxxxxxxxxxxx其中第一个 UUID 是 stream id第二个是 track id。两条轨道共用一个 stream id正说明它们被归到了同一个MediaStream。第二个打开chrome://webrtc-internals找到对应连接的 outbound-rtp / inbound-rtp 统计项。里面能看到每个 RTP 流对应的trackId、mediaStreamId、kind等字段。把这里的mediaStreamId和 SDP 里的amsid对应上你就能把“RTP 包级的流”和“上层 API 的 Stream”彻底对应起来了。这里顺带说个经验以后你要分析“是不是某条 Track 没发送数据”先看 outbound-rtp 中该 track 对应的bytesSent有没有增长再看framesEncoded有没有增长基本能定位是采集端问题还是编码发送问题。3.4 动态增减轨道removeTrack 之后发生了什么WebRTC 早期给人印象是“建立连接后就不能动了”现在完全不是。你可以在任意时刻动态添加或移除轨道。移除使用pc.removeTrack(sender)这里sender是addTrack时的返回值也可以从pc.getSenders()里取。// 先添加视频轨 const videoSender pc.addTrack(videoTrack, localStream); // 几秒后想移除视频轨 pc.removeTrack(videoSender);但要注意一个重要行为removeTrack并不会让对端的track.readyState立刻变成ended也不会触发track.onended。它只是把本地 SDP 中对应 m-line 的传输方向改成inactive意思是“我不会再从这个轨道发数据了”。对端能感知到的通常是event.track的muted变为 true仅部分实现音频/视频长时间没有新数据播放器画面冻结或黑屏对端通过getStats()看到该 track 对应 inbound-rtp 的bytesReceived停止增长如果你想让对端明确知道“这条 Track 完了”最稳妥的做法是走应用层信令通知或者在 removeTrack 之后主动触发一次negotiationneeded让双方把 SDP 更新掉。否则只依赖媒体面判断会有很大延迟和模糊性。动态增加轨道则相反pc.addTrack添加后会自动触发negotiationneeded你只要在negotiationneeded事件里重新创建 Offer 走信令流程即可。这也是现代 WebRTC 应用的标准姿势有多少轨道就 add 多少随时可增减。4. 链路容量估计如何影响 Track码率、拥塞与自适应4.1 先泼一盆冷水很多“stream disconnected”报错和 WebRTC 无关网络上搜 WebRTC 相关关键词时很容易带出一堆“stream disconnected before completion: stream closed before response.completed”“stream disconnected before completion: upstream request failed”之类的报错。我在这里必须明确说一句这类报错绝大多数出现在 HTTP/SSE 流式接口、某些 AI Agent 工具、代理网关的流式响应场景里跟 WebRTC 的MediaStream没有一毛钱关系。比如 Java 里的Stream、Node.js 里的Stream、硬件总线里的AXI-Stream甚至安装 Altium Designer 时的stream write error它们只是英文里同一个单词。你要是拿搜 Java Stream 八股文的经验去套 WebRTC 的 Track/Stream只会越绕越远。真正确认报错归属的办法是看上下文——是不是跟RTCPeerConnection、getUserMedia、RTP这些词在一起。如果不是别往 WebRTC 上靠。4.2 WebRTC 侧的“流断开”到底由谁决定在 WebRTC 里真正能让所有 Track 都挂掉的事件是连接层失效ICE 失败、DTLS 传输关闭、连接状态变为 failed 或 disconnected 且无法恢复。此时pc.onconnectionstatechange会给你信号连接结束之后远端相关的track才会陆续变成ended。但网络抖动、丢包、短暂卡顿不会直接让 Track 变成 ended。连接还在只是传输质量变差。所以你在做产品时不要依赖track.readyState去判断对端是不是没网了应该通过iceConnectionState、connectionState和getStats()里的currentRoundTripTime、packetsLost等指标综合判断。这也就是为什么真正做音视频监控的人会去读chrome://webrtc-internals和RTCOutboundRTPStreamStats而不是只看“流是否断开”。4.3 带宽估计器如何作用于每条 Track链路容量估计是 WebRTC 拥塞控制的核心和 Track 的编码码率直接相关。它的基本模型是发送端为每个 RTP 包加一个transport sequence numbertransport-cc 扩展接收端记录每个包的到达时间周期性地把延迟信息通过 RTCP Feedback 发回。发送端根据“延迟有没有持续增长”“丢包率是不是升高”判断网络是否拥塞从而调整目标码率。当估计出的带宽下降时编码器会压低每条 Track 的编码码率优先降低编码目标码率target bitrate帧率可能下降极端情况下分辨率也会被降甚至丢帧这一层和MediaStreamTrack是紧耦合的Track 采集原始帧编码器负责压缩而带宽估计器指导编码器“往低了压”。所以你观察到的现象经常是网络一抖动画面先模糊、再卡顿、再黑屏而Track.readyState始终是 live。有人以为是“流断了”其实是码率被 BWE 砍下来了而已。在这些机制里“流”这个单词再次出现了许多次但请你记住底层 RTCP Feedback 里说的“流”有时指 RTP 流一条 Track 的媒体数据包序列有时指拥塞控制流一个连接内所有 RTP 流的集合。它跟 API 上层的MediaStream容器不是同一个东西。搞清楚这个你再读那些讨论带宽估计的文章就不会懵了。5. 常见问题速查Track/Stream 相关的坑与排查5.1 为什么 track.clone() 看起来像同一条轨行为却各自独立问题本地调了track.clone()给另一个 video 标签预览修改 clone 的enabledfalse后原轨居然还能正常显示。原因clone()创建了新 Track 对象但底层媒体源是同一个。enabled和stop()是轨道级别的开关所以 clone 的开关不会直接作用于原轨。但底层采集源是否释放由所有引用它的 Track 共同决定。如果原轨停了clone 轨还在采集源可能继续维持反过来也一样。建议不要指望 clone 能“复制一份数据流”它更适合做“同一采集源的多个出口”。需要独立设备的话重新getUserMedia指定另外的deviceId。5.2 移除 track 后远端怎么看起来好像“没变化”问题pc.removeTrack(sender)之后对端画面没有立刻消失过了好几秒才黑屏。原因removeTrack只是 SDP 传输方向改为inactive对端不会把 Track 状态改为 ended。播放器因为没有新帧会延迟显示最后一帧或等待一段时间才黑屏。建议产品层面可以主动通过信令通知对端“这条通道关了”让对端移除视频元素、显示占位图。也可以用RTCRtpReceiver的track.onmute事件配合但不同浏览器行为有差异信令最可靠。5.3 同一个 track 加两次为什么报错问题想在同一个 RTCPeerConnection 里把本地视频轨同时用于“视频主画面”和“小窗画中画”于是调了两次addTrack(videoTrack, stream)结果抛InvalidTrackError。原因规范明确禁止同一个 Track 在同一个连接内重复发送。一个 Track 只能对应一个RTCRtpSender。建议如果只是本地展示画中画直接用两个video元素引用同一个 Track 即可不需要重复 add如果确实需要在连接里发送两路画面比如不同帧率/分辨率请用track.clone()后把 clone 挂到新的 sender或者改用 simulcast 机制让编码器从同一轨道生成多路不同质量的 RTP 流。5.4 “Stream 断开”这个报错到底该不该查 WebRTC问题项目里搜到stream disconnected before completion: stream closed before response.completed以为和 WebRTC 有关折腾了半天没结果。原因这类报错通常由流式 HTTP/SSE 客户端、自动化脚本或代理中间层产生与 WebRTC 的MediaStream完全无关。它说的 stream 是“响应数据流”不是音视频轨道容器。建议拿到任何报错先看它出现在哪一层。凡是和RTCPeerConnection、RTP、m-line、MediaStreamTrack无关的直接按通用网络流式接口排查。不要把精力浪费在错位的关键词搜索上。5.5 SDP 里能看到多路 Track但对端 ontrack 只触发了一次问题本地addTrack了音频和视频两条轨道但对端ontrack事件只触发了一次视频音频一直没收到。原因大概率是 SDP 协商里音频 m-line 的编码协商失败或者远端设置了audio标签但没有执行play()导致音频状态异常也可能本地只把视频轨道挂到了 stream 里而音频轨道没有 addTrack 进连接。建议先用pc.getTransceivers()看每一条 m-line 的状态再在接收端监听pc.onnegotiationneeded确保收到新的 offer/answer 后及时 setRemoteDescription最后检查ontrack里是否对audiotrack 做了 srcObject 绑定并且记得调用audioEl.play()。一件事做久了会有一些属于自己的“手癖”。我现在调试任何 WebRTC 项目第一步永远是搞清楚“我手里这货是 MediaStream 还是 MediaStreamTrack”。控制台打印一下Object.prototype.toString.call(obj)或者直接判断有没有getTracks方法三秒钟就能定位问题的层次。这个习惯帮我少踩了无数次坑。另外如果你在搜 WebRTC 问题时碰见一堆stream disconnected before completion之类的报错记得先冷静大概率那是 HTTP/SSE 流式接口在喊冤不是 WebRTC 的 Track 与 Stream 出了问题。容器归容器数据归数据这个认知一旦建立后面所有 WebRTC 的底层细节都不会再把你绕晕。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进