简介:面向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交换到底交换了什么
拿到对端的信令地址后,接下来双方要互相交换SDP(Session Description Protocol,会话描述协议)。SDP不是媒体数据本身,它是一个描述“我这边想怎么通话”的文本协议,里面包含媒体类型、编解码器、传输地址、带宽要求等信息。
调用createOffer生成的是提议SDP,格式类似这样(简化描述):
v=0 o=- 4611709806824416116 2 IN IP4 127.0.0.1 s=- t=0 0 m=video 9 UDP/TLS/RTP/SAVPF 96 97 98 ... a=rtpmap:96 VP8/90000 a=rtcp-fb:96 goog-remb a=rtcp-fb:96 transport-cc关键要理解两点:
- 编解码器协商:VP8、H264、opus这些编解码器不是每次通话都全部启用,而是通过SDP的优先级顺序和rtpmap参数协商出一个双方共同的组合。如果两端浏览器或客户端的编解码能力差异很大,协商失败就会表现为黑屏或者无声。
- m=video和m=audio行:分别描述视频和音频的候选端口、传输协议。很多人忽略音频部分,只把注意力放在视频上,结果画面通了却没声音,就是因为SDP里audio相关的m行没处理好。
你可以用pc.setLocalDescription(offer)把offer设为本端描述,然后通过信令发给远端;远端用pc.setRemoteDescription(offer)接收,再调用createAnswer返回应答SDP。整个SDP交换就是一次“议价”的过程。
2.3 ICE候选收集与NAT穿透:局域网秒通、公网不通的根源
ICE(Interactive 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,不做框架依赖
- 媒体服务器:不额外引入SFU,demo阶段只做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生成offer,setLocalDescription后发送给信令服务器。
- 被叫端收到offer,setRemoteDescription,然后createAnswer返回answer。
- 双方交换ICE candidate,RTCPeerConnection自动进行连通性检查。
- 连接建立,媒体流开始流动。
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 Control(GCC)的带宽估计体系,主要包括两部分:
- 基于丢包的控制器:收到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里每个环节为什么存在。
本文还有配套的精品资源,点击获取