
做WebRTC开发的朋友一定都遇到过这种尴尬明明调用了pc.addTrack(track)对端死活看不到画面或者一切看起来正常但带宽统计里视频码率就是上不去还有更诡异的——本地预览好好的一进通话就黑屏查了半天才发现是enabledfalse和removeTrack没区分清楚。这些问题的根子往往不在应用层而是在addTrack这行代码往下走的整条链路上。我这些年排查音视频问题的经验是只要你能把“一条Track从采集到发送”这条链路完整讲清楚80%的疑难杂症都能自己找到排查方向。这篇就从最基础也最容易被忽略的地方开始——你写的那行addTrack底层到底做了哪些事一条Track是怎么一步步走进PeerConnection最终变成对端屏幕上的画面的。1. 一条媒体轨的一生从采集设备到对端屏幕1.1 先弄清楚几个容易搞混的对象很多人一开始会把MediaStream和MediaStreamTrack混为一谈实际上它们是两个完全不同的抽象。MediaStream更像是一个“容器”或者“播放列表”里面可以放多个轨。你从canvas.captureStream()、getUserMedia()、getDisplayMedia()拿到的返回值都是这个容器。而MediaStreamTrack才是真正承载媒体数据的最小单元——它对应一路摄像头画面、一路麦克风声音、一路屏幕共享画面。这里有个很关键的点WebRTC虽然用MediaStream做API层的组织但真正被发送、被编码、被接收的单元是Track不是Stream。两条流可以共享同一条Track比如你同时往两个RTCPeerConnection里addTrack同一个视频轨浏览器是允许的实际效果就是这一路画面被并行编码发送给两个对端。反过来说如果你把一条Track从流A里removeTrack流B里的对应Track也会跟着失效因为它俩引用的是同一个源。在排查问题的时候这个区分尤为重要。经常有同事拿着代码来问“我明明把stream add进去了为什么对端没画面”我一看代码他add进去的是一个只包含Track A的stream但真正想发的其实是Track B问题就出在拿错Track上。1.2 采集到的Track真的“进”了PC吗严格来说Track本身从来没有被“放”进PeerConnection里。PeerConnection里保存的不是Track对象而是RTCRtpSender和RTCRtpTransceiverTrack只是被“绑定”到Sender上的一个属性。打个比方RTCPeerConnection相当于一条运输线路RTCRtpTransceiver是这条线路上的一条车道RTCRtpSender是车道上的发送装置Track则是装进装置里的货。你调addTrack不是把“货”直接扔进“线路”里而是先在车道上装了一个发送装置再把货放上去。这个模型的直接推论是Track可以换但Sender和车道不会因此重建。你随时可以调用sender.replaceTrack(newTrack)把原来的摄像头换成屏幕共享对端不需要重新协商SSRC、传输信道、编码参数这些底层状态都会尽量保持不变。这种设计是为了让媒体切换尽量平滑避免对端频繁触发重协商和画面中断。所以回答标题的问题一条Track被添加进pc本质是PC上新增了一个Sender并把Track挂到了这个Sender上。链路是Track - RTCRtpSender - RTCRtpTransceiver - m行 - DTLS/SRTP - 网络。2. addTrack背后的三个动作2.1 第一件事创建RTCRtpSender当你调用pc.addTrack(track, stream)时浏览器会同步创建一个RTCRtpSender对象。这个Sender负责后面的一切发送工作包括编码器的生命周期管理、RTP打包、RTCP反馈处理、拥塞控制配合等等。从代码层面看你通常会这么写const pc new RTCPeerConnection({ iceServers: [...] }); const stream await navigator.mediaDevices.getUserMedia({ video: true, audio: false }); const track stream.getVideoTracks()[0]; const sender pc.addTrack(track, stream);注意新版规范里addTrack返回的是RTCRtpSender但如果你用的是addTransceiver的方式返回值则是RTCRtpTransceiver再通过transceiver.sender拿到Sender。const transceiver pc.addTransceiver(track, { direction: sendonly }); const sender transceiver.sender;这两种方式在实际开发里都很常见。addTrack更偏“我有一条现成的轨要发”addTransceiver更偏“我先占一条收发通道后面再决定放什么轨”。后者在需要控制direction或者要setCodecPreferences的场景下更灵活。关于stream参数它在addTrack里其实不是用于发送的而是用于在对端建立MediaStream语义。浏览器会把添加了Track的Stream信息写进SDP的amsid属性这样对端收到后可以把Track重新归组到对应的MediaStream里方便ontrack回调里按流来组织画面。很多人在实现“一屏多流”时直接传null也是可以的但这样对端拿到的stream会是空流需要自己处理Track到流的映射。2.2 第二件事准备一条“透明车道”——RTCRtpTransceiver创建Sender只是第一步紧接着浏览器会为这条Track创建一个RTCRtpTransceiver。Transceiver的职责是维护这条媒体通道的完整状态包括本端方向、协商后的方向、关联的m行、当前使用的编解码器、传输参数等。每个Transceiver最终都会对应SDP里的一个m行。比如你有一个音频Track和一个视频TrackPC里就有两个TransceiverSDP里就有两个m行一个maudio一个mvideo。m行的mid属性会和Transceiver一一对应协商完成后你通过pc.getTransceivers()看到的就是这些通道对象。这里有个容易被忽略的坑一个Transceiver只能承载一条同类型媒体轨。你不能把音频和视频塞进同一个Transceiver也不能让一条视频轨产生两个Transceiver。如果你要同时发摄像头和屏幕共享这两路视频必须创建两个Transceiver本质是两条独立媒体流使用不同的SSRC而不是把两条视频轨塞到一个Transceiver里。Transceiver还有个重要属性direction取值范围是sendrecv、sendonly、recvonly、inactive。addTrack时会自动设为sendrecv如果你只想发不收可以显式设置sendonly。注意direction只是本端“期望”的方向最终生效的是协商后的currentDirection。我曾经排查过一个很隐蔽的问题本端设了sendonly但对端发来了recvonly结果协商完currentDirection变成inactive媒体一条都没发出去。最后发现是两端同时都在用“完美协商”模式却都以为自己才是offerer把方向搞反了。2.3 第三件事触发协商让对端知道你要发Sender和Transceiver创建好只是本端的内部状态准备好了。要让对端真正能收到媒体还需要完成SDP协商。这就是onnegotiationneeded回调诞生的原因。调用addTrack之后浏览器内部会打上一个“需要重新协商”的标记然后在当前任务队列空闲时触发onnegotiationneeded事件。你在这个回调里调用createOffer()-setLocalDescription()- 发送offer - 接收answer -setRemoteDescription()整套流程走完两条媒体轨才真正进入可发送状态。标准的Perfect Negotiation流程大致是这样pc.onnegotiationneeded async () { try { await pc.setLocalDescription(); signaling.send({ description: pc.localDescription }); } catch (e) { console.error(协商失败, e); } };这里有个常见误解很多人以为addTrack成功调用了媒体就开始发送了。不是的。在协商完成之前Track只是“待命状态”SRTP密钥没建立RTP包也不会出网卡。如果对端始终没收到画面先去看双方的协商有没有成功pc.connectionState是不是connected而不是一上来就怀疑编码器。协商完成后Transceiver内部的currentDirection会更新Sender开始真正调度编码器对Track进行采集和编码一条媒体轨的“发送之旅”才算正式开始。3. 开关与换轨Track状态对发送链路的影响3.1 readyState、enabled、muted分别管什么MediaStreamTrack有三个属性经常让人踩坑readyState、enabled、muted。它们的含义差别很大直接影响发送行为。readyState表示Track的生命周期状态只有live和ended两个值。ended意味着采集源已经彻底结束比如摄像头被拔掉、屏幕共享被用户主动停止。此时Track不可恢复继续发送也是空数据。enabled应用层的“软开关”默认true。设为false后Track不再输出新鲜帧。注意它还是会输出帧的只是输出的是黑帧/静音帧。视频会变成全黑音频会是静音数据包。muted表示底层采集是否暂时不可用比如应用切到后台、设备被别的进程抢占。muted为true时同样不会产生有效数据帧。它通常会伴随mute/unmute事件触发。为什么enabledfalse还要继续发黑帧这是有意的设计。在实时通信里保持RTP包持续流动可以让接收端的解码器、抖动缓冲、回声消除AEC等模块维持稳定状态避免频繁进入饥饿模式进而产生卡顿和爆音。但代价就是带宽并没有省下来。3.2 replaceTrack的正确打开方式真正的换轨如果你需要在通话过程中把摄像头画面切换成屏幕共享画面正确操作是使用sender.replaceTrack(newTrack)而不是removeTrack再加addTrack。const screenStream await navigator.mediaDevices.getDisplayMedia({ video: true }); const screenTrack screenStream.getVideoTracks()[0]; await sender.replaceTrack(screenTrack);replaceTrack的好处是协商状态不重建Transceiver、SSRC、传输通道保持原样切换更平滑。而且它允许传入null表示停止发送媒体但保留Sender和Transceiver结构后续还可以再replaceTrack回来。这里要注意几个限制newTrack和原Track的kind必须一致。你不能用一个音频Track去替换视频Track浏览器会直接抛TypeError。换轨后编码器会重新配置。如果新旧分辨率、帧率差异很大可能短暂出现画面卡顿或花屏这属于正常现象。不要在新Track上重复调用getUserMedia后忘记停止旧Track。屏幕共享结束后旧Track的采集源没有释放摄像头指示灯可能一直亮着这在隐私敏感的场景下非常致命。3.3 停流为什么不能依赖muted/enabledfalse这是我想重点强调的一个坑不要用enabledfalse或muted来“停掉”一条流。之前帮一个做在线教育产品的团队排查问题他们反馈“学员关摄像头之后服务端统计发现视频码率还是居高不下”。我看代码他们的实现是track.enabled false于是带宽照常消耗。因为在WebRTC的设计里enabledfalse发送的是黑帧RTP包仍然持续产生编码器照样工作packetsSent和bytesSent还是在涨。真正要停流有几条路pc.removeTrack(sender)把Sender从PC里移除触发重新协商对端会收到ontrack对应Track的ended事件。这是最彻底的停止方式。sender.replaceTrack(null)停止发送媒体数据但保留Transceiver位置不需要重新协商就能立刻生效。适合临时关闭或切换场景。直接track.stop()停止采集源Track进入ended但并不自动从PC里移除RTP层会自动发一些RTCP BYE之类的通知但SDP协商状态不会主动更新。具体选哪种取决于你需不需要保留这条通道。如果只是临时关闭摄像头、后续还可能打开推荐replaceTrack(null)如果是彻底不再发了用removeTrack更干净。4. 走到SDP这一步编码协商决定了这条Track能被谁看懂4.1 m行与sendrecv/sendonly的语义当SDP被创建时每个Transceiver会生成一个m行。简化后的视频m行长这样mvideo 49170 UDP/TLS/RTP/SAVPF 96 98 102 amid:0 asendonly artpmap:96 VP8/90000 afmtp:96 ... artpmap:98 H264/90000 afmtp:98 profile-level-id42E01F; packetization-mode1 artpmap:102 H264/90000 afmtp:102 profile-level-id640C1F; packetization-mode1 cIN IP4 0.0.0.0mvideo后面的49170是端口在offer里通长是0或9表示“还没有选路”UDP/TLS/RTP/SAVPF表示用DTLS做密钥协商、SRTP加密这是现代WebRTC的标准协议栈。后面那串数字是payload type编号同一个媒体类型可以对应多个PT。这一行有几个关键语义asendonly表明本端只发送不接收。对端看到这个方向后就不会往这条m行发送RTP。amid:0对应Transceiver的mid控制消息、BUNDLE分组、SSRC关联都靠它。artpmap和afmtp声明支持的编解码器及其参数可能是多个供对端选择。协商的本质是“双方找交集”。Offer列出自己能编码的、Answer者列出自己能解码的最终通过m行里保留的PT列表确定双方都能用的编解码器。浏览器在内部会选择第一个双方都支持的编解码器作为实际使用的编码器。所以你在webrtc-internals看到的实际编码格式和你在getUserMedia里设置的videoConstraints有关但最终决定权在协商结果里。4.2 编解码器取舍VP8、H264、AV1实测要注意什么WebRTC默认的编解码器在各浏览器里有差异。Chrome和Firefox通常默认首选VP8Safari更偏好H264因为苹果设备硬件解码能力强。做跨端互通时如果协商不到双方都支持的编解码器媒体就会失败。以H264为例SDP里的profile-level-id和packetization-mode是两个最常出问题的参数。profile-level-id42E01F对应Constrained Baseline Profile Level 3.1这是WebRTC里兼容性最好的H264配置。640C1F对应Constrained High Profile画质更好但部分老旧设备不支持硬解。packetization-mode1表示支持Non-Interleaved模式即每个NAL单元可以单独打包Single NAL Unit Packet这对于低延迟传输很重要。如果对端SDP里没有这个参数或默认是0容易出现花屏。做H264推送服务对接时我踩过一个大坑服务端用FFmpeg编码H264把profile设置成了High结果Chrome端一直协商失败。排查到最后发现是Chrome的H264解码只支持到Constrained High Profile的特定level服务端编码出来的SPS信息超出了解码能力边界。后来统一在服务端限制profile:v baseline或profile:v main问题才解决。VP8的优势在于它不像H264那样有专利授权和profile地狱且Chrome对它的软编软解优化做得很好。缺点是同等画质下码率通常比H264高10%~20%。如果你的场景是纯Web端互连VP8是最省心的选择如果涉及硬件终端、小程序、SIP网关等外部系统H264通常是更好的兼容性选择。AV1目前已经在Chrome里支持编码但软编成本较高移动端硬解覆盖还不行生产环境要谨慎。如果你想主动控制首选编码器可以在创建Transceiver之后设置偏好transceiver.setCodecPreferences( RTCRtpReceiver.getCapabilities(video).codecs.filter( codec codec.mimeType video/VP8 || codec.mimeType video/H264 ) );注意setCodecPreferences必须在创建Offer/Answer之前调用一旦协商开始就来不及了。4.3 setParameters调码率的正确姿势协商好的编码参数不是一成不变的你可以通过Sender的getParameters()和setParameters()在运行时调整。const params sender.getParameters(); params.encodings[0].maxBitrate 500_000; params.encodings[0].maxFramerate 24; await sender.setParameters(params);这里有几个经验值maxBitrate的调整通常会在几百毫秒内生效可用于“手动降码率”之类的场景。实际码率是编码器根据内容复杂度、拥塞控制算法动态调整的maxBitrate只是上限不是目标值。如果场景复杂实际码率会接近上限如果画面静止可能远低于上限。scaleResolutionDownBy参数可以控制分辨率降档例如2表示宽高各缩小一半。先降分辨率再压码率比单纯调maxBitrate画质会好很多因为模糊和块效应是有区别的。音频编码器的码率一般不建议动Opus内部有自适应机制乱调容易影响语音质量。5. 编码之后RTP包是怎么给媒体帧“贴快递单”的5.1 RTP头里的关键字段为什么长这样编码器输出的是压缩后的视频帧比如一个H264的I帧可能达到几十KB甚至更大。但这些数据不能直接扔到网络上必须按照RTP协议拆成一个个小包并为每个包填上标准的RTP头相当于给货物贴上快递单。RTP固定头是12字节关键字段包括payload type (PT)7位标识负载格式。比如96代表VP898代表H264。这个值和SDP里协商的PT一一对应。sequence number16位每个RTP包递增。接收端用它做丢包检测和排序。timestamp32位标识媒体采样时间。视频一般用90kHz时钟音频用采样率通常48kHz。SSRC32位用于标识同一媒体源的RTP包。一条视频Track的SSRC是固定的音频和视频的SSRC不同。如果对端收到两个不同SSRC的包会认为是两条媒体流。这些字段不是随便定的。sequence number是传输层的连续性保证和显示时间无关timestamp是播放层的同步基础告诉接收端这个包应该在什么时间点被解码和渲染。两者分开设计是因为网络传输会抖动、会丢包接收端需要根据时间戳重排播放节奏而不是简单按到达顺序播放。5.2 H264/H265 NALU拆分与打包模式对于H264/H265这种基于NAL单元的编码格式一帧图像编码后可能产生多个NAL单元。RTP打包时最关键的是分片策略对于一个超过MTU典型值1200字节的大NAL单元必须切成多个RTP包发送。H264 RTP封装有三种典型模式单一NAL单元模式Single NALU Packet一个RTP包正好装一个完整的NAL单元适合尺寸较小的NAL。聚合包STAP-A多个小NAL单元如SPS、PPS、SEI合并进一个RTP包减少包头开销。分片包FU-A一个大NAL单元按字节切分成多个RTP分片每个分片用FU头里的S起始、E结束标记位来标识顺序。实际抓包时你会发现I帧通常会产生大量FU-A分片而P帧、B帧因为本身小可能一个RTP包就能装下。这也解释了为什么I帧丢失对画面影响巨大——一个I帧分片丢了整个帧解码失败接收端只能等下一个I帧或者主动发PLI请求关键帧。所以排查卡顿时如果看到大量PLI和FIR反馈说明I帧在网络上持续丢失问题多半出在路由、防火墙或者MTU设置上。5.3 同一时刻的音视频如何对得上同步是个系统工程音频和视频是两条独立的RTP流各自有独立的SSRC、独立的sequence number和时间戳。音频时间戳按48kHz采样率走视频时间戳按90kHz时钟走它们的“刻度”完全不同。接收端怎么知道音画是否对齐答案是靠RTCP里的SRSender Report报文。SR里不仅包含RTP时间戳还包含对应的NTP时间戳全球统一绝对时间。接收端拿到音频SR和视频SR后通过NTP这一共同基准就可以把两个RTP时间戳映射到同一时间轴上实现音画同步。这个设计很优雅但在实际使用中有一个常见坑如果发送端的采集链路里音频和视频来自不同的时钟源且没有打上同一个NTP基准就会出现音画不同步。这在Web端很少见因为浏览器内部统一调度但在服务端混合转发/重新打包时如果对音频和视频分别用两套时钟参考出来的流就可能持续漂移。排查音画不同步时不要只看播放器要先去getStats()里看音频和视频的RTCP发送报告核对各自的timestamp和NTP关联字段是否合理。6. 最后的物理出口加密、平滑发送与带宽限制6.1 SRTP加密后抓包还能看到什么很多刚接触WebRTC的人会问我“为什么我用Wireshark抓包看不到H264的起始码”因为RTP包在进入网络之前已经被SRTP加密了。现代WebRTC强制使用DTLS-SRTP媒体负载通过AES-GCM或AES-CMAES-CTR模式加密只有DTLS握手完成后两端的SRTP密钥才可用。所以你在网上看到的裸流量里能看到的是UDP包、DTLS握手包、ICE的STUN包以及加密后的SRTP密文——内容无法直接解析。这给调试带来了麻烦但可以采用几个变通手段用chrome://webrtc-internals看浏览器内部的RTP统计和解码状态不从网络层看内容。在服务端SFU里可以在媒体数据被转发前后做一次“降密”日志记录SSRC、PT、序列号等信息用于分析丢包、乱序、时序问题。如果必须要看RTP明文内容可以在应用层通过RTCRtpSender的回调或者自建采集管道在进入SRTP之前记录原始数据。但这通常需要改造浏览器端实现生产环境不太推荐。6.2 发送节奏为什么不是“采集一帧发一帧”有一个很常见的误解摄像头以30fps采集RTP是不是就以每33ms一个帧的节奏发出去实际上WebRTC内部有一个专门的平滑发送器Pacer它的功能是把编码器产生的突发帧数据按照当前估算的发送码率打散到多个很小的发送间隔里均匀地发送出去。为什么要这么做因为编码器输出是突发的。一个I帧可能瞬间产生几MB数据如果一次性全丢到网络上会瞬间占满链路缓冲造成网络拥塞和丢包而P、B帧之间又可能隔很久不发。平滑发送可以避免这种突发流量让UDP包在时间轴上尽量均匀从而降低延迟和丢包率。这也就解释了为什么你在getStats()里看到的bitrate是平滑的曲线而不是突然的尖峰。6.3 GCC与带宽探测为什么video经常优先被砍真正的发送码率不是随便定的它由WebRTC的拥塞控制算法在每一个周期动态计算。Chrome目前主要使用GCCGoogle Congestion Control体系结合丢包率、单向延迟梯度和REMB/Transport-CC反馈估算出当前路径可用的带宽再决定视频编码器的目标码率。这里有一个浏览器内置的升降级逻辑带宽充足时逐步提高码率探测上限带宽不足时先降视频码率再降分辨率通过qualityLimitationReason可以看到是bandwidth还是cpu限制。音频的优先级通常高于视频因为音频包小、对延迟更敏感一旦音频开始丢包通话基本就不可用了。所以在实际多路互通时会出现一个现象网络恶化时视频码率被压到极低画面开始模糊但语音仍然清晰。这不是故障而是拥塞控制刻意保护音频的结果。7. 实战排查一条Track没发出去的5类原因7.1 从getStats和webrtc-internals快速定位遇到“Track加了但媒体没发出去”的问题我的排查顺序是固定的打开chrome://webrtc-internals看RTCPeerConnection的connectionState是否变成connected。如果不变化问题在ICE/DTLS层面跟Track无关。看outbound-rtp统计里的bytesSent是否持续增长。如果不增长说明Sender没有拿到有效数据重点检查Track是否live、enabled是否正确、是否有帧被编码framesEncoded。看framesEncoded和frameWidth/frameHeight。如果framesEncoded为0说明采集侧或者编码器没工作看看是不是replaceTrack(null)之后忘换回来了。看qualityLimitationReason字段。如果是cpu说明性能不够如果是bandwidth说明网络估算带宽太低如果是none代表没有限制。看nackCount和pliCount。大量NACK说明网络丢包严重大量PLI说明接收端频繁请求关键帧通常是I帧丢失或解码失败。用getStats()从业务侧拿数据也同样可靠const stats await pc.getStats(); stats.forEach(report { if (report.type outbound-rtp report.kind video) { console.log(bytesSent, report.bytesSent); console.log(framesEncoded, report.framesEncoded); console.log(bitrate, report.bitrate); } });7.2 经验速查表现象优先排查点常见原因connectionState一直new/checkingICE、STUN/TURN配置、防火墙未配置TURN导致对称型NAT打洞失败connectionState是connected但没画面outbound-rtp的bytesSent是否增长Track被replaceTrack(null)或removeTrack了framesEncoded为0采集源是否正常enabledfalse或readyState不是livebytesSent增长但fps很低CPU受限或分辨率设置过大qualityLimitationReasoncpu接收端频繁花屏网络丢包或I帧丢失观察nackCount/pliCount音画不同步RTCP SR时间戳基准不一致服务端重新打包时NTP基准错位H264协商失败SDP里profile-level-id不匹配服务端编码用了High Profile但解码端不支持再补一个隐蔽问题的经验千万不要同时依赖track.onended和removeTrack来判断对端状态。onended只在Track被stop()或者采集源结束时触发removeTrack对端收到的是ontrack里的Track进入muted还是ended各浏览器实现细节有差异被坑过一次之后我建议你始终以信令层的明确通知为准不要把媒体层事件当成业务状态机。大概就是因为这套逻辑撑起了WebRTC的灵活性我到现在还经常把这些底层知识点当成排查工具栏。如果你正在做多路推流或者SFU接入下一篇我打算把“一条PC里多条Track”的复用和带宽分配一起讲透到时候我们接着聊。