写这篇记录之前,先说一下背景。上个月我接了一个双人视频通话的需求,业务方点名要WebRTC,还说后端已经搭好了信令服务,让我这边直接在Android端集成就行。原以为这就是个“调库+联调”的常规活,结果从环境配置到跑通第一条视频流,整整折腾了一周半,中间踩的坑比过去半年加起来都多。这篇就直接把最值得记录的几类问题写出来,包括权限申请、SDP交换时序、摄像头切换、混淆打包这些环节,希望能给后面做同类功能的人省点时间。

我在写这篇文章时,按“从设计到实现再到排错”的顺序来组织,中间涉及到的都是我在真机调试时实际遇到的问题,不是网上那些 demo 里碰不到的隐藏雷区。你如果在按官方sample接入的过程中觉得一切顺利,那说明你运气非常好;如果和我一样各种奇奇怪怪的报错,那这篇应该能帮你定位到问题。

1. 项目背景与整体设计思路

1.1 为什么选WebRTC而不是其他方案

当时终端这边主要考虑了三个方向:自研协议栈、基于RTSP的低延迟方案、WebRTC。自研协议栈的问题很明显,开发周期长,还要做NAT穿透、丢包重传、码率自适应这些底层的脏活,短时间根本搞不定。RTSP虽然成熟,但主要走的是客户端拉流模式,做一对一互动通话时,双向推拉流和NAT穿透的处理并不比自研协议栈简单多少。

WebRTC的核心竞争力在于:音视频引擎、网络传输、NAT穿透这几块都内置好了,对上层暴露的API又是统一模型(PeerConnection、Track、SDP、ICE),Android端接入其实只需要做三件事——采集本地音视频流、把媒体能力协商消息通过信令通道转发、把远端流渲染到SurfaceViewRenderer上。这样看好像很简单,真正动手才会发现,API简单不代表链路简单,端上的权限、生命周期、硬件适配、代码混淆都会在某个环节突然冒出来卡你一下。

我最终选择WebRTC还有一个现实原因:后端的信令服务是对接网页端和小程序端的,WebRTC天然跨平台,同样的信令协议三端通用,不用为Android端单独维护一套通信协议。这点在项目后续扩展时价值很大,如果你也要做多端互通,选WebRTC基本没什么悬念。

1.2 整体架构与核心流程

Android端的视频通话模块,我从功能边界上拆成四层:UI与状态管理层、信令交互层、WebRTC引擎封装层、以及底层的硬件与网络适配层。

UI层只管通话页面的展示和用户操作,接听、挂断、切换摄像头、静音;状态管理层维护通话状态机,空闲、呼叫中、协商中、通话中、结束这些状态之间的流转都用状态机约束,避免UI和引擎层状态不同步。信令层用WebSocket和信令服务器通信,完成房间加入、对端发现、SDP和ICE Candidate交换。WebRTC引擎封装层负责PeerConnection生命周期、Track增删、采集设备和渲染器的绑定,这层是核心,也是踩坑最多的区域。

整条视频数据流的走向是这样的:本地摄像头采集原始帧 → 经过VideoSource的captureObserver回调 → 进入VideoEncoder编码 → 通过RTP包发到对端 → 对端经VideoDecoder解码 → 送到VideoSink的onFrame回调 → 渲染到SurfaceViewRenderer。这条链路里任何一环出了问题,都会表现为黑屏、卡顿、花屏,排查时必须顺着链路逐段定位。

1.3 选型时踩的第一个坑:WebRTC库的版本差异

这里必须先提一个非常容易忽略的点。网上搜“Android WebRTC”会出来一堆依赖写法,有 org.webrtc:google-webrtc 、有 io.webrtc:webrtc-android 、还有GitHub上某些维护者自己编译的版本。这些库的API和底层实现有细微差别,一旦和信令服务器的SDP规范、编码器协商参数对不上,问题会异常诡异。

