简介:这是一份面向Android开发者的WebRTC音视频通话实战示例,适合希望快速掌握实时音视频通信、构建类微信通话界面的中初级开发者。项目基于谷歌开源WebRTC技术,围绕getUserMedia、RTCPeerConnection与RTCDataChannel三大核心组件展开,涵盖初始化PeerConnectionFactory、获取本地媒体流、创建SDP与ICE候选、信令交换、添加远程流及监听连接状态变化等关键环节,并实现了视频预览、接听挂断、静音与摄像头切换等UI布局。资源包共1460个文件,约94.92MB,以xml布局、json配置、dex与class字节码、so库、jar依赖及java源码为主,完整保留了Android Studio工程结构与依赖配置。目前已有2042人学习下载,读者可据此理解WebRTC在Android端的集成方式,并在此基础上对接登录系统或定制品牌UI,快速搭建可运行的通话框架。
1. WebRTC 音视频通话 demo:从零跑通一个能用的通话链路
很多人第一次接触 WebRTC,都是被“浏览器原生支持、不用装插件、点对点直连”这几个词吸引进来的。但真动手写一个 WebRTC 音视频通话 demo,往往会卡在几个很具体的地方:本地能跑,换台机器就不行;信令服务器不知道怎么写;ICE 候选收集不全导致连不上;明明拿到了媒体流,远端画面就是黑的。这个标题背后要解决的核心问题,其实不是“WebRTC 是什么”,而是“怎么用最短路径搭出一个能双向通话、能复现、能排错的最小系统”。
它适合两类人:一类是想快速验证 WebRTC 可行性的前端或全栈开发者,另一类是需要在产品里嵌入实时音视频能力、但不想一上来就接商业 SDK 的工程师。一个完整的 WebRTC 通话 demo 至少包含四块:本地媒体采集、信令交换、ICE 协商、远端流渲染。缺任何一块,通话都跑不起来。下面按实际落地顺序拆开讲,每一步都给可抄的代码和参数说明。
2. 信令与媒体采集:先把“能拿到画面”和“能交换描述”做扎实
2.1 为什么信令服务器不能省,以及最小实现选型
WebRTC 本身只负责媒体传输和编解码协商,它不规定信令怎么走。也就是说,两个浏览器之间怎么交换 SDP 描述和 ICE 候选,完全由你自己决定。常见做法是用 WebSocket 做信令通道,因为它是全双工、低延迟、浏览器原生支持。也有用 HTTP 轮询的,但延迟高、体验差,只适合做演示。
我一般会用一个极简的 Node.js WebSocket 服务,只做三件事:维护房间号到连接的映射、转发 offer/answer、转发 ICE candidate。不引入数据库、不引入鉴权,先把链路跑通。下面是最小信令服务代码:
// signal-server.js const WebSocket = require('ws'); const wss = new WebSocket.Server({ port: 8080 }); // rooms: { roomId: [ws1, ws2] } const rooms = new Map(); wss.on('connection', (ws) => { ws.on('message', (raw) => { const msg = JSON.parse(raw); const { roomId, type, payload } = msg; if (type === 'join') { if (!rooms.has(roomId)) rooms.set(roomId, []); const peers = rooms.get(roomId); peers.push(ws); ws.roomId = roomId; // 通知已有成员:有新 peer 加入 peers.forEach((peer) => { if (peer !== ws && peer.readyState === WebSocket.OPEN) { peer.send(JSON.stringify({ type: 'peer-joined' })); } }); return; } // 转发 offer / answer / ice-candidate 给同房间另一个 peer const peers = rooms.get(roomId) || []; peers.forEach((peer) => { if (peer !== ws && peer.readyState === WebSocket.OPEN) { peer.send(JSON.stringify({ type, payload })); } }); }); ws.on('close', () => { const peers = rooms.get(ws.roomId) || []; const idx = peers.indexOf(ws); if (idx > -1) peers.splice(idx, 1); }); });这段代码的关键逻辑是:join 时把连接按 roomId 分组,后续所有 offer、answer、ice-candidate 都只转发给同房间的另一个连接。参数上,port 可以按需改,roomId 用字符串即可,不需要额外生成规则。注意这里没有做房间人数上限,实际产品里要限制两人或做 SFU 分流。
2.2 本地媒体采集:getUserMedia 的参数怎么设才不翻车
拿到本地音视频流是整个 demo 的起点。很多人直接写{ audio: true, video: true },结果在某些机器上拿到的是低分辨率、或者音频有回声。更稳妥的写法是显式指定约束:
// local-media.js async function getLocalStream() { const constraints = { audio: { echoCancellation: true, // 回声消除,通话必开 noiseSuppression: true, // 噪声抑制 autoGainControl: true // 自动增益 }, video: { width: { ideal: 1280 }, height: { ideal: 720 }, frameRate: { ideal: 30, max: 30 } } }; try { const stream = await navigator.mediaDevices.getUserMedia(constraints); return stream; } catch (err) { // 常见错误:NotAllowedError 用户拒绝,NotFoundError 无设备 console.error('getUserMedia failed:', err.name, err.message); throw err; } }逻辑说明:audio 的三个布尔参数是通话场景的默认推荐值,echoCancellation 不开会导致对方听到自己的回声。video 用 ideal 而不是 exact,是为了在设备不支持 720p 时自动降级,而不是直接抛错。frameRate 设 max 30 可以避免某些摄像头输出 60 帧导致带宽浪费。失败时看 err.name,NotAllowedError 是权限问题,NotFoundError 是设备问题,OverconstrainedError 是约束太严。
2.3 创建 RTCPeerConnection 与绑定轨道
拿到本地流之后,要创建 RTCPeerConnection 并把轨道加进去。ICE 服务器配置是这里最容易忽略的地方。没有 STUN 服务器,很多网络环境下收集不到公网候选,通话直接失败。常见做法是配一个公共 STUN:
// peer-connection.js function createPeerConnection(iceServers) { const pc = new RTCPeerConnection({ iceServers: iceServers || [ { urls: 'stun:stun.example.com:3478' } ], iceCandidatePoolSize: 10 }); // 收集到候选后通过信令发出去 pc.onicecandidate = (event) => { if (event.candidate) { sendSignal({ type: 'ice-candidate', payload: event.candidate }); } }; // 远端轨道到达后渲染 pc.ontrack = (event) => { const remoteVideo = document.getElementById('remoteVideo'); if (remoteVideo.srcObject !== event.streams[0]) { remoteVideo.srcObject = event.streams[0]; } }; pc.onconnectionstatechange = () => { console.log('connection state:', pc.connectionState); // failed 状态需要触发重连或提示 }; return pc; }参数说明:iceCandidatePoolSize 预收集候选,能加快连接建立。onicecandidate 里必须判断 event.candidate 是否存在,为 null 表示收集结束。ontrack 里用 event.streams[0] 而不是逐个轨道拼,能直接拿到完整流。connectionState 变成 failed 时,demo 里至少要在控制台打出来,否则连不上时完全是个黑匣子。
3. 完整通话流程:offer/answer 交换与 ICE 候选对接
3.1 发起方:createOffer 到 setLocalDescription 的顺序
发起方的流程是:创建 PC、加轨道、createOffer、setLocalDescription、通过信令发 offer。顺序不能乱,setLocalDescription 之后才会开始收集 ICE 候选。代码:
// caller.js async function startCall(pc, signal) { const offer = await pc.createOffer({ offerToReceiveAudio: true, offerToReceiveVideo: true }); await pc.setLocalDescription(offer); signal({ type: 'offer', payload: pc.localDescription }); }逻辑说明:offerToReceiveAudio/Video 在早期用来声明接收意愿,现在浏览器默认会协商,但显式写上兼容性更好。setLocalDescription 之后,onicecandidate 会陆续触发,候选通过信令发出去。注意不要在 setLocalDescription 之前就发 offer,否则 SDP 里没有完整的候选信息。
3.2 接收方:setRemoteDescription 与 createAnswer 的时序
接收方收到 offer 后,先 setRemoteDescription,再 createAnswer,再 setLocalDescription,最后把 answer 发回去。代码:
// callee.js async function handleOffer(pc, offer, signal) { await pc.setRemoteDescription(new RTCSessionDescription(offer)); const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); signal({ type: 'answer', payload: pc.localDescription }); }这里最常见的翻车点是:接收方还没加本地轨道就 createAnswer,导致 answer 里没有媒体方向。正确做法是在 handleOffer 之前就把本地流轨道 addTrack 到 pc 上。另外,setRemoteDescription 是异步的,必须 await,否则后续 createAnswer 可能拿到不完整的远端描述。
3.3 ICE 候选的双向添加与时机问题
ICE 候选可能在 offer/answer 之前、之中、之后到达,所以不能假设顺序。标准做法是:收到候选就 addIceCandidate,但如果此时 remoteDescription 还没设置,要先缓存起来。代码:
// ice-handler.js const pendingCandidates = []; async function handleIceCandidate(pc, candidate) { if (!pc.remoteDescription || !pc.remoteDescription.type) { pendingCandidates.push(candidate); return; } await pc.addIceCandidate(new RTCIceCandidate(candidate)); } async function flushPendingCandidates(pc) { while (pendingCandidates.length) { const c = pendingCandidates.shift(); await pc.addIceCandidate(new RTCIceCandidate(c)); } }逻辑说明:pendingCandidates 缓存早到的候选,在 setRemoteDescription 之后调用 flushPendingCandidates 统一添加。这个细节在本地测试时经常被忽略,因为本地候选到达顺序比较理想,但跨网络时必现。参数上,addIceCandidate 失败会抛错,demo 里至少要 catch 并打印,否则候选丢了都不知道。
3.4 一个可运行的双向通话页面骨架
把上面几块拼起来,HTML 结构只需要两个 video 标签和一点按钮:
<!-- index.html --> <video id="localVideo" autoplay muted playsinline></video> <video id="remoteVideo" autoplay playsinline></video> <button id="callBtn">呼叫</button> <button id="hangupBtn">挂断</button>// main.js const ws = new WebSocket('ws://localhost:8080'); let pc = null; async function init() { const stream = await getLocalStream(); document.getElementById('localVideo').srcObject = stream; pc = createPeerConnection(); stream.getTracks().forEach((track) => pc.addTrack(track, stream)); ws.onmessage = async (event) => { const msg = JSON.parse(event.data); if (msg.type === 'offer') { await handleOffer(pc, msg.payload, sendSignal); await flushPendingCandidates(pc); } else if (msg.type === 'answer') { await pc.setRemoteDescription(new RTCSessionDescription(msg.payload)); await flushPendingCandidates(pc); } else if (msg.type === 'ice-candidate') { await handleIceCandidate(pc, msg.payload); } }; } function sendSignal(msg) { ws.send(JSON.stringify({ roomId: 'demo-room', ...msg })); } document.getElementById('callBtn').onclick = () => startCall(pc, sendSignal);这段骨架的关键点是:本地流先渲染到 localVideo,再 addTrack 到 pc;信令消息按类型分发;offer 处理完要 flush 缓存候选。roomId 写死为 demo-room,实际用的时候按用户输入或 URL 参数生成。
4. 避坑与排查:连不上、黑屏、回声的 5 个血泪记录
4.1 现象:本地能通,换台机器就卡在 connecting
原因:ICE 候选只收集到了局域网地址,没有公网候选。没有 STUN 服务器时,跨 NAT 基本连不上。解决:在 RTCPeerConnection 配置里加 STUN,并检查 onicecandidate 是否收到了 srflx 类型的候选。可以在 chrome://webrtc-internals 里看候选列表,如果全是 host 类型,说明 STUN 没生效。
4.2 现象:远端视频黑屏,但 connectionState 是 connected
原因:ontrack 触发了,但 srcObject 赋值时机不对,或者远端流里只有音频没有视频。解决:在 ontrack 里打印 event.streams[0].getTracks(),确认有 video track。另外检查 autoplay 和 playsinline 是否都加了,移动端缺 playsinline 会导致视频不渲染。如果还是黑屏,看 remoteVideo.videoWidth 是否为 0,为 0 说明流没真正到达。
4.3 现象:通话有严重回声,对方听到自己
原因:本地 video 标签没有 muted,导致本地音频被扬声器播放后又被麦克风采集。解决:localVideo 必须加 muted 属性。另外确认 audio 约束里 echoCancellation 为 true。如果用的是外置扬声器,回声消除效果有限,建议戴耳机测试。
4.4 现象:ICE 候选添加报错 InvalidStateError
原因:在 remoteDescription 还没设置时就 addIceCandidate。解决:用 3.3 里的 pendingCandidates 缓存机制,等 setRemoteDescription 完成后再统一添加。这个错误在本地测试时可能不出现,因为本地候选到达晚,但跨网络时必现。
4.5 现象:挂断后重新呼叫,第二次连不上
原因:旧的 RTCPeerConnection 没有 close,信令连接状态混乱。解决:挂断时调用 pc.close(),并把 pc 置为 null,重新呼叫时重新创建。同时信令服务里要清理已关闭的连接,避免转发到死连接。这个坑在 demo 里很常见,因为大家只关注第一次通话。
5. 进阶技巧:用 getStats 验证链路质量与自适应码率
demo 跑通之后,下一步不是加功能,而是学会看数据。WebRTC 的 getStats API 能拿到往返延迟、丢包率、可用带宽、编码器信息。我一般会在通话建立后每隔 2 秒拉一次统计,重点看三个指标:candidate-pair 的 currentRoundTripTime、inbound-rtp 的 packetsLost、outbound-rtp 的 targetBitrate。下面是一个最小统计采集:
// stats.js async function collectStats(pc) { const stats = await pc.getStats(); stats.forEach((report) => { if (report.type === 'candidate-pair' && report.state === 'succeeded') { console.log('RTT:', report.currentRoundTripTime); } if (report.type === 'inbound-rtp' && report.kind === 'video') { console.log('packetsLost:', report.packetsLost, 'jitter:', report.jitter); } if (report.type === 'outbound-rtp' && report.kind === 'video') { console.log('targetBitrate:', report.targetBitrate); } }); } setInterval(() => collectStats(pc), 2000);参数说明:currentRoundTripTime 单位是秒,超过 0.3 秒通话体验明显变差。packetsLost 是累计值,要看增量而不是绝对值。targetBitrate 是浏览器根据网络状况动态调整的目标码率,如果它一直往下掉,说明带宽不足。这些数据比看日志有用得多,是排查质量问题的后悔药。
另一个实用技巧是手动限制码率。在 addTrack 之前用 setParameters 改 sender 的 encoding:
// bitrate-limit.js async function limitBitrate(pc, maxBitrate) { const senders = pc.getSenders(); for (const sender of senders) { if (sender.track && sender.track.kind === 'video') { const params = sender.getParameters(); if (!params.encodings || !params.encodings.length) { params.encodings = [{}]; } params.encodings[0].maxBitrate = maxBitrate; await sender.setParameters(params); } } }逻辑说明:maxBitrate 单位是 bps,比如 500000 表示 500kbps。这个设置要在 setLocalDescription 之后调用才生效,太早调用 encodings 可能为空。限制码率在弱网下能减少卡顿,但画质会下降,需要按场景权衡。
最后说一个我自己的习惯:每次改完信令或 ICE 相关代码,先在 chrome://webrtc-internals 里完整走一遍通话,看候选收集、SDP 交换、连接状态变化是否和预期一致。这个页面是 WebRTC 调试的黑匣子,比在代码里到处打 console 高效得多。希望帮到你。
本文还有配套的精品资源,点击获取