WebRTC音视频通话Demo实战:信令、ICE穿透与媒体协商全解析

📅 发布时间:2026/9/9 2:03:23
WebRTC音视频通话Demo实战:信令、ICE穿透与媒体协商全解析 简介针对Android平台的WebRTC音视频通话示例工程面向需要快速实现P2P音视频通话功能或希望深入理解WebRTC技术栈的开发者。demo界面参考微信通话样式覆盖视频预览、接听/挂断、静音、摄像头切换等常见通话场景可作为二次开发的基础框架。压缩包共1460个文件约94.92MB以xml布局、java源码、class字节码、dex文件、so原生库、json配置为主其中so库封装了WebRTC底层能力java与xml对应业务逻辑和界面适合在Android Studio中直接导入学习。资源已受到2040人关注说明具备一定的参考价值。工程完整包含PeerConnectionFactory初始化、getUserMedia调用、RTCPeerConnection建立、SDP与ICE交换、信令处理等关键环节并提供了可运行的APK和Gradle配置便于对照源码理解音视频通话的完整流程也可根据业务需要改造适配。1. 从零搭一个WebRTC音视频通话Demo到底要搞定哪些事?先说结论WebRTC这套东西第一次跑通demo的时候你会觉得“怎么就这么点代码”但实际上能跑通的背后每一步都是坑。我见过很多人卡在“摄像头开了但对方看不到画面”“本机能通但换了两台电脑就不行”这种问题上绝大多数原因不是API用错了而是对WebRTC的工作机制没有一个完整的认识。这篇文章我会从我自己搭一个WebRTC音视频通话Demo的完整过程出发把核心概念、信令设计、媒体协商、ICE穿透、推流拉流这些环节全部拆开讲清楚。文章里给出的代码都是可以直接跑的级别省略掉的部分我会明确告诉你需要补什么。适合刚接触WebRTC的客户端开发、前端工程师以及想快速验证一套音视频方案的团队参考。先理清一个基本认知WebRTC不是“一个库”它是一整套浏览器内置的实时通信能力。它解决的最核心问题是在两个浏览器或App之间建立一条低延迟的音视频传输通道。注意这里的重点是“建立通道”至于你要传的是视频聊天、屏幕共享还是白板数据那是上层应用自己决定的。所以你要做的Demo本质上就三件事采集媒体流、交换连接信息、传输音视频数据。而这三件事里只有第一件是浏览器帮你做好的后面两件都需要你在应用层自己实现一部分逻辑。2. Demo整体架构设计与方案选型2.1 为什么需要一个信令服务器它到底干什么用很多人第一次写WebRTC demo时最大的疑惑是为什么不能直接A浏览器调用B浏览器原因很简单——WebRTC需要知道“对方在哪里”但在连接建立之前两个浏览器之间并没有任何已知的通路。有人会说“那我知道了对方IP直接连不就行了”问题在于互联网上绝大多数设备都在NAT后面你拿到的是内网地址光知道这个地址没有任何意义。所以WebRTC引入了信令Signaling的概念。信令服务器的职责就是在两个端之间传递三类信息会话描述协议SDP包含媒体格式、编解码器、网络信息等参数的声明ICE候选ICE Candidate每个端能使用的网络路径候选包括内网IP、公网IP映射、中继地址等会话控制消息比如“我加入房间了”“我挂断了”这类状态消息信令的具体内容、格式、传递方式WebRTC规范完全不管。你可以用WebSocket、可以用Socket.IO、可以用MQTT甚至用HTTP轮询都行。WebRTC只定义了SDP和ICE的格式传递过程是应用层的事。我之前见过有人把信令服务器和媒体服务器混为一谈这是两个完全不同的东西。信令服务器只传控制消息不碰音视频数据媒体服务器才是负责转发或混合音视频流的。一个纯P2P的WebRTC demo完全可以只有信令服务器不需要任何媒体服务器。2.2 我为什么选这个技术组合WebRTC的API在现代浏览器里基本都原生支持所以客户端的Demo我用的是纯JavaScript加HTML不引入任何框架。如果一开始就用React或Vue很容易把问题复杂化——你分不清一个报错到底是WebRTC的问题还是框架生命周期的问题。信令服务器我选了Node.js加Socket.IO。原因有三点Socket.IO天然支持房间Room概念方便做多人联调的扩展事件驱动的模型和WebRTC的信令模型非常匹配即使你不熟悉Node.js照着本文的代码也能在十分钟内跑起来如果你是想在移动端或者桌面端做Demo只要理解了本文的原理换成Android的Java/Kotlin或者iOS的Swift只是API形态不同信令交互流程完全一致。2.3 一次完整通话的建立流程先看懂整体时序在动手写代码之前你必须先在心里有一张完整的时序图否则你会不知道你写的每行代码在干什么。一次标准的WebRTC通话建立流程是这样的A和B都连接到信令服务器加入同一个房间A调用getUserMedia采集本地音视频拿到MediaStreamA创建RTCPeerConnection把本地MediaStream的轨道Track添加进去A创建Offer一个SDP描述调用setLocalDescription设置到本地PeerConnectionA通过信令服务器把Offer发给BB收到Offer做三件事设置远端描述setRemoteDescription、创建Answer、setLocalDescriptionB把Answer通过信令服务器发回给A双方在setLocalDescription之后浏览器会自动开始ICE收集通过onicecandidate回调把候选信息发给对方双方不断交换ICE候选直到找到一条可用的传输路径连接建立媒体流开始传输。onaddstream或ontrack回调触发把对方的流绑定到video标签上这十步里第4和第6步是最容易写错的很多人会把setLocalDescription和setRemoteDescription的方法调用顺序搞混。记住一个口诀先set再send先receive再set。你本地的描述要先设置到自己的PeerConnection上然后才发送给对方你收到对方的描述要先设置到自己的PeerConnection上然后才回复。3. 核心代码逐段拆解从采集到播放3.1 媒体采集getUserMedia的参数选择有讲究音视频通话的第一步是采集这个接口看起来简单但参数选择直接影响后面的效果和兼容性。我的Demo里用的是这样一组参数const constraints { audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 } } }; const localStream await navigator.mediaDevices.getUserMedia(constraints);注意几个细节。音频的三个参数——回声消除、降噪、自动增益——我建议在采集时就显式打开不要依赖浏览器的默认值。不同浏览器对默认值的处理差异很大比如Chrome默认开启回声消除但Safari在某些版本上如果没显式指定回声问题会非常明显。视频这边用ideal而不是exact。exact表示强制要求如果摄像头不支持这个分辨率会直接报OverconstrainedErrorideal表示首选浏览器会根据实际硬件能力自动选择一个最接近的分辨率。对于Demo来说一定用ideal兼容性会好很多。还有一点要注意本地预览的video标签一定要设muted属性否则你会听到自己说话的回声。这不是WebRTC的问题是音频反馈导致的但几乎每个第一次写WebRTC的人都会踩到这个。3.2 创建PeerConnectionSTUN服务器的必要性RTCPeerConnection的配置是全网问得最多的问题之一核心都集中在ICE服务器配置上const pcConfig { iceServers: [ { urls: stun:stun.l.google.com:19302 }, { urls: turn:your-turn-server.com:3478, username: your-username, credential: your-credential } ] }; const pc new RTCPeerConnection(pcConfig);先说STUN。它的作用是帮你的设备发现自己经过NAT映射之后的公网地址。打个比方你在一间大房子的某个房间里STUN服务器就像是门口的对讲机它能告诉你“从外面看你家的门牌号是多少”但它不会帮你转接任何电话。Demo阶段直接用谷歌的公共STUN服务器就好stun:stun.l.google.com:19302这个地址稳定运行了很多年。不过要注意公共STUN服务器只适合开发和测试生产环境一定要自建或者购买商业服务否则一是不稳定二是你的用户量大了会给别人造成负担。再说TURN。它的作用是当P2P直连失败时用一台服务器做媒体数据的中继转发。TURN服务器配置里需要username和credential通常由TURN服务商提供或你自建时自己设置。在没有TURN服务器的情况下如果两端的网络环境恰好无法建立直连你的Demo就会卡死在“连接中”状态。很多人在公司网络环境或两个不同运营商网络之间测试时发现连不通基本都是因为缺了TURN。3.3 媒体协商与ICE处理Demo里最核心的一段代码媒体协商是整个WebRTC建立连接的灵魂。我给出一个完整的呼叫方Offer端核心代码// 呼叫方创建Offer并发送 async function makeCall(targetUserId) { // 添加本地音视频轨道到PeerConnection localStream.getTracks().forEach(track { pc.addTrack(track, localStream); }); // 监听ICE候选并发送给对方 pc.onicecandidate (event) { if (event.candidate) { signalingSocket.emit(candidate, { target: targetUserId, candidate: event.candidate }); } }; // 监听对方媒体流绑定到video标签 pc.ontrack (event) { remoteVideo.srcObject event.streams[0]; }; // 创建Offer并设置到本地 const offer await pc.createOffer(); await pc.setLocalDescription(offer); // 通过信令服务器发送Offer signalingSocket.emit(offer, { target: targetUserId, sdp: pc.localDescription }); }接听方的核心代码有一点需要特别注意// 接听方收到Offer后创建Answer signalingSocket.on(offer, async (data) { // 先把收到的SDP设置到远端描述 await pc.setRemoteDescription(data.sdp); // 添加本地音视频轨道注意接听方也要添加 localStream.getTracks().forEach(track { pc.addTrack(track, localStream); }); // 创建Answer并设置到本地 const answer await pc.createAnswer(); await pc.setLocalDescription(answer); // 发送Answer给呼叫方 signalingSocket.emit(answer, { target: data.from, sdp: pc.localDescription }); });我再强调一下这里的顺序问题。很多人在接听方这边会把addTrack放在setRemoteDescription之前这在现代浏览器上通常也能工作但不符合规范推荐的顺序。规范要求的是先设置远端描述再添加轨道再创建Answer。因为createAnswer需要知道对端支持哪些编解码器这个信息是从远端描述里来的。轨道添加的顺序影响的是媒体协商时本地是否有可用的媒体流。ICE候选的处理相对简单但有一个很容易忽视的细节——ICE候选可能在Offer/Answer交换完成之前就已经到达对方。所以你在处理candidate事件时要先判断remoteDescription是否已经设置signalingSocket.on(candidate, async (data) { if (pc.remoteDescription pc.remoteDescription.type) { await pc.addIceCandidate(data.candidate); } else { // 缓存起来等setRemoteDescription之后再添加 pendingCandidates.push(data.candidate); } });如果你不做这个判断很可能会遇到Failed to set remote answer sdp或Error adding ICE candidate这类报错。这类问题排查起来比较费劲因为报错信息并不会直接告诉你“你收到ICE候选时远端描述还没设置”。我在下面排查章节还会再讲这个。3.4 视频渲染与连接状态监听视频画面的渲染是我觉得很多人会忽略的一个环节。你只需要两行代码remoteVideo.srcObject event.streams[0]; remoteVideo.play();但要注意的是ontrack回调可能在连接建立过程中触发多次因为音频轨道和视频轨道是分别到达的。在某些浏览器上你会发现这个回调先触发一次拿到音频流再触发一次拿到视频流如果你在回调里直接赋值srcObject会把后到的视频流覆盖掉前面已设置的流。稳妥的做法是维护一个MediaStream实例把到达的轨道用addTrack加进去const remoteStream new MediaStream(); pc.ontrack (event) { event.streams[0].getTracks().forEach(track { remoteStream.addTrack(track); }); remoteVideo.srcObject remoteStream; };另外强烈建议在Demo里加上连接状态的监听这个对调试帮助极大pc.onconnectionstatechange () { console.log(连接状态, pc.connectionState); if (pc.connectionState failed) { // 触发重连逻辑或提示用户 } }; pc.oniceconnectionstatechange () { console.log(ICE状态, pc.iceConnectionState); };生产级的代码里这些状态的变化应该和UI做完整联动但在Demo里你至少要把它们输出到控制台。因为WebRTC的很多问题都是“看起来没反应实际上某个中间状态出了问题”有了这些日志你就能定位问题发生在哪个环节。4. 信令服务器用一个Node.js服务撑起整个Demo4.1 Socket.IO信令服务的核心实现信令服务器的代码其实很短因为它的职责就是“转发”和“通知”。下面是我的Demo里使用的最简版本const express require(express); const http require(http); const { Server } require(socket.io); const app express(); const server http.createServer(app); const io new Server(server); const rooms new Map(); io.on(connection, (socket) { console.log(用户已连接, socket.id); // 加入房间 socket.on(join, ({ roomId }, callback) { const clients rooms.get(roomId) || []; if (clients.length 2) { callback({ code: 1, msg: 房间已满 }); return; } socket.join(roomId); if (!rooms.has(roomId)) { rooms.set(roomId, []); } rooms.get(roomId).push(socket.id); socket.roomId roomId; // 通知房间内其他用户 socket.to(roomId).emit(peer-joined, { peerId: socket.id }); callback({ code: 0, peerId: socket.id }); }); // 转发Offer/Answer/ICE候选 socket.on(offer, ({ target, sdp }) { socket.to(target).emit(offer, { from: socket.id, sdp }); }); socket.on(answer, ({ target, sdp }) { socket.to(target).emit(answer, { from: socket.id, sdp }); }); socket.on(candidate, ({ target, candidate }) { socket.to(target).emit(candidate, { from: socket.id, candidate }); }); // 挂断与断开 socket.on(hangup, ({ target }) { socket.to(target).emit(peer-hangup, { from: socket.id }); }); socket.on(disconnect, () { if (socket.roomId) { socket.to(socket.roomId).emit(peer-left, { peerId: socket.id }); const clients rooms.get(socket.roomId); if (clients) { const idx clients.indexOf(socket.id); if (idx -1) clients.splice(idx, 1); } } }); }); server.listen(3000, () { console.log(信令服务器运行在http://localhost:3000); });这段代码里我用Map来存储每个房间的用户列表逻辑上相当于一个极简的“聊天室”。注意我限制每个房间最多两个用户因为这是1对1通话Demo。如果你想扩展成多人会议就需要换成网格架构Mesh让每个端都和另外几个端分别建立PeerConnection复杂度会指数级上升。4.2 为什么Socket.IO这类库适合做信令而不是传媒体这里我想强调一个核心认知。很多人刚开始接触WebRTC时会有一个疑问既然Socket.IO能传数据为什么不用它直接把音视频数据传过去答案是性能和延迟。Socket.IO的数据传输走的是WebSocket或HTTP长轮询每一帧都包含大量协议头开销而且经过服务器中转延迟比P2P的UDP传输高一个数量级。音视频通话对延迟极其敏感300毫秒以上的延迟人就能明显感觉到不自然所以媒体数据必须走专用的RTCPeerConnection通道信令服务器只能做“牵线搭桥”的工作。我见过一些半懂不懂的方案用WebSocket直接传base64编码的音视频帧那延迟和带宽消耗是灾难级别的。WebRTC的设计哲学就是信令与媒体分离各走各的道。4.3 公网部署时的注意点Test阶段在本地跑信令服务器监听localhost就够了。但如果你想让两台不同网络环境下的电脑通话多数情况下这才是你测试的目的你需要把信令服务器部署到一台公网服务器上并把前端的WebSocket连接地址改成服务器的地址。这个环节有三个常见的坑部署环境有没有放行端口很多云服务器默认只开放80/443如果你的Node服务跑在3000端口需要在防火墙和安全组里放行。HTTPS/WSS问题getUserMedia接口在非安全上下文也就是HTTP下是不可用的Chrome会直接报错。所以如果信令服务器是用HTTP跑的前端页面也得通过HTTP访问而用户摄像头可能无法调用。稳妥方案是给域名配一个HTTPS证书或者用内网穿透工具把本地服务暴露成HTTPS地址。ws和wss的跨域问题Socket.IO默认允许跨域但如果你自己配置了CORS策略需要注意跨域时的握手方式要匹配。5. 推流与拉流理解Demo背后的数据通道机制5.1 基于WebRTC的“推流”到底是什么意思在WebRTC的语境下推流和拉流这两个词和传统RTMP/HTTP-FLV直播里的含义是有区别的。传统直播中推流是主播端把媒体数据上传到服务器拉流是观众端从服务器获取数据。但在WebRTC的P2P架构里没有中心化的服务器来存储和分发媒体流所以推流拉流更多是指谁主动发起媒体数据的传输。在本文的1对1Demo里通话双方既在推流也在拉流——你把本地采集的媒体流通过PeerConnection推送出去同时也从PeerConnection接收对端的媒体流。用WebRTC的行话来说这是双向的“推拉流一体”。如果你想做的是类似直播场景的“单向推流”比如一个主播端把画面推给多个观众端实际上WebRTC的网格架构也能做只是N个观众就需要N个PeerConnection对主播端的上行带宽要求很高。这也是为什么正规的WebRTC直播方案通常会引入SFU选择性转发单元服务器由SFU接收主播的一路流然后按需转发给观众。SFU属于媒体服务器范畴纯P2P的Demo用不到但如果你将来要扩展这个概念必须知道。5.2 数据通道除了音视频你还能传什么WebRTC除了音视频轨道还提供了一个DataChannel可以在这个对等连接上直接传输任意数据——文本消息、文件分片、游戏状态同步等等。DataChannel在Demo里虽然不必须但我强烈建议你顺手加一个因为它是验证“连接已经真正建立”的最直观方式。// 创建DataChannel呼叫方 const dataChannel pc.createDataChannel(chat, { reliable: true }); dataChannel.onopen () { console.log(数据通道已打开); }; dataChannel.onmessage (event) { console.log(收到消息, event.data); }; // 发送消息的示例 function sendMessage(msg) { if (dataChannel dataChannel.readyState open) { dataChannel.send(msg); } }注意DataChannel必须在发起方调用createOffer之前创建。如果发起方在createOffer之后才创建这个通道可能不会被加入到已协商的SDP中导致对端无法识别这个通道。接听方需要监听ondatachannel事件来获取这个通道pc.ondatachannel (event) { const channel event.channel; channel.onopen () { /* ... */ }; channel.onmessage (event) { /* ... */ }; };6. 弱网卡顿Demo跑通之后必须面对的问题6.1 你的Demo能跑通不代表真正能用于生产这是我最想强调的一点。一个Demo在局域网内或者网络状况良好的情况下跑通是WebRTC开发中最不值得高兴的事。因为WebRTC真正难的地方——或者说真正体现技术含量的地方——是弱网环境下的表现。这不仅体现在实际通话质量上也是面试时被问到的高频题。弱网导致的卡顿主要有两种表现网络抖动Jitter网络延迟出现剧烈波动导致到达的数据包间隔不均匀。视频表现为画面一顿一顿但不是持续性的。丢包Packet Loss数据包在传输过程中丢失导致画面出现马赛克、花屏或者声音断续。WebRTC有丢包重传机制但如果丢包率过高重传也救不回来。6.2 WebRTC内置了哪些弱网对抗机制WebRTC在底层已经做了很多优化理解这些有助于你判断问题出在哪一层拥塞控制WebRTC使用GCCGoogle Congestion Control算法基于延迟和丢包率来动态调整发送码率。当网络变差时它会主动降低视频分辨率或帧率。丢包重传NACK接收端发现丢了某个包会发送NACK请求重传。但对于实时性要求高的音视频来说重传有个时间窗如果视频帧来不及在播放前到达就只能丢弃。前向纠错FEC发送端在原始数据之外追加冗余包接收端即使丢了一部分包也能通过冗余恢复出原始数据。代价是额外消耗带宽。Jitter Buffer接收端有一个缓冲区会暂存收到的数据包等到足够的数据后再均匀地播放。缓冲区越大抗抖动能力越强但延迟也会越大。这是WebRTC内部自动管理的上层无法直接配置。这些机制在Demo里你不需要也不容易直接操作但你可以通过浏览器的getStats()接口拿到详细指标来判断你的网络状况下WebRTC做了什么调整setInterval(async () { const stats await pc.getStats(); stats.forEach(report { if (report.type inbound-rtp report.kind video) { console.log(收到码率, report.bytesReceived / 1024, KB/s); console.log(丢包数, report.packetsLost); console.log(抖动, report.jitter, s); } }); }, 3000);在我的实践经验里如果丢包率超过2%用户就能感觉到画质变差超过5%卡顿就会很明显。如果你在自己的Demo测试中发现卡顿第一步就是先确认丢包率和码率变化趋势再去考虑代码层面的优化。盲目改代码解决不了物理网络的限制。7. 常见问题排查我把踩过的坑都整理在这里我整理了一份WebRTC Demo阶段最常遇到的问题速查表都是我自己实际调试中遇到过的具有很高的参考价值问题现象根本原因解决办法摄像头打不开控制台报NotFoundError页面在HTTP环境下运行getUserMedia被浏览器禁用改用HTTPS访问页面或localhost本地访问本地画面正常远端黑屏媒体协商成功但媒体流没有被正确绑定检查ontrack回调里是否在多次触发时覆盖了srcObject改用addTrack方式连接状态一直new/checkingSTUN/TURN配置缺失或网络环境禁止UDP添加STUN服务器跨网络测试时添加TURN服务器报错Failed to set remote answer sdp对端描述设置顺序错误确保先setRemoteDescription再createAnswerFirefox/Chrome互通失败不同浏览器的编解码器偏好不同检查SDP中是否有共同的编解码器必要时用offerToReceiveAudio/Video参数通话有回声本地预览video标签未设置muted给本地预览video加muted属性报错Error adding ICE candidate收到candidate时remoteDescription还未设置在setRemoteDescription之后添加ICE候选或缓存待处理能听到对方声音但画面卡死视频帧率过高网络带宽不足调整getUserMedia的帧率约束或依赖WebRTC自身的码率自适应长时间后连接自动断开网络切换导致IP变化或NAT映射过期监听iceconnectionstatechange实现重连机制最后一个额外提醒如果你在浏览器控制台看到类似Failed to execute addIceCandidate on RTCPeerConnection的报错不要直接去查这个API的使用方法先检查你自己代码里remoteDescription的设置时机。这是排查过最多人问的问题百分之八十都是时序搞错了。8. 跑通Demo之后下一步怎么走最后说点我自己的亲身体会。很多人跑通一个WebRTC Demo之后会有一种“就这么简单”的错觉然后直接开始写业务代码结果遇到各种实际环境的问题又回来重新补基础。我建议的路子是Demo跑通之后先做两件小事来加深理解。第一用浏览器的开发者工具或者独立的WebRTC调试工具把SDP打开看一下里面每一行代表的是什么意思最好搞清楚afmtp、aice-ufrag、afingerprint这些关键字段的作用。第二把网络环境切换到不同的网络比如手机热点、公司网络、跨运营商网络反复测试观察ICE候选的变化和连接建立的成功率。这两件事做完你对WebRTC的理解会比看十篇教程都有用。再往下走如果你想把这个Demo做成真正可用的产品需要考虑的事情就多了TURN服务器的自建与维护、弱网下的码率策略调整、通话质量的监控上报、多人会议的整体架构选型Mesh、SFU还是MCU、移动端的适配与后台保活等等。但这些都是后话先把Demo彻底跑通吃透路才能走稳。本文还有配套的精品资源点击获取