我最终用的是 org.webrtc:google-webrtc:1.0.32006 这个版本,官方维护、API比较稳定,内部集成了H.264和VP8/VP9编码器,ABI也只需在arm64-v8a和armeabi-v7a之间选。这里提醒一句:如果服务端或者对端设备只支持H.264,那你必须确认手里的WebRTC库带了H.264编码器;某些第三方精简版只保留VP8,联调时会出现“两端都正常但互相黑屏”的诡异情况。

建议接入新工程时先把官方的 AppRTCDemo 跑通,再做业务封装。官方demo基本涵盖了权限申请、信令交互、通话页面的完整流程,先确认环境没问题,再改自己的业务代码,能省掉大量排查时间。

2. 环境准备与工程接入

2.1 Android Studio与SDK版本怎么选

WebRTC对Android SDK的最低版本要求不高,官方声明是21以上,但真要跑起来建议最低版本放在23以上,因为动态权限在6.0之后是强制项,你最低版本小于23的话还得额外处理一套兼容逻辑,纯属白费力气。

编译SDK版本建议在31以上,因为Android 12(API 31)对BLUETOOTH权限做了拆分,如果编译SDK低于31,蓝牙权限声明是会报错的。我这次用的Android Studio Dolphin版本 + compileSdk 33,targetSdk 32,真机是Android 13,这个组合没有出现编译层面的兼容问题。

如果你用的是更高版本的Android Studio,可能遇到AGP版本过新导致的依赖冲突,建议把Gradle和AGP都锁在相对稳定的版本组合上,不要动不动就升到最新。老项目迁移到新版IDE时频繁报缓存问题,也可以直接清掉 .gradle 缓存目录重建,比在IDE里反复Sync要快得多。

2.2 依赖引入的两种方式对比

第一种是直接引Maven官方库:

implementation 'org.webrtc:google-webrtc:1.0.32006'

这种方式省心,库已经预编译好了,直接使用 PeerConnectionFactory 即可。

第二种是从源码自己编译,这个方式适合需要定制底层编码器或日志模块的深度改造场景,但编一次WebRTC源码需要下载十几个GB的依赖,耗时几个小时起步,如果没有特殊需求完全没必要。

我建议直接用第一种方式。同时要注意:尽可能在单独的Module中封装WebRTC相关代码,而不是在业务Module里直接到处new PeerConnectionFactory 。因为WebRTC初始化代码和Camera枚举逻辑都比较吃线程,如果分散在业务代码里,后面做ProGuard混淆、多Module构建时,牵连面会非常大。

2.3 AndroidManifest与权限配置详解

WebRTC视频通话需要的主要权限如下:

<uses-feature android:name="android.hardware.camera" />
<uses-feature android:name="android.hardware.camera.autofocus" />

<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.BLUETOOTH" android:maxSdkVersion="30" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_AUDIO" />

这里面最容易忽略的是 BLUETOOTH_CONNECT 。Android 12之后蓝牙权限必须动态申请,如果你的App没有这项权限,WebRTC在创建音频Track时会拿不到蓝牙耳机设备,最终表现为连上了蓝牙耳机但对方听不到声音。当时我排查这个Bug花了大半天,最后在日志里看到 setAudioDeviceModule 相关异常才反应过来。

摄像头和录音权限是运行时权限,必须在创建PeerConnection之前拿到。WebRTC库不会帮你弹出权限对话框,也不会在权限被拒绝时回退到无声模式,它的处理方式是“直接打开采集失败”,然后整个通话流程卡在“正在连接...”。我建议权限申请应该单独放在进入通话页之前,而不是在通话页内边创建连接边申请。

2.4 so库兼容与ABI配置

WebRTC库包含native层的so文件。官方库默认支持armeabi-v7a、arm64-v8a、x86和x86_64。如果你的App只设置 abiFilters 打arm64-v8a,没问题;但如果你的工程还混用了其他包含so的第三方库,就需要确保所有库的ABI筛选一致,否则会在安装或运行时碰到很诡异的 UnsatisfiedLinkError 。

