从零拆解Android WebRTC音视频通话Demo:核心模块与避坑指南 简介这是一份面向Android开发者的WebRTC音视频通话实战示例基于谷歌开源实时通信技术帮助开发者在移动端快速搭建P2P音视频通话能力适合具备一定Android基础、希望深入理解实时音视频通信原理的中级开发者学习参考。资源包共1460个文件约94.92MB涵盖xml布局、json配置、dex与class字节码、so原生库、jar依赖及java源码等完整呈现了Android工程从依赖配置到界面实现的整体结构。项目针对Android平台优化界面风格接近微信通话包含视频预览、接听挂断、静音与摄像头切换等交互模块并涉及PeerConnectionFactory初始化、getUserMedia媒体采集、RTCPeerConnection连接建立、SDP与ICE候选交换、信令处理及连接状态监听等核心流程。目前已有2042人学习下载读者可借此掌握WebRTC在Android端的集成思路与关键实现步骤为后续定制化开发打下基础。1. 从零拆一个 WebRTC 音视频通话 DemoAndroid 端到底能跑通什么如果你在 Android 上搜过 WebRTC 音视频通话的示例大概率见过那种“两个手机连上同一个信令服务器就能互相看见”的 demo。听起来简单但真到自己动手往往卡在权限、编解码协商、NAT 穿透这几步上。我这次拆的这个 WebRTC 音视频通话 demo就是一个最小可运行的 Android 端 1v1 通话工程包含信令交换、SDP 协商、ICE 候选收集、本地/远端视频渲染这几块核心链路。它适合两类人一是想快速验证 WebRTC 在 Android 真机上能不能跑通的开发者二是需要一份干净代码基线来做二次开发比如加美颜、加录制、换信令协议的工程师。整个 demo 不依赖任何商业 SDK纯原生 WebRTC 库加一个轻量信令服务代码量不大但该有的坑一个不少。2. 拆开这个 DemoAndroid 端 WebRTC 的四个核心模块2.1 信令层为什么 WebRTC 自己不管信令WebRTC 标准里只定义了媒体传输和协商格式没规定信令怎么走。这个 demo 用了一个极简的 WebSocket 信令服务只做三件事转发 SDP offer/answer、转发 ICE candidate、维护房间号。常见做法是用 Node.js 的ws库起一个几十行的服务或者直接拿现成的信令服务器改。我一般会先确认信令消息格式因为后面 Android 端解析全靠它。// 信令服务核心逻辑Node.js ws const WebSocket require(ws); const wss new WebSocket.Server({ port: 8080 }); const rooms {}; // 房间号 - 两个客户端 wss.on(connection, (ws) { ws.on(message, (msg) { const data JSON.parse(msg); // 加入房间把两个客户端配对 if (data.type join) { if (!rooms[data.room]) rooms[data.room] []; rooms[data.room].push(ws); ws.room data.room; // 房间满两人就通知发起方创建 offer if (rooms[data.room].length 2) { rooms[data.room][0].send(JSON.stringify({ type: ready })); } } else { // 转发 offer / answer / candidate 给对方 const peer rooms[ws.room]?.find(c c ! ws); if (peer) peer.send(msg); } }); });这段代码里room是房间标识两个客户端用同一个房间号加入后服务端只负责透传消息不解析 SDP 内容。参数上唯一要注意的是端口别和 Android 端写死的地址冲突。逻辑说明客户端 A 加入后等 BB 加入后服务端通知 A 发 offer之后所有消息原样转发。这个设计的好处是信令服务可以随时替换成 HTTP 轮询或 MQTTAndroid 端只需要改连接方式不用动 WebRTC 核心逻辑。2.2 PeerConnection 初始化参数设错直接黑屏Android 端初始化PeerConnectionFactory和PeerConnection是整个 demo 最容易翻车的地方。常见做法是先用PeerConnectionFactory.initialize()做全局初始化再构建 factory最后用IceServer列表创建 peer connection。参数里sdpSemantics必须设成UNIFIED_PLAN否则和现代浏览器或新版 WebRTC 库协商会失败。// Android 端 PeerConnection 初始化关键代码 PeerConnectionFactory.initialize( PeerConnectionFactory.InitializationOptions.builder(context) .setEnableInternalTracer(false) .createInitializationOptions() ); PeerConnectionFactory factory PeerConnectionFactory.builder() .setOptions(new PeerConnectionFactory.Options()) .createPeerConnectionFactory(); ListPeerConnection.IceServer iceServers new ArrayList(); iceServers.add(PeerConnection.IceServer.builder(stun:stun.example.com:3478).createIceServer()); PeerConnection.RTCConfiguration config new PeerConnection.RTCConfiguration(iceServers); config.sdpSemantics PeerConnection.SdpSemantics.UNIFIED_PLAN; // 必须 config.continualGatheringPolicy PeerConnection.ContinualGatheringPolicy.GATHER_CONTINUALLY; PeerConnection peerConnection factory.createPeerConnection(config, new PeerConnection.Observer() { Override public void onIceCandidate(IceCandidate candidate) { // 把 candidate 通过信令发给对方 signalingClient.sendCandidate(candidate); } Override public void onAddStream(MediaStream stream) { // 远端流到达绑定到 SurfaceViewRenderer runOnUiThread(() - remoteRenderer.setStream(stream)); } // 其他回调省略 });这里iceServers填的是 STUN 地址demo 里通常用一个公共 STUN 或者自建。continualGatheringPolicy设成持续收集是为了在通话中网络切换时还能补发 candidate。注意onAddStream在新版 API 里可能被onAddTrack替代具体看 WebRTC 库版本。我一般会先跑通onIceCandidate和onAddStream这两个回调确认信令通了再调媒体。2.3 本地采集与渲染Camera2 和 SurfaceViewRenderer 的对接Android 端采集用Camera2Enumerator找摄像头然后创建VideoCapturer再通过VideoSource绑定到VideoTrack。渲染这块 demo 用的是SurfaceViewRenderer本地预览和远端渲染各一个。常见坑是SurfaceViewRenderer的初始化顺序必须先init(rootEglBase.getEglBaseContext(), null)再setMirror(true)做本地镜像最后才能setStream。// 本地视频采集与渲染 CameraEnumerator enumerator new Camera2Enumerator(context); String[] deviceNames enumerator.getDeviceNames(); VideoCapturer capturer enumerator.createCapturer(deviceNames[0], null); // 默认前摄 VideoSource videoSource factory.createVideoSource(false); capturer.initialize(surfaceTextureHelper, context, videoSource.getCapturerObserver()); capturer.startCapture(1280, 720, 30); // 分辨率 720p帧率 30 VideoTrack localTrack factory.createVideoTrack(local_video, videoSource); localTrack.addSink(localRenderer); // 本地预览 // 把 track 加到 peer connection peerConnection.addTrack(localTrack, Collections.singletonList(stream_id));参数上startCapture的宽高和帧率要根据设备能力调设太高会直接失败。surfaceTextureHelper要用SurfaceTextureHelper.create(CaptureThread, eglBase.getEglBaseContext())创建。逻辑说明采集到的帧通过VideoSource分发给本地渲染和 peer connection 两条路本地渲染是直接显示远端渲染要等onAddStream回调。如果本地预览黑屏先查capturer.startCapture是否抛异常再查SurfaceViewRenderer是否在主线程初始化。2.4 媒体协商与 ICESDP 里到底改了什么SDP offer/answer 交换是 WebRTC 通话的“黑匣子”demo 里通常直接透传但你要知道里面关键字段。offer 里包含媒体行mvideo、编解码列表artpmap、ICE 候选acandidate。Android 端创建 offer 用peerConnection.createOffer(sdpObserver, MediaConstraints)然后setLocalDescription再把 SDP 通过信令发出去。收到 answer 后setRemoteDescriptionICE 候选会陆续通过onIceCandidate回调出来。// 创建 offer 并发送 MediaConstraints constraints new MediaConstraints(); constraints.mandatory.add(new MediaConstraints.KeyValuePair(OfferToReceiveVideo, true)); constraints.mandatory.add(new MediaConstraints.KeyValuePair(OfferToReceiveAudio, true)); peerConnection.createOffer(new SdpObserver() { Override public void onCreateSuccess(SessionDescription sdp) { peerConnection.setLocalDescription(new SdpObserver() {}, sdp); signalingClient.sendOffer(sdp.description); // 发原始 SDP 字符串 } Override public void onCreateFailure(String error) { Log.e(WebRTC, createOffer failed: error); } // onSetSuccess / onSetFailure 省略 }, constraints);OfferToReceiveVideo和OfferToReceiveAudio必须设 true否则 offer 里不会带接收方向。SDP 字符串里aice-ufrag和aice-pwd是 ICE 认证凭据两端必须匹配。如果协商后没有画面先看 SDP 里有没有asendrecv再看 ICE 状态是不是CONNECTED。我一般会在onIceConnectionChange里打日志状态从CHECKING到CONNECTED才算通。3. 避坑排查Android WebRTC 最容易翻车的五个点3.1 现象本地预览正常远端黑屏原因onAddStream回调没触发或者远端流绑定的SurfaceViewRenderer没初始化。常见于PeerConnection.Observer注册不完整或者setRemoteDescription失败但没打日志。解决在onAddStream里加日志确认流到达检查remoteRenderer.init()是否在setStream之前调用。另外确认 SDP answer 里arecvonly还是asendrecv如果是recvonly说明对方没发流。3.2 现象ICE 状态一直卡在 CHECKING原因STUN 服务器不可达或者 candidate 没通过信令发出去。demo 里常见的是信令服务只转发了 offer/answer忘了转发 candidate。解决检查onIceCandidate回调里是否调用了发送逻辑信令服务是否把 candidate 消息透传。如果用的是公共 STUN确认网络环境能访问该地址。自建 STUN 的话检查端口和防火墙。3.3 现象createOffer 返回的 SDP 里没有视频行原因MediaConstraints里OfferToReceiveVideo没设 true或者VideoTrack没加到 peer connection。解决确认peerConnection.addTrack(localTrack, ...)在createOffer之前调用并且 constraints 里两个接收标志都设了。如果用的是addTransceiver检查 direction 参数。3.4 现象通话几秒后自动断开原因PeerConnection被 GC 回收或者VideoCapturer没 stop 导致资源泄漏。demo 里常见的是把peerConnection声明成局部变量方法结束就没了引用。解决把peerConnection、VideoSource、VideoCapturer都作为成员变量持有在onDestroy里按顺序capturer.stopCapture()、videoSource.dispose()、peerConnection.close()。3.5 现象权限申请了但采集还是失败原因Android 6.0 以上需要运行时权限demo 里可能只申请了CAMERA和RECORD_AUDIO但忘了在AndroidManifest.xml里声明。或者权限回调里没等用户授权就调了startCapture。解决确认 manifest 里CAMERA、RECORD_AUDIO、INTERNET三个权限都声明了运行时用ActivityCompat.requestPermissions申请在onRequestPermissionsResult里再启动采集。4. 进阶技巧用 ADB 和日志验证 ICE 连通性跑通 demo 之后真正要调优的时候不能只看画面。我习惯用adb logcat过滤 WebRTC 原生日志重点看 ICE 候选类型和连接状态。下面这条命令可以抓取关键标签adb logcat -s WebRTC:V org.webrtc:V | grep -E IceConnectionState|Candidate|SDP输出里IceConnectionState从CHECKING到CONNECTED的时间能反映网络质量Candidate行会显示host、srflx、relay三种类型。如果只有host没有srflx说明 STUN 没生效跨网络通话会失败。我一般会先确认srflx候选出现再测跨网段通话。另一个技巧是手动改 SDP 里的编解码优先级。比如强制用 VP8 而不是 H264可以在setLocalDescription之前对 SDP 字符串做替换// 强制 VP8 优先示例实际需按 SDP 格式解析 String modifiedSdp sdp.description.replace(H264/90000, VP8/90000); SessionDescription modified new SessionDescription(sdp.type, modifiedSdp); peerConnection.setLocalDescription(sdpObserver, modified);这种改法有风险因为 SDP 里artpmap的 payload type 和afmtp要对应直接替换字符串可能破坏格式。更稳妥的做法是用RtpParameters在addTransceiver时指定编码。我一般只在调试阶段用字符串替换快速验证正式代码里走 API 设置。验证音频链路可以用AudioTrack的统计信息在onAddStream里拿到远端音频 track 后注册AudioTrackSink打印能量值。如果能量值一直为 0说明音频没通先查OfferToReceiveAudio和麦克风权限。从那以后我每次跑 WebRTC demo都强制先看adb logcat里 ICE 状态和候选类型确认srflx出现再测画面。这个习惯帮我省了很多“看起来通了其实没通”的返工。希望帮到你。本文还有配套的精品资源点击获取