
简介面向Android开发者的WebRTC音视频通话示例工程聚焦如何在移动端快速搭建类似微信通话体验的实时音视频方案。资源为RAR压缩包共1460个文件约94.92MB包含XML布局与Android资源、Java/Kotlin源码、Class及DEX编译产物、SO原生库和JAR依赖以及Gradle配置与JSON文件便于在Android Studio中直接导入、编译和调试。已有2040人学习下载适合初学WebRTC或希望在现有项目中集成点对点通话能力的开发者。工程完整覆盖摄像头与麦克风采集、RTCPeerConnection连接建立、SDP与ICE信令交互、本地/远程流展示及通话控制UI等关键环节界面仿照微信通话样式提供接听/挂断、静音、摄像头切换等交互。包内还包含APK安装包可快速运行体验也可作为二次开发的基础框架帮助理解WebRTC在Android平台的实际落地流程。 从我手头这个真实需求说起要在网页里做一对一的音视频通话不依赖商业SDK就基于WebRTC自己拼一个demo出来。网上搜了一圈倒是能看到各种“五分钟跑通WebRTC通话”的文章真按着敲完要么是局域网里能通、一到公网就黑屏要么是摄像头推流没问题、对方就是听不见声音。折腾了几轮之后我最深的感触是WebRTC通话demo这事儿表面上是“调几个API”的活实际上是在跟信令、SDP、ICE、弱网拥塞这一整套链路较劲。这篇文章就把我自己从零搭WebRTC音视频通话demo的过程、踩的坑和优化思路整理出来给正打算做类似demo、或者demo跑通后想往生产环境靠的同学一个参考。1. 先把“demo”这个词拆开WebRTC通话demo到底要解决哪些真问题很多教程会把WebRTC demo简化成一个页面加一段getUserMedia代码仿佛拿到摄像头画面就是通话成功。但真实的双向音视频通话远比“采集画面”复杂demo的边界必须要划清楚。1.1 为什么自建WebRTC demo而不是直接调第三方SDK选择自建而不是直接集成声网、即构这类SDK最直接的原因是我想把底层链路彻底搞清楚。第三方的优势是封装好、弱网优化成熟但它对你是个黑盒出了问题只能看日志猜自建WebRTC demo虽然初期慢但信令怎么设计、SDP怎么协商、ICE候选怎么收集、带宽不足时会触发什么反馈每一个环节都暴露在你面前排查问题的时候心里有底。另外很多实际项目是需要二次开发的比如自定义美颜、自定义音频处理、私有协议对接、或者是要打包成鸿蒙的hap、hsp模块。这些场景下你对WebRTC底层的掌控程度直接决定后续能不能落地。自己跑一个demo不是为了证明“我能调API”而是要验证“这套链路在目标环境下是否真的通”。1.2 一个能跑的demo由哪几块组成一个最小可用的WebRTC音视频通话demo至少要包含三块采集与渲染端也就是浏览器或者客户端里调用getUserMedia采集摄像头和麦克风用video标签播放本地预览和远端流。信令通道负责传递对方的SDP offer/answer和ICE candidate。注意WebRTC规范里并没有定义信令协议你可以用WebSocket、Socket.IO甚至轮询都行。NAT穿透与媒体转发通过ICE框架收集候选地址借助STUN服务器发现公网映射必要时走TURN服务器中转媒体流。很多人第一次跑demo时只做了第一块然后拿着一个页面说“我已经拿到本地摄像头画面了”这不叫通话这只能叫“本地预览”。真正的通话必须有第二块和第三块参与否则两端无法建立媒体连接。“demo”看着简单实际上它是这整套机制的最小闭环。2. 信令、SDP与ICE跑通之前必须理解的三个底层机制如果你只是照着别人的代码抄一遍可能demo也能通但一旦换了网络环境就抓瞎。我建议在动手写代码前先花半小时把这三个机制的工作流程理清后面能省一天排查时间。2.1 信令服务器WebRTC没规定但demo不能没有WebRTC协议栈本身不包含信令服务意思是它不关心你用什么方式把SDP和ICE候选送到对端。但“不关心”不等于“不需要”恰恰相反信令是通话建立的起点。我用的方案是Node.js加WebSocket这样一个简单的信令服务大概长这样const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); const rooms new Map(); wss.on(connection, (ws) { let currentRoom null; ws.on(message, (data) { const msg JSON.parse(data); if (msg.type join) { currentRoom msg.room; if (!rooms.has(currentRoom)) rooms.set(currentRoom, []); const peers rooms.get(currentRoom); if (peers.length 2) { peers.push(ws); ws.send(JSON.stringify({ type: joined, peerId: peers.length - 1 })); if (peers.length 2) { peers.forEach(p p.send(JSON.stringify({ type: ready }))); } } else { ws.send(JSON.stringify({ type: full })); } } else if (msg.type signal) { const peers rooms.get(currentRoom); if (peers) { peers.forEach(p { if (p ! ws) p.send(JSON.stringify(msg.data)); }); } } }); ws.on(close, () { if (currentRoom rooms.has(currentRoom)) { const peers rooms.get(currentRoom).filter(p p ! ws); if (peers.length 0) rooms.set(currentRoom, peers); else rooms.delete(currentRoom); } }); });这个服务只做两件事把加入同一房间的双方凑在一起房间内最多两人以及转发双方发来的信令消息。demo阶段不需要考虑鉴权、重连、多房间管理但结构上要为这些留好位置。2.2 SDP交换到底交换了什么拿到对端的信令地址后接下来双方要互相交换SDPSession Description Protocol会话描述协议。SDP不是媒体数据本身它是一个描述“我这边想怎么通话”的文本协议里面包含媒体类型、编解码器、传输地址、带宽要求等信息。调用createOffer生成的是提议SDP格式类似这样简化描述v0 o- 4611709806824416116 2 IN IP4 127.0.0.1 s- t0 0 mvideo 9 UDP/TLS/RTP/SAVPF 96 97 98 ... artpmap:96 VP8/90000 artcp-fb:96 goog-remb artcp-fb:96 transport-cc关键要理解两点编解码器协商VP8、H264、opus这些编解码器不是每次通话都全部启用而是通过SDP的优先级顺序和rtpmap参数协商出一个双方共同的组合。如果两端浏览器或客户端的编解码能力差异很大协商失败就会表现为黑屏或者无声。mvideo和maudio行分别描述视频和音频的候选端口、传输协议。很多人忽略音频部分只把注意力放在视频上结果画面通了却没声音就是因为SDP里audio相关的m行没处理好。你可以用pc.setLocalDescription(offer)把offer设为本端描述然后通过信令发给远端远端用pc.setRemoteDescription(offer)接收再调用createAnswer返回应答SDP。整个SDP交换就是一次“议价”的过程。2.3 ICE候选收集与NAT穿透局域网秒通、公网不通的根源ICEInteractive Connectivity Establishment交互式连接建立是WebRTC里最容易出问题但又最容易被demo忽略的部分。它的工作方式是本端收集所有可能的网络路径候选本地IP、NAT映射后的公网IP、TURN服务器的中继地址通过信令发给对端两端各自尝试连通性测试选出一条可用的路径。局域网里跑demo的时候两端的IP通常都在同一个网段候选地址很少很快就能找到直连路径。但部署到公网上情况就变成了这样本端在NAT后面本地候选地址是192.168.x.x对端根本路由不过去。需要通过STUN服务器发现公网映射地址这就是典型的“打洞”过程。如果NAT类型是对称型的UDP打洞会失败必须退化到TURN中继。我在demo里配的STUN是Google的公共STUN服务但在国内网络环境下时延不稳定后来改成了自建的coturn。配置方式很简单创建RTCPeerConnection时传入iceServers数组const pc new RTCPeerConnection({ iceServers: [ { urls: stun:your-stun-server:3478 }, { urls: turn:your-turn-server:3478, username: demo, credential: demo } ] });注意TURN服务器的配置在demo阶段容易漏掉或者只配了STUN。局域网测试时不会发现问题一旦两端都在复杂的NAT后面媒体流就完全建立不起来表现就是双方都能看到自己在转圈但对方的画面和声音永远过不来。3. 从零搭建一个可复现的WebRTC音视频通话demo搞清楚了信令、SDP和ICE接下来就是动手实操的部分。我给自己的目标是两端都能在浏览器跑起来一端推流一端拉流音画同步正常并且可以放在不同网络下测试。3.1 技术选型与目录结构我当时的技术栈是信令服务器Node.js WebSocket客户端原生JavaScript不做框架依赖媒体服务器不额外引入SFUdemo阶段只做P2P直连目录结构是这样的webrtc-demo/ ├── package.json ├── server/ │ └── signaling-server.js └── public/ ├── index.html ├── client.js └── style.css选这个组合的原因很简单Node.js做WebSocket服务非常轻量原生JS客户端则能最大程度保留WebRTC API的原始面貌方便对照源码和标准文档学习。如果你后续要集成React或者Vue也完全可以把client.js里的逻辑抽成自定义Hooks或service。3.2 推流端与拉流端的核心代码骨架在一个双向通话demo里其实每一端既是推流端也是拉流端。不过为了说清楚“推流和拉流”的概念我把它拆成两个动作推流是指把本地采集的媒体流通过RTCPeerConnection发送出去拉流是指接收远端的媒体流并渲染到本地。先看推流端的关键代码async function startLocalStream() { const stream await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: true }); document.getElementById(localVideo).srcObject stream; stream.getTracks().forEach(track pc.addTrack(track, stream)); }这段代码做了三件事采集本地音视频把采集到的流展示在本地video标签上然后把track添加到RTCPeerConnection里。需要注意addTrack之后WebRTC并不会立即发送数据一定要等SDP协商完成、ICE连接状态变成connected之后才真正开始推流。再看拉流端的处理逻辑pc.ontrack function(event) { const remoteStream event.streams[0]; document.getElementById(remoteVideo).srcObject remoteStream; };ontrack回调在远端媒体track到达时触发。这里有一个细节如果你在SDP协商前就把远端候选地址都发过来了ontrack可能不会触发因为在setRemoteDescription之前传入的track无法被正确映射。常见的做法是等setRemoteDescription结束之后再处理ICE候选或者至少保证信令消息的时序是:先SDP后candidate。完整的连接建立过程可以归纳为创建RTCPeerConnection并配置ICE服务器。getUserMedia采集流addTrack加入媒体轨道。主叫端调用createOffer生成offersetLocalDescription后发送给信令服务器。被叫端收到offersetRemoteDescription然后createAnswer返回answer。双方交换ICE candidateRTCPeerConnection自动进行连通性检查。连接建立媒体流开始流动。3.3 用WebSocket写一个极简信令服务前面给出的信令服务代码已经可以满足基本需求但如果你希望更贴近生产环境有几个地方可以提前补上消息类型区分join、leave、signal、ready等状态要定义清楚。房间生命周期管理当第二个用户加入时要通知双方当有人离开时要通知对端否则另一端的RTCPeerConnection会一直保持connecting状态。心跳与断线重连demo可以不写但如果你要跨网络测试Wi-Fi切换或者网络闪断会直接摧毁信令通道页面上的表现是视频卡住然后一直不恢复。我实际测试时发现一个很典型的问题信令服务放在本地页面在其他网络环境打开的时候如果WebSocket地址写的是localhost那信令消息根本送不到对端。正确做法是把信令服务部署到一台有公网IP或内网穿透的服务器上前端代码里通过环境变量来配置WebSocket地址。这个“低级错误”反而是新手最容易忽略的。4. “推流和拉流”只是开始弱网卡顿优化才是demo升级的关键很多demo能跑通但切到弱网环境就原形毕露。热搜里也有人在问“webrtc 弱网卡顿怎么优化”这正是从demo走向实用必须跨过的一关。4.1 为什么wifi下流畅切到4G就花屏卡顿Wi-Fi下通常带宽充足、丢包率低WebRTC默认的码率策略可以放开跑。但到了4G或者跨地域公网环境带宽受限、网络抖动、丢包率上升如果两端还是按照高码率推流就会出现拥塞。问题出在WebRTC自身的带宽估计机制还没有适应网络变化。WebRTC采用的是基于延迟和丢包的拥塞控制策略它通过实时观察RTT往返时延和丢包率来调整发送码率。网络突然变差时编码器会先尝试降低分辨率、帧率或者帧质量但如果调整速度跟不上网络劣化的速度用户看到的就是马赛克、卡顿甚至黑屏。4.2 WebRTC的拥塞控制机制GCC、带宽估计在demo里的表现WebRTC目前默认使用基于Google Congestion ControlGCC的带宽估计体系主要包括两部分基于丢包的控制器收到RTCP receiver report之后根据丢包率调整码率。丢包率升高乘性降低码率丢包率低尝试加性增加。基于延迟的控制器通过观察数据包到达时间的增量变化判断网络是否开始排队。如果排队延时有上涨趋势就会提前降低发送码率避免缓冲膨胀。在demo的表现上你会发现网络变差的时候视频会先从1080P降到720P再降到480P这一般是WebRTC内部在协商后自动调整的结果。如果你完全不关心这些只会觉得“画面画质下降了”但其实底层已经做了两三次降级决策。4.3 我实测过的几种优化手段在demo阶段不需要马上引入大规模媒体服务器但有一些低成本的优化手段值得先试调整码率上限在创建RTCPeerConnection后可以通过设置编码参数来限制最大码率。比如用RTCRtpSender.setParameters来约束视频码率。我实测把最大码率限制在800kbps之后弱网下的卡顿频率明显下降画面质量虽然达不到满血状态但至少流畅可用。const sender pc.getSenders().find(s s.track s.track.kind video); const params sender.getParameters(); params.encodings[0].maxBitrate 800 * 1000; await sender.setParameters(params);开启带宽自适应WebRTC默认是开启的但要注意别在RTCPeerConnection上手动固定码率。很多人为了“提高画质”把码率固定成2Mbps结果网络一抖动就直接卡死。正确做法是设置上限和下限让WebRTC的GCC算法在区间内自行调整。使用TURN中继作为兜底如果你发现STUN打洞经常失败或者跨运营商网络时UDP路径质量极差可以考虑强制部分流量走TURN。代价是延迟和带宽成本上升但媒体流稳定性能得到保障。在有TURN中转的情况下弱网丢包对通话的影响会更可控。开启simulcast或SVC如果你准备把demo往多人通话方向扩展simulcast同时发送多个分辨率的视频流或SVC空间可伸缩编码可以让你在不同网络条件下选用不同的视频层这是从P2P转向SFU架构时很重要的能力。不过在纯P2P demo里simulcast的收益有限反而会增加带宽消耗建议先不做。5. 排障实录与经验清单最后这段我把自己在demo调试中踩过的一些具体坑整理出来有些是网上不太会写“那么细”的但对排查问题非常有帮助。5.1 踩过的坑从“找不到设备”到“只能听见自己说话”坑一getUserMedia在非HTTPS环境下被拦截。浏览器把摄像头和麦克风视为敏感权限非安全上下文里直接报错。我在本地测试用的是localhost没问题但手机通过IP访问电脑上的页面时就被浏览器干掉了。解决方式是给页面套一个HTTPS证书或者用内网穿透工具暴露HTTPS地址。坑二只看到本地画面远端黑屏。这种情况下首先要看的是RTCPeerConnection的iceConnectionState状态。我在控制台里打出完整状态变化发现一直停留在checking说明ICE候选没有成功连通。排查下来是TURN服务器配置错误导致本端无法中继。后来把TURN地址换成了coturn的正确配置并且检查了端口是否开放状态才变成connected。坑三视频通了但声音是“回声”。这个坑很有意思不是因为远端听不见而是因为麦克风采集到了扬声器播放的远端声音形成回声。demo阶段最简单的验证方法是用耳机测试如果戴耳机没有回声那就说明是声学回声路径的问题需要在产品上做AEC声学回声消除。WebRTC本身内置了回声消除模块测试时出现回声往往是因为你手动改了音频设备或禁用了audioProcessing参数。坑四信令转发顺序错乱导致连不上。我在信令服务里最初只简单转发消息没有做顺序控制。后来遇到一种情况ICE candidate在SDP之前到达对端导致对端addIceCandidate时还没有remote description直接抛异常。解决方法是在接到candidate消息时如果remoteDescription还没有设置就把candidate缓存起来等setRemoteDescription之后再统一添加。这是一个非常典型的时序问题尤其在网络延迟波动的时候更容易暴露。5.2 demo跑通之后的下一步从demo到可交付的音视频应用如果你已经完成了上面所有的步骤恭喜你已经拥有了一个真正的WebRTC音视频通话demo。但说实话demo离一个可交付的音视频应用还差不少东西。结合我自己后续扩展的经验至少还有这几件事需要考虑信令服务的安全性demo里信令协议是全明文也没有鉴权。现实中你需要至少加入token校验、用户身份映射和消息加密否则别人可以伪造加入房间请求甚至窃听通话协商信息。媒体服务架构选型如果只是一对一P2P够用。但一旦超过两人要么做网格Mesh最多3-4人带宽消耗极大要么引入SFU如mediasoup、LiveKit、Janus等。从demo转向多人场景时我建议直接看SFU方案别在Mesh上浪费时间。监控与质量统计生产环境要有getStats数据的上报至少关注丢包率、RTT、Jitter、帧率、码率这五个核心指标。我在demo里就加了定时打印getStats的逻辑方便定位是网络问题还是编码问题。移动端适配与打包WebRTC API在移动端浏览器上有兼容性差异尤其是Android的WebView环境。如果你要打包成鸿蒙的hap或hsp需要改用鸿蒙系统的多媒体能力或者适配WebRTC的鸿蒙版本不能直接套浏览器实现。这部分最好从设计阶段就评估清楚。如果只让我给后来者一条建议那就是不要迷信“五分钟跑通”的教程用一天时间把信令、SDP和ICE的交互时序彻底跑明白比多写一百行业务代码更有价值。我当初就是从一遍遍打印日志、查看状态机变化开始才真正理解了这个demo里每个环节为什么存在。本文还有配套的精品资源点击获取