我碰到的场景是:App同时集成了WebRTC和某个扫脸SDK,那个SDK只提供armeabi-v7a和arm64-v8a,而WebRTC默认还带x86,合并之后在模拟器上调试没问题,但真机没什么问题。反而是在打Release包时,我为了瘦身把abiFilters设成“armeabi-v7a, arm64-v8a”,结果忘了某个模块的so目录结构里还残留了其他ABI的文件,Gradle打包时报了Duplicate File。解决办法是统一在Module的 packagingOptions 里排除重复so,或者统一全工程的ABI策略。

3. 核心环节实现:信令交互与媒体协商

3.1 信令服务器选型与消息格式设计

WebRTC并不是一个开箱即用的“无服务器”协议,两端要建立连接,必须先通过信令通道交换SDP和ICE Candidate。信令服务器本身不属于WebRTC标准的一部分,可以自己实现,也可以直接用现成的消息服务。

我们后端选的是基于WebSocket的Node.js信令服务,消息格式用的是JSON,核心事件就四类:

  • join :加入指定通话房间
  • offer :把本地SDP发给对端
  • answer :把对端生成的SDP回传
  • ice_candidate :转发ICE候选信息

Android端封装信令层时,要注意WebSocket必须在主线程外的线程发起连接,回调事件可以通过Handler切换到主线程。具体消息示例:

{
  "type": "offer",
  "roomId": "room_123",
  "sdp": {
    "type": "offer",
    "sdp": "v=0\r\n..."
  }
}

这部分的坑在于信令消息的到达时序。如果offer还没处理完,answer就到了,两端就会卡在协商阶段。建议在信令层做消息串行化处理,所有SDP相关消息进同一个队列按序消费。

3.2 SDP Offer/Answer交换的时序问题

SDP交换是整个媒体协商的核心。Android端一般是发起方先调用 createOffer ,拿到生成的SDP后 setLocalDescription ,再通过信令通道发给对端;对端收到offer以后 setRemoteDescription ,然后 createAnswer ,再 setLocalDescription 并回传answer。

很多第一次接触WebRTC的人会问,为什么要先 setLocalDescription 再发送offer?因为SDP里包含了本端支持的编码参数、DTLS指纹、ICE Credentials等信息,只有先让本地PeerConnection知道自己的描述,它才能正确生成后续的ICE候选。如果你先发送offer再调用 setLocalDescription ,对端返回answer时本地会不认账,日志里会出现“Called in wrong state”之类的错误。

正确流程是这样的:

val sdpObserver = object : SdpObserver {
    override fun onCreateSuccess(desc: SessionDescription) {
        peerConnection.setLocalDescription(object : SdpObserver {
            override fun onSetSuccess() {
                // send desc to remote via signaling
                signalingClient.sendOffer(desc)
            }
            override fun onSetFailure(reason: String?) {}
        }, desc)
    }
}
peerConnection.createOffer(sdpObserver, offerConstraints)

这里的回调一定要严格按顺序执行,onCreateSuccess之后才能setLocalDescription,setLocalDescription成功之后才能发送。别在onCreateSuccess里直接发消息,虽然大多数时候也能通,但遇到慢网络或弱网时,很容易出现对端已经返回answer、本地还没有设好localDescription的错乱状态。

3.3 ICE Candidate的收集与转发

ICE Candidate是网络层面的候选地址,决定了媒体流能不能穿透NAT到达对端。WebRTC内部会通过STUN/TURN服务器收集候选,收集完毕后通过 OnIceCandidate 回调抛出来,应用层需要把这个Candidate也通过信令发给对端。

代码里要在创建PeerConnection时注册IceObserver:

peerConnection = factory.createPeerConnection(rtcConfig, object : PeerConnection.Observer {
    override fun onIceCandidate(candidate: IceCandidate?) {
        candidate?.let { signalingClient.sendIceCandidate(it) }
    }
})

这个环节有两个常见问题。第一个是不等ICE Candidate收集完就发送SDP,导致对端网络候选信息不全,两边都在内网时问题不大,但在跨网段或公网场景下就是“一直连接中”。第二个是只发了一个Candidate就以为完事了,实际WebRTC可能收集到多个候选,需要全部转发过去,直到 onIceGatheringChange 回调了 IceGatheringState.COMPLETE 才能确认收集结束。

