一、项目介绍
1. 背景与意义
随着移动端实时通讯(RTC)需求的爆炸式增长,基于 WebRTC 的点对点(P2P)视频和音频通话已成为几乎所有现代社交、直播、远程协作应用的核心功能。在 Android 平台上直接使用 WebRTC 原生 API 能最大化定制性和性能,但学习曲线陡峭、配置复杂。本项目通过手把手示例,教你在 Android 上从零开始:
-
集成 Google WebRTC 原生库
-
建立信令通道(使用 WebSocket)
-
完成两台 Android 设备之间互连
-
传输并渲染实时视频流与音频流
-
管理 ICE 候选、SDP 协商、网络变化
-
处理会话断开与重连
-
封装复用模块,支持多房间、多对多通话
全文超过一万字,所有代码整合到一个代码块中,用注释区分不同文件,便于复制。
2. 功能需求
-
双端互连:两部 Android 设备加入同一房间,相互看到对方摄像头画面并听到对方音频
-
信令服务:基于 WebSocket 进行 SDP 与 ICE 候选交换
-
视频渲染:SurfaceViewRenderer 渲染本地与远端流
-
网络自适应:处理网络切换与重连
-
UI 交互:开始/结束通话、切换前后摄、静音、视频开关
-
日志与统计:展示通话时长、丢包、码率等信息
二、相关知识
-
WebRTC 核心组件
-
PeerConnectionFactory
:工厂,用于创建音视频捕获器、编码器与PeerConnection
-
PeerConnection
:负责底层 ICE 协商、NAT 穿透与媒体传输 -
VideoCapturer
+VideoTrack
:采集摄像头并编码发送 -
SurfaceViewRenderer
:渲染远端与本地视频 -
AudioTrack
:采集与播放音频
-
-
信令协议
-
WebRTC 本身不包含信令,需要自定义。常用 WebSocket、Socket.IO、RESTful+Long Polling 等
-
-
ICE 与 STUN/TURN
-
IceServer
列表包含 STUN(候选发现)与 TURN(中继)服务器 -
根据环境添加公有或自建 STUN/TURN
-
-
SDP 协商流程
-
Caller 创建 Offer,Caller SetLocalDescription → 发送 Offer → Callee SetRemoteDescription → Callee CreateAnswer → Callee SetLocalDescription → 发送 Answer → Caller SetRemoteDescription
-
双方收集与交换 ICE 候选
-
-
Android 与 WebRTC 打包
-
可以从 Google Maven(
org.webrtc:google-webrtc:VERSION
)拉取,也可用官方 C++ 源码自行编译
-
-
多线程与 Looper
-
WebRTC 在内部使用
EglBase.Context
、SurfaceViewRenderer
要在 UI 线程操作,协商和 I/O 可在后台线程
-
-
性能与电量
-
建议使用硬件编码(默认),并在低带宽场景下动态适应分辨率和帧率
-
三、环境与依赖
// app/build.gradle
plugins {
id 'com.android.application'
id 'kotlin-android'
}
android {
compileSdk 34
defaultConfig {
applicationId "com.example.webrtcdemo"
minSdk 21
targetSdk 34
}
packagingOptions {
pickFirst 'lib/armeabi-v7a/libjingle_peerconnection_so.so'
pickFirst 'lib/arm64-v8a/libjingle_peerconnection_so.so'
}
}
dependencies {
implementation "androidx.appcompat:appcompat:1.6.1"
implementation "androidx.constraintlayout:constraintlayout:2.1.4"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4"
// WebRTC 原生库
implementation 'org.webrtc:google-webrtc:1.0.32006'
// WebSocket 客户端
implementation 'com.squareup.okhttp3:okhttp:4.10.0'
}
四、实现思路与架构
-
信令模块
SignalClient
-
WebSocket 连接服务器,封装
sendOffer()
,sendAnswer()
,sendIceCandidate()
-
注册回调收到远端 SDP/ICE 后调用
onRemoteSessionReceived()
-
-
PeerConnection 管理
RTCClient
-
初始化
PeerConnectionFactory
、创建本地流(摄像头+麦克风) -
创建
PeerConnection
,添加本地MediaStream
,设置PeerConnection.Observer
-
startCall()
→createOffer()
→ 发送 Offer -
接收到 Offer →
onRemoteSessionReceived()
→createAnswer()
→ 发送 Answer -
交换 ICE 候选
-
-
UI 层
MainActivity
-
持有两个
SurfaceViewRenderer
:本地与远端 -
按钮触发呼叫/挂断、静音、摄像头切换
-
显示通话时长与网络状态
-
-
生命周期管理
-
在
onCreate()
初始化,onDestroy()
释放资源 -
退出通话时关闭 WebSocket 与
PeerConnection
-
-
网络重连
-
SignalClient
在 WebSocket 断开时自动重试 -
PeerConnection
在 ICE 失败时尝试重连接
-
五、整合代码
// =======================================================
// 文件:AndroidManifest.xml
// 描述:申请网络与摄像头权限
// =======================================================
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.webrtcdemo">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<application android:theme="@style/Theme.App">
<activity android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity>
</application>
</manifest>
// =======================================================
// 文件:res/layout/activity_main.xml
// 描述:双 SurfaceViewRenderer 与控制按钮
// =======================================================
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_height="match_parent">
<org.webrtc.SurfaceViewRenderer
android:id="@+id/localView"
android:layout_width="120dp" android:layout_height="160dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<org.webrtc.SurfaceViewRenderer
android:id="@+id/remoteView"
android:layout_width="0dp" android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/controlPanel"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<LinearLayout
android:id="@+id/controlPanel"
android:layout_width="0dp" android:layout_height="wrap_content"
android:orientation="horizontal" android:gravity="center"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
<Button android:id="@+id/btnCall" android:text="呼叫"/>
<Button android:id="@+id/btnHangup" android:text="挂断" android:layout_marginStart="16dp"/>
<ToggleButton android:id="@+id/btnMute" android:textOff="静音" android:textOn="静音" android:layout_marginStart="16dp"/>
<ToggleButton android:id="@+id/btnSwitch" android:textOff="切换摄像头" android:textOn="切换摄像头" android:layout_marginStart="16dp"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
// =======================================================
// 文件:SignalClient.kt
// 描述:WebSocket 信令客户端,使用 OkHttp
// =======================================================
package com.example.webrtcdemo.signal
import okhttp3.*
import okio.ByteString
import org.json.JSONObject
import java.util.concurrent.TimeUnit
class SignalClient(
private val serverUrl: String,
private val listener: Listener
) {
interface Listener {
fun onOffer(sdp: String)
fun onAnswer(sdp: String)
fun onIceCandidate(sdpMid: String, sdpMLineIndex: Int, candidate: String)
}
private val client = OkHttpClient.Builder()
.readTimeout(0, TimeUnit.MILLISECONDS)
.build()
private var ws: WebSocket? = null
fun connect() {
val req = Request.Builder().url(serverUrl).build()
ws = client.newWebSocket(req, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
val json = JSONObject(text)
when (json.getString("type")) {
"offer" -> listener.onOffer(json.getString("sdp"))
"answer"-> listener.onAnswer(json.getString("sdp"))
"candidate"-> listener.onIceCandidate(
json.getString("sdpMid"),
json.getInt("sdpMLineIndex"),
json.getString("candidate")
)
}
}
})
}
fun sendOffer(sdp: String) {
ws?.send(JSONObject().apply {
put("type","offer"); put("sdp",sdp)
}.toString())
}
fun sendAnswer(sdp: String) {
ws?.send(JSONObject().apply {
put("type","answer"); put("sdp",sdp)
}.toString())
}
fun sendIceCandidate(sdpMid: String, sdpMLineIndex: Int, candidate: String) {
ws?.send(JSONObject().apply {
put("type","candidate")
put("sdpMid",sdpMid)
put("sdpMLineIndex",sdpMLineIndex)
put("candidate",candidate)
}.toString())
}
fun close() { ws?.close(1000, null) }
}
// =======================================================
// 文件:RTCClient.kt
// 描述:WebRTC 管理类,封装 PeerConnection 与流
// =======================================================
package com.example.webrtcdemo.rtc
import android.content.Context
import org.webrtc.*
class RTCClient(
private val context: Context,
private val eglBase: EglBase,
private val signalClient: com.example.webrtcdemo.signal.SignalClient
) : PeerConnection.Observer, PeerConnection.IceServer {
private val factory: PeerConnectionFactory
private val peerConnection: PeerConnection
private val localVideoTrack: VideoTrack
private val localAudioTrack: AudioTrack
init {
PeerConnectionFactory.initialize(
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
)
factory = PeerConnectionFactory.builder()
.setVideoEncoderFactory(DefaultVideoEncoderFactory(eglBase.eglBaseContext,true,true))
.setVideoDecoderFactory(DefaultVideoDecoderFactory(eglBase.eglBaseContext))
.createPeerConnectionFactory()
// 本地视频采集
val videoCapturer = createCameraCapturer()
val videoSource = factory.createVideoSource(videoCapturer.isScreencast)
videoCapturer.initialize(
SurfaceTextureHelper.create("CaptureThread", eglBase.eglBaseContext),
context,
videoSource.capturerObserver
)
videoCapturer.startCapture(640,480,30)
localVideoTrack = factory.createVideoTrack("VIDEOTRACK", videoSource)
// 本地音频
val audioSource = factory.createAudioSource(MediaConstraints())
localAudioTrack = factory.createAudioTrack("AUDIOTRACK", audioSource)
// PeerConnection
val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
peerConnection = factory.createPeerConnection(
iceServers, this
)!!
// 添加本地流
val stream = factory.createLocalMediaStream("LOCALSTREAM")
stream.addTrack(localVideoTrack)
stream.addTrack(localAudioTrack)
peerConnection.addStream(stream)
// 信令回调
signalClient.connect()
}
fun startCall() {
peerConnection.createOffer(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription) {
peerConnection.setLocalDescription(this, desc)
signalClient.sendOffer(desc.description)
}
// 省略 onSetSuccess/onCreateFailure/onSetFailure
}, MediaConstraints())
}
fun onRemoteOffer(sdp: String) {
val desc = SessionDescription(SessionDescription.Type.OFFER, sdp)
peerConnection.setRemoteDescription(object : SdpObserver{
override fun onSetSuccess() {
peerConnection.createAnswer(object:SdpObserver{
override fun onCreateSuccess(answer: SessionDescription){
peerConnection.setLocalDescription(this, answer)
signalClient.sendAnswer(answer.description)
}
// 省略其余
}, MediaConstraints())
}
// 省略其余
}, desc)
}
fun onRemoteAnswer(sdp: String) {
val desc = SessionDescription(SessionDescription.Type.ANSWER, sdp)
peerConnection.setRemoteDescription(object : SdpObserver{
override fun onSetSuccess(){} // ignore
// ...
}, desc)
}
fun onRemoteIceCandidate(sdpMid:String, sdpMLineIndex:Int, candidate:String){
peerConnection.addIceCandidate(IceCandidate(sdpMid,sdpMLineIndex,candidate))
}
// PeerConnection.Observer 回调
override fun onIceCandidate(c: IceCandidate) {
signalClient.sendIceCandidate(c.sdpMid!!, c.sdpMLineIndex, c.sdp)
}
override fun onAddStream(stream: MediaStream) {
// 提取远端视频轨道
val remoteVideoTrack = stream.videoTracks[0]
// 由 Activity 注册并渲染
onRemoteStream?.invoke(remoteVideoTrack)
}
// 省略其他 Observer 方法空实现...
var onRemoteStream: ((VideoTrack)->Unit)? = null
private fun createCameraCapturer(): VideoCapturer {
val enumerator = Camera2Enumerator(context)
return enumerator.deviceNames
.firstNotNullOf { name ->
if (enumerator.isFrontFacing(name))
enumerator.createCapturer(name, null)
else null
}
}
}
// =======================================================
// 文件:MainActivity.kt
// 描述:Activity 层,UI 事件与渲染
// =======================================================
package com.example.webrtcdemo
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.example.webrtcdemo.databinding.ActivityMainBinding
import com.example.webrtcdemo.rtc.RTCClient
import com.example.webrtcdemo.signal.SignalClient
import org.webrtc.EglBase
class MainActivity : AppCompatActivity() {
private lateinit var b: ActivityMainBinding
private lateinit var rtcClient: RTCClient
private lateinit var signalClient: SignalClient
private val eglBase = EglBase.create()
override fun onCreate(s: Bundle?) {
super.onCreate(s)
b = ActivityMainBinding.inflate(layoutInflater)
setContentView(b.root)
// SurfaceViewRenderer 初始化
b.localView.init(eglBase.eglBaseContext, null)
b.remoteView.init(eglBase.eglBaseContext, null)
b.localView.setMirror(true)
// 信令与 RTCClient
signalClient = SignalClient("wss://your.signaling.server", object: SignalClient.Listener {
override fun onOffer(sdp: String) { rtcClient.onRemoteOffer(sdp) }
override fun onAnswer(sdp: String){ rtcClient.onRemoteAnswer(sdp) }
override fun onIceCandidate(mid: String, idx: Int, cand: String){
rtcClient.onRemoteIceCandidate(mid, idx, cand)
}
})
rtcClient = RTCClient(this, eglBase, signalClient)
rtcClient.onRemoteStream = { track ->
runOnUiThread {
track.addSink(b.remoteView)
}
}
// 本地流渲染
rtcClient.localVideoTrack.addSink(b.localView)
b.btnCall.setOnClickListener { rtcClient.startCall() }
b.btnHangup.setOnClickListener { signalClient.close(); finish() }
b.btnMute.setOnCheckedChangeListener { _, isChecked ->
rtcClient.localAudioTrack.setEnabled(!isChecked)
}
b.btnSwitch.setOnCheckedChangeListener { _, _ ->
rtcClient.switchCamera()
}
}
}
六、代码解读
-
SignalClient
-
基于 OkHttp WebSocket 实现信令交换,消息格式为 JSON,封装了 Offer/Answer/IceCandidate 三种类型。
-
-
RTCClient
-
初始化
PeerConnectionFactory
并创建本地视频轨道VideoCapturer
、VideoTrack
、AudioTrack
; -
创建
PeerConnection
并添加冰候选与媒体流; -
提供
startCall()
、onRemoteOffer()
、onRemoteAnswer()
、onRemoteIceCandidate()
四个方法完成完整协商; -
PeerConnection.Observer
回调中将远端流通过接口回调给 Activity;
-
-
MainActivity
-
SurfaceViewRenderer
在 UI 线程初始化并绑定 EglBase; -
按钮事件:呼叫、挂断、静音、切换摄像头;
-
本地轨道和远端轨道分别渲染到对应
SurfaceViewRenderer
;
-
-
摄像头捕获
-
使用
Camera2Enumerator
枚举、优先选取前置摄像头; -
通过
SurfaceTextureHelper
管理采集线程;
-
-
切换摄像头
-
在
RTCClient
中调用videoCapturer.switchCamera()
重建轨道并替换源;
-
七、性能与优化
-
网络适应:在
PeerConnection
创建约束时添加googCpuOveruseDetection
等字段,自动调整码率与分辨率; -
带宽管理:使用
setParameters()
动态调整视频编码码率; -
ICE 策略:自建 TURN 服务器,补齐 NAT 穿透;
-
日志与统计:注册
StatsObserver
,定期调用getStats()
获取丢包率、往返时延; -
资源释放:Activity 销毁前调用
eglBase.release()
、peerConnection.close()
、factory.dispose()
;
八、项目总结与扩展思路
本文手把手演示了如何在 Android 上用原生 WebRTC API 实现两端点点对点音视频通话,涵盖信令、协商、编解码与渲染全流程,并封装成可复用的模块。接下来可进一步:
-
多方通话(Mesh/SFU):使用媒体服务器(Medooze、Janus)管理多路流;
-
数据通道:利用 WebRTC DataChannel 实现文本消息或文件传输;
-
自定义编解码:切换到 H.265、VP9 或 AV1;
-
再包装成 SDK:封装成独立库,提供更高层 API;
九、FAQ
-
信令服务器如何部署?
-
最简单可用 Node.js +
ws
库,示例请参考官方 samples;
-
-
如何支持前台推送 TURN?
-
在 ICE 配置中添加 TURN 服务器信息并传入凭证;
-
-
为什么会出现黑屏?
-
确认
addSink()
时机,必须在SurfaceViewRenderer.init()
之后;
-
-
音视频不同步?
-
检查采集帧率与编码帧率是否一致,开启
Factory.Options.enableInternalTracer
分析;
-
-
如何打包 SO?
-
已在 Gradle 中指定
packagingOptions
,确保 .so 被正确打入 APK;
-