STUN和TURN服务器的配置也直接影响连接成功率。我这边配置的是:

val iceServers = listOf(
    IceServer.builder("stun:stun.l.google.com:19302").createIceServer(),
    IceServer.builder("turn:turn.example.com:3478")
        .setUsername("user")
        .setPassword("password")
        .createIceServer()
)

建议至少同时配一个STUN和一个TURN。STUN负责内网穿透,TURN负责在对称NAT或防火墙限制下做流量中继。如果你的场景对通话质量要求高,TURN服务器一定要用带宽充足、网络链路稳定的节点,否则通话质量会完全取决于TURN服务器所在机房的出网状况。

3.4 视频数据流在端侧的处理流程

再往深一层看,WebRTC的端侧媒体流处理流程是整个视频通话功能的主干。Android端采集的视频帧从CameraSource开始,经过VideoSource废弃帧率控制后,进入编码器编码成H.264或VP8帧,再经RTP包拆分后由ICE传输到对端。对端收包、组包、解码,变成原始YUV帧,通过VideoSink回调送到渲染器。

我在代码里是这么设置视频源的:

val videoSource = factory.createVideoSource(capturer.isScreencast)
val capturer = Camera2Enumerator(context).createCapturer(cameraName, videoSource.capturerObserver)
val videoTrack = factory.createVideoTrack(VIDEO_TRACK_ID, videoSource)
peerConnection.addTrack(videoTrack)

摄像头采集器的枚举也很关键。现在市面上的Android设备基本都是Camera2接口,但如果用 Camera2Enumerator 枚举不到设备,或者设备老一些,建议做一个降级处理,回退到 CameraEnumerator 的 camera1 实现,否则部分老型号机型上会直接报“摄像头打开失败”。

渲染远端视频流时,我用的是 SurfaceViewRenderer ,它本质上是封了一层SurfaceView的WebRTC渲染控件。渲染器需要设置明确定位坐标系,并在 onFrame 回调中更新画面,但这块通常不需要自己处理,WebRTC内部已经做了YUV转换和渲染。你要做的是在Activity生命周期里正确调用 renderer.onPause() 和 renderer.onResume() ,并在页面销毁时调用 renderer.release() ,否则底层Surface资源一直不释放,再创建新通话时会看到黑屏或闪屏。

4. 常见问题与排查技巧实录

4.1 FileProvider与content:// 权限引发的采集失败

这个坑比较冷门,但如果你做屏幕共享或录像保存功能,一定会碰上。我在做屏幕共享时,需要把录屏文件保存到外部存储,然后用FileProvider分享给相册。在调试的时候,获取的Uri是 content://com.example.myapp.fileprovider/external_path/android/data/com.example.myapp/cache/capture.mp4 这种格式。如果FileProvider配置的路径和实际路径不一致,就会出现系统找不到文件、媒体库无法读取的问题。

排查这个问题的关键点是核对 file_paths.xml 和实际存储路径。如果你在文件路径中配置了 <external-cache-path name="cache" path="." /> ,那上面的uri是合法的;如果只配置了 <external-path name="external" path="Android/data/com.example.myapp" /> ,那访问 /cache/capture.mp4 就会报错,必须同时检查FileProvider规则与实际文件位置。

我最终的配置是在 file_paths.xml 里同时声明两个路径:

<paths>
    <external-path name="external" path="Android/data/com.example.myapp/" />
    <cache-path name="cache" path="." />
</paths>

不过要特别提醒:不要把外部存储的整个根目录都对外分享,Android 7.0以后严格限制FileProvider暴露范围,如果你意外暴露了过多目录,系统会在安装或运行时做校验,同时从安全角度来说也不建议这样做。

4.2 摄像头打开失败与权限回调时机

摄像头的打开失败,80%以上都和权限回调时机有关。WebRTC库并不会在权限申请通过后自动重新打开摄像头,所以你在获取权限的回调里再去创建VideoCapturer、创建VideoSource和Track,是完全来不及的。因为PeerConnection可能已经创建好了,但Track还是空的。

我的建议是权限请求和WebRTC引擎初始化解耦。进入通话页之前先确保Camera和Audio权限都已授予;如果权限没有,就先弹窗提示,等权限拿完再初始化引擎。不要在通话页里做“边申请权限边初始化WebRTC”这种流程,否则大概率出现本地没有视频画面,或者本地有画面但远端黑屏的诡异状态。

另外,Android 9及以上系统对后台摄像头访问限制非常严格。如果你的App退到后台,摄像头会立即停止采集,回到前台后WebRTC不会自动恢复视频流。如果业务要求切后台后继续发送音频或视频,必须自己做前后台切换的逻辑,在 onPause 中保留PeerConnection,在 onResume 中重新开启采集器。

4.3 渲染黑屏、花屏与SurfaceViewRenderer释放

黑屏问题要分几种情况来看。最常见的是远端有流但渲染不出来,我当时的排查思路是:先用 addSink 看远端Track有没有 onFrame 回调,有回调说明解码正常,问题出在渲染器;没有回调说明整条链路可能断在媒体协商或弱网重协商阶段。

如果解码正常但黑屏,基本都是渲染器问题。我在代码里用的渲染器是:

remoteVideoView = findViewById(R.id.remote_video_view)
remoteVideoView.init(eglBaseContext, null)
remoteVideoView.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_BALANCED)
remoteVideoView.setMirror(false)

EGLBase.Context 需要在整个Activity中保证单例,多个SurfaceViewRenderer可以共享一个EGL上下文,但不要在页面销毁后又拿旧的上下文去创建新的渲染器。 setScalingType 这个属性决定画面比例模式,参数传错容易出现画面拉伸变形,但不会造成黑屏。

花屏问题一般出现在编码器协商不一致或者网络丢包严重时。你可以通过 onFrame 回调的 VideoFrame.getBuffer() 去判断数据是否存在,但更好的是打开WebRTC内部的统计信息查看丢包率。如果丢包率高,需要调节码率和分辨率参数,而不是盲目重连。

4.4 切换前后摄像头的线程安全问题

视频通话基本都要支持“切换摄像头”功能。我最初切换摄像头的实现是在UI点击事件里直接调用 capturer.switchCamera() ,结果多切换几次之后就出现了画面卡死,日志里报了很多Camera相关异常。

原因很简单: switchCamera 是耗时操作,不能在主线程直接调用,否则会引起Camera设备资源竞争。而且切换过程中新旧CameraSource不能同时持有摄像头硬件,必须在回调里等旧Camera释放之后再打开新Camera。

我后面改成这样,用 Handler 放到子线程:

Thread {
    videoCapturer?.switchCamera(object : CameraSwitchHandler {
        override fun onCameraSwitchDone(isFrontFacing: Boolean) {
            runOnUiThread {
                // update UI
            }
        }
        override fun onCameraSwitchError(errorDescription: String?) {
            // handle error
        }
    })
}.start()

同时要注意:在切换摄像头时,远端看到的画面方向可能会发生翻转。这里需要在本地渲染器上设置 setMirror(true) ,但在对端看到的画面不应该镜像,否则会像照镜子一样左右颠倒。做这块时要区分本地预览和远端渲染。

4.5 弱网环境下的卡顿与回声

弱网下的卡顿主要靠WebRTC自带的拥塞控制来缓解,但应用层可以做的更多。比如你可以根据网络状态动态调整视频码率上限。我当时的实现是在网络状态回调里动态设置比特率:

val params = peerConnection.parameters
val rtpParameters = params.getEncodings()
rtpParameters[0].maxBitrateBps = if (isWifi) 1500000 else 500000
peerConnection.parameters = params

这个策略在同一个小房间内多人通话(比如视频会议)或者转推流场景下尤其重要,因为上行带宽往往是瓶颈,如果不做码率控制,视频延迟会逐渐升高,最终导致通话质量崩溃。

回声问题则要区分是硬回声还是软回声。WebRTC内置了回声消除模块(AEC),但如果打开的扬声器和麦克风距离很近,或者设备本身声学设计不佳,AEC会失效。我遇到过一次现象是:对方能听到自己的回音,但我们这边的AEC开关没动过。排查到最后是系统录音模式设置的问题,在创建AudioDeviceModule时没有设置正确的音频模式,导致AEC没生效。合规做法是在创建PeerConnectionFactory时显式指定音频处理模块,并开启 enableAudioProcessing 。

另外要关注Android端的音频焦点问题。视频通话进行中来电或者系统闹钟响,会抢占音频焦点,此时WebRTC的音频输出会自动静音。你需要监听电话状态和音频焦点变化,在通话被中断时及时挂断或暂停通话,否则会处于“双方能看到画面但听不到声音”的状态。

4.6 ProGuard混淆导致反射失效

打Release包之后发现WebRTC能初始化,但创建PeerConnection时报内部错误,日志提示找不到某个方法或字段。这就是典型的混淆问题。

WebRTC内部大量使用了反射、JNI和动态代理,打包时必须对 org.webrtc 包做混淆保留。我用的规则是:

-keep class org.webrtc.** { *; }
-keepclassmembers class org.webrtc.** { *; }
-keep class * extends org.webrtc.** { *; }
-dontwarn org.webrtc.**

同时要注意,WebRTC依赖的 com.google.protobuf 、 org.json 、 javax.annotation 等库也要做相应保留,否则可能出现运行时类缺失。

如果你自定义了混淆字典,又发现“自定义混淆字典无效”,这通常不是WebRTC的问题,而是混淆字典配置本身的位置或命名不对,导致ProGuard没有读取到你指定的字典文件。检查规则是确认 -obfuscationdictionary 、 -classobfuscationdictionary 和 -packageobfuscationdictionary 的路径是否指向了有效文件,并检查AGP是否覆盖了你的ProGuard配置。我后来干脆把自定义字典文件放到 app/proguard/ 目录,然后在 proguard-rules.pro 里引用相对路径,问题就消失了。

5. 签名、打包与多Module构建的坑

5.1 应用签名SHA1值与第三方平台配置

视频通话如果要做音视频录制、实时美颜或者其他涉及第三方SDK的功能,经常需要配置应用签名的SHA1值。我当时在接某个美颜SDK时,需要把App签名的SHA1填到平台后台白名单里,否则SDK初始化都过不了。

查看SHA1的方式很简单,在Gradle里有现成任务,也可以在命令行里用keytool:

keytool -list -v -keystore your_keystore.jks -alias your_alias -storepass your_storepass

注意release签名和debug签名的SHA1值完全不同。如果你只在debug包调试,而平台后台填的是release签名的SHA1,那和平台SDK相关的所有功能调试时都会失败。别问我怎么知道的,这个问题我浪费了整整一个下午。

发布时记得把签名文件放在安全位置,不建议在 build.gradle 明文密码。我一般用 keystore.properties 文件统一管理,并在 .gitignore 里排除掉。视频通话涉及隐私数据,如果App还有安全审计要求,签名和密钥管理这块要格外谨慎。

5.2 多Module构建下的资源冲突与ABI整理

项目大了以后,很多人会把通话模块单独拆成一个Module。我遇到的情况是:主App Module和通话Module都各自包含了一个so文件,而且来自不同第三方库,Gradle在合并时因为相同的ABI目录下出现了同名但不同哈希的so文件,导致打包失败。

解决方法是先在 packagingOptions 里做排除:

android {
    packagingOptions {
        pickFirst '**/*.so'
    }
}

但 pickFirst 是治标不治本。如果两个so文件真名相同但内容不同,运行时一定会出错。更稳妥的方式是统一各Module的ABI筛选,只保留你实际要支持的ABI类型。如果App内同时有多个库涉及native层,建议把WebRTC和它们一一验证过再打包,别等发版之后用户报crash。

还有一个容易忽略的点:如果你的通话Module是通过AAR方式提供出去的,AAR内部的so目录结构必须符合规范,否则主App引用后会打不出包。我在移植一个旧项目时发现,某些AAR的so文件夹路径写错了,导致所有native库都加载不了,最后检查压缩包内部结构才发现问题。

5.3 Android Studio项目移植与版本管理经验

最近很多人在做“移植Android Studio项目”这件事,尤其是从Eclipse时代的老项目迁移过来的。WebRTC项目对接时,我建议你在迁移过程中先锁定三样东西:AGP版本、Gradle版本、JDK版本。这三者不兼容,你的WebRTC库大概率连编译都过不去。

比如AGP 7.0以上要求JDK 11,而WebRTC库如果依赖了JDK 11之前没有的API,编译就会报错。我当时的项目是从JDK 8迁到JDK 11的,过程里出现了很多 java.lang.NoClassDefFoundError ,最终结论是从官方文档确认支持的版本组合,不要自己瞎试。

还有一个关于Android Studio本身的小建议:不要为了追求新特性而把IDE升级到最新版本后立刻跑大项目。每次升级之后先跑一次官方demo工程,确认环境没问题再打开业务工程。我身边有同事因为IDE升级导致Cache错乱,反反复复Sync,最后花了几个小时才修好环境。

5.4 编译参数与构建性能的取舍

如果你工程比较大,WebRTC的加入会让编译时间明显上升。我在调优构建时会关掉无关模块的增量编译任务,同时开启Gradle的构建缓存。如果你的机器配置够,还可以在 gradle.properties 里设置:

org.gradle.parallel=true
org.gradle.caching=true
org.gradle.jvmargs=-Xmx4096m -XX:MaxMetaspaceSize=1024m

但有个问题:开启Gradle构建缓存之后,WebRTC模块是native代码,它是通过NDK编译的,NDK的编译缓存很吃磁盘空间。如果磁盘比较紧张,建议定期清理 ~/.gradle/caches 下面过期的缓存。不要把磁盘塞满,否则后续编译时可能出现无法写入临时文件导致的诡异报错。

另外,如果只是改WebRTC引擎封装层的代码,不需要重新编译native库,Gradle会自动做增量构建。但如果你改了 abiFilters 或NDK版本,构建系统会重新触发整个native构建流程,时间会非常久。遇到这种场景,可以先跑一次完整构建,确认没有问题,后续只修改上层Java/Kotlin代码,构建速度会快很多。

6. 写在最后的经验和建议

这次 WebRTC 视频通话功能从接手到稳定上线,前后经历了大概两周。回头看,最花时间的并不是代码编写,而是那些零散的、单看每一条都不算致命但叠加在一起就很折磨人的环境与兼容性问题。

我个人在实际操作中的体会是,如果你要做 Android 端的 WebRTC 视频通话,第一条线是确认好信令服务和媒体协商流程,第二条线才是写代码。把 SDP 交换时序、ICE Candidate 转发、权限回调顺序、SurfaceViewRenderer 生命周期这四件事理顺,你已经解决了 80% 的问题。至于后来遇到的那些摄像头切换死锁、混淆导致反射失效、FileProvider 的 content:// 路径配置错误,都属于一次性踩完就能记住的经验。

最后再分享一个小技巧:在联调阶段,一定要开启 WebRTC 内部的详细日志。你需要在创建 PeerConnectionFactory 之前调用 Logging.enableLogToDebugOutput(Logging.Severity.LS_INFO) ,这样在 logcat 里就会输出完整的 ICE 状态变化、RTP 收发统计、编码器和解码器信息。很多看似“莫名其妙”的视频问题,在日志里都能找到对应的线索。顺着日志排错,比自己瞎猜要高效得多。

希望这篇记录能帮你少走点弯路。如果后面有时间,我再把“Android 端多人视频会议”和“WebRTC 转 RTSP”这两个方向的经验整理出来继续分享。

Logo

邀请您加入社区

更多推荐