iOS WebRTC 跑通了,但 Android 端的依赖、权限、Camera2 采集、前后摄切换,和 iOS 完全是两个世界。本文用 Claude Code 写 Android WebRTC 封装 + 跨平台信令协议对齐,让 iOS ↔ Android 1v1 通话真正打通。
1、Android WebRTC:不同的平台,不同的坑
“iOS WebRTC 用 GoogleWebRTC pod 就能跑,Android 同样用 Google 的 lib 怎么就开始崩溃了?”
Android WebRTC 的特殊挑战:
| # | 坑 | iOS 对比 | Android 特殊处理 |
|---|---|---|---|
| 1 | Camera 采集 | RTCCameraVideoCapturer 封装好 | 需自己处理 Camera2 API 或 CameraX |
| 2 | 前后摄切换 | capturer.camera.position 切换 | Camera2 需要重新 createCaptureSession |
| 3 | 音频采集 | AVAudioSession 自动管理 | AudioManager 需手动管理通信模式 |
| 4 | 权限请求 | Info.plist 声明即可 | 运行时动态请求 CAMERA + RECORD_AUDIO |
| 5 | EGL 上下文 | 自动管理 | 需手动创建 EGL 上下文(WebRTC 的硬编依赖) |
| 6 | ProGuard 混淆 | 不需要 | WebRTC 有大量 JNI 类不能混淆 |
2、Android WebRTC 架构
┌────────────────────────────────────────────────────┐
│ Android App │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────┐ │
│ │ Camera2/ │ │ AudioRecord │ │ Signaling │ │
│ │ CameraX 采集 │ │ 低延迟采集 │ │ Client │ │
│ └──────┬───────┘ └──────┬───────┘ └─────┬─────┘ │
│ │ │ │ │
│ ┌──────▼──────────────────▼─────────────────▼─────┐ │
│ │ PeerConnection │ │
│ │ 编码 (MediaCodec H.264/H.265 + Opus) │ │
│ │ ICE/DTLS/SRTP 传输 │ │
│ └──────┬──────────────────────────┬──────────────┘ │
│ │ │ │
│ ┌──────▼────────┐ ┌─────────────▼──────────────┐ │
│ │ SurfaceView │ │ AudioTrack (低延迟播放) │ │
│ │ 渲染 + EGL │ │ │ │
│ └──────────────┘ └─────────────────────────────┘ │
└────────────────────────────────────────────────────┘
3、Claude Code 生成 Android WebRTC 封装
3.1、Gradle 依赖配置
// build.gradle.kts (app)
dependencies {
// WebRTC 官方预编译库(Maven Central)
implementation("io.getstream:stream-webrtc-android:1.0.3")
// 或使用 Google 官方 Maven:
// implementation("org.webrtc:google-webrtc:1.0.32006")
// CameraX (简化摄像头采集)
implementation("androidx.camera:camera-core:1.4.0")
implementation("androidx.camera:camera-camera2:1.4.0")
implementation("androidx.camera:camera-lifecycle:1.4.0")
// WebSocket 信令
implementation("com.squareup.okhttp3:okhttp:4.12.0")
// 协程
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.0")
}
3.2、WebRTC 客户端封装
// WebRTCClient.kt
// Android WebRTC 客户端:封装采集、PeerConnection、渲染
import android.content.Context
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import org.webrtc.*
class WebRTCClient(
private val appContext: Context,
private val eglBase: EglBase = EglBase.create() // EGL 上下文(硬编必需)
) {
companion object {
private const val TAG = "WebRTCClient"
private const val VIDEO_WIDTH = 720
private const val VIDEO_HEIGHT = 1280
private const val VIDEO_FPS = 30
private const val AUDIO_TRACK_ID = "audio0"
private const val VIDEO_TRACK_ID = "video0"
private const val LOCAL_STREAM_ID = "localStream"
// ICE 服务器
val DEFAULT_ICE_SERVERS = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
}
// MARK: - 核心组件
private val factory: PeerConnectionFactory
private var peerConnection: PeerConnection? = null
// 音视频轨道
private var localVideoTrack: VideoTrack? = null
private var localAudioTrack: AudioTrack? = null
private var remoteVideoTrack: VideoTrack? = null
private var remoteAudioTrack: AudioTrack? = null
// 采集
private var videoCapturer: CameraVideoCapturer? = null
private var videoSource: VideoSource? = null
private var audioSource: AudioSource? = null
// 状态
private val _connectionState = MutableStateFlow(PeerConnection.PeerConnectionState.NEW)
val connectionState: StateFlow<PeerConnection.PeerConnectionState> = _connectionState.asStateFlow()
private val _iceCandidates = MutableSharedFlow<IceCandidate>()
val iceCandidates: SharedFlow<IceCandidate> = _iceCandidates.asSharedFlow()
// 回调
var onRemoteVideoTrack: ((VideoTrack) -> Unit)? = null
var onRemoteAudioTrack: ((AudioTrack) -> Unit)? = null
var onConnectionStateChange: ((PeerConnection.PeerConnectionState) -> Unit)? = null
var onError: ((Exception) -> Unit)? = null
var onLocalSdpGenerated: ((SessionDescription) -> Unit)? = null
// 标记
private var isInitialized = false
private var isCapturing = false
init {
// 初始化 PeerConnectionFactory
val options = PeerConnectionFactory.InitializationOptions.builder(appContext)
.setFieldTrials("WebRTC-H264HighProfile/Enabled/") // 启用 H.264 High Profile
.createInitializationOptions()
PeerConnectionFactory.initialize(options)
// 编码器工厂
val encoderFactory = DefaultVideoEncoderFactory(
eglBase.eglBaseContext,
true, // enableIntelVp8Encoder
true // enableH264HighProfile
)
val decoderFactory = DefaultVideoDecoderFactory(eglBase.eglBaseContext)
factory = PeerConnectionFactory.builder()
.setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(decoderFactory)
.setOptions(PeerConnectionFactory.Options().apply {
disableEncryption = false
disableNetworkMonitor = false // 启用网络状态监听
})
.createPeerConnectionFactory()
Log.d(TAG, "✅ PeerConnectionFactory 已初始化")
}
// MARK: - 采集
/** 启动本地摄像头 + 麦克风采集 */
fun startLocalCapture() {
if (isCapturing) {
Log.w(TAG, "⚠️ 采集已在进行中")
return
}
// 1. 创建音频源 + 轨道
val audioConstraints = MediaConstraints()
audioSource = factory.createAudioSource(audioConstraints)
localAudioTrack = factory.createAudioTrack(AUDIO_TRACK_ID, audioSource)
// 注意:Android 不自动激活音频,需手动在通话中设置
localAudioTrack?.setEnabled(true)
// 2. 创建视频源 + 采集器
videoSource = factory.createVideoSource(false) // isScreencast=false
videoCapturer = createCameraCapturer()
// 3. 启动摄像头采集
videoCapturer?.initialize(
SurfaceTextureHelper.create("CaptureThread", eglBase.eglBaseContext),
appContext,
videoSource?.capturerObserver
)
videoCapturer?.startCapture(VIDEO_WIDTH, VIDEO_HEIGHT, VIDEO_FPS)
// 4. 创建视频轨道
localVideoTrack = factory.createVideoTrack(VIDEO_TRACK_ID, videoSource)
isCapturing = true
Log.d(TAG, "✅ 本地采集已启动: ${VIDEO_WIDTH}x$VIDEO_HEIGHT @${VIDEO_FPS}fps")
}
/** 停止本地采集 */
fun stopLocalCapture() {
videoCapturer?.stopCapture()
videoCapturer?.dispose()
videoCapturer = null
videoSource?.dispose()
videoSource = null
localVideoTrack?.dispose()
localVideoTrack = null
localAudioTrack?.dispose()
localAudioTrack = null
audioSource?.dispose()
audioSource = null
isCapturing = false
Log.d(TAG, "⏹ 本地采集已停止")
}
// MARK: - PeerConnection
fun createPeerConnection() {
val config = PeerConnection.RTCConfiguration(DEFAULT_ICE_SERVERS).apply {
sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN // 关键!与 iOS 一致
continualGatheringPolicy = PeerConnection.ContinualGatheringPolicy.GATHER_ONCE
tcpCandidatePolicy = PeerConnection.TcpCandidatePolicy.ENABLED
}
peerConnection = factory.createPeerConnection(config, object : PeerConnection.Observer {
override fun onIceCandidate(candidate: IceCandidate?) {
candidate?.let { _iceCandidates.tryEmit(it) }
}
override fun onIceCandidatesRemoved(candidates: Array<out IceCandidate>?) {}
override fun onSignalingChange(state: PeerConnection.SignalingState?) {
Log.d(TAG, "Signaling: $state")
}
override fun onIceConnectionChange(state: PeerConnection.IceConnectionState?) {
Log.d(TAG, "ICE: $state")
}
override fun onConnectionChange(state: PeerConnection.PeerConnectionState?) {
state?.let {
_connectionState.value = it
onConnectionStateChange?.invoke(it)
}
}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
override fun onIceGatheringChange(state: PeerConnection.IceGatheringState?) {}
override fun onAddStream(stream: MediaStream?) {
Log.d(TAG, "远端流到达: ${stream?.id}")
stream?.videoTracks?.firstOrNull()?.let { videoTrack ->
remoteVideoTrack = videoTrack
onRemoteVideoTrack?.invoke(videoTrack)
}
stream?.audioTracks?.firstOrNull()?.let { audioTrack ->
remoteAudioTrack = audioTrack
onRemoteAudioTrack?.invoke(audioTrack)
}
}
override fun onRemoveStream(stream: MediaStream?) {
remoteVideoTrack = null
remoteAudioTrack = null
}
override fun onDataChannel(channel: DataChannel?) {}
override fun onRenegotiationNeeded() {
Log.d(TAG, "需要重新协商")
}
})
// 添加本地轨道到 PeerConnection
localAudioTrack?.let { peerConnection?.addTrack(it, listOf(LOCAL_STREAM_ID)) }
localVideoTrack?.let { peerConnection?.addTrack(it, listOf(LOCAL_STREAM_ID)) }
Log.d(TAG, "✅ PeerConnection 已创建")
}
// MARK: - SDP 协商
suspend fun createOffer(): SessionDescription = withContext(Dispatchers.IO) {
val pc = peerConnection ?: throw IllegalStateException("PeerConnection 未创建")
val sdp = suspendCancellableCoroutine<SessionDescription> { cont ->
pc.createOffer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let { pc.setLocalDescription(SimpleSdpObserver(), it) }
cont.resume(sdp ?: throw RuntimeException("SDP 为 null")) {}
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String?) {
cont.resumeWithException(RuntimeException("CreateOffer 失败: $error"))
}
override fun onSetFailure(error: String?) {}
}, MediaConstraints())
}
onLocalSdpGenerated?.invoke(sdp)
return@withContext sdp
}
suspend fun createAnswer(): SessionDescription = withContext(Dispatchers.IO) {
val pc = peerConnection ?: throw IllegalStateException("PeerConnection 未创建")
suspendCancellableCoroutine<SessionDescription> { cont ->
pc.createAnswer(object : SdpObserver {
override fun onCreateSuccess(sdp: SessionDescription?) {
sdp?.let { pc.setLocalDescription(SimpleSdpObserver(), it) }
cont.resume(sdp ?: throw RuntimeException("SDP 为 null")) {}
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String?) {
cont.resumeWithException(RuntimeException("CreateAnswer 失败: $error"))
}
override fun onSetFailure(error: String?) {}
}, MediaConstraints())
}
}
suspend fun setRemoteSdp(sdp: String, type: SessionDescription.Type) = withContext(Dispatchers.IO) {
val pc = peerConnection ?: throw IllegalStateException("PeerConnection 未创建")
val description = SessionDescription(type, sdp)
suspendCancellableCoroutine<Unit> { cont ->
pc.setRemoteDescription(SimpleSdpObserver(), description)
// setRemoteDescription 是异步的,在回调中的 onSetSuccess 确认
// 简化处理:使用延时等待
cont.resume(Unit) {}
// 生产环境应通过 Observer 回调或 CountDownLatch 等待
}
}
fun addRemoteIceCandidate(sdp: String, sdpMLineIndex: Int, sdpMid: String) {
val candidate = IceCandidate(sdpMid, sdpMLineIndex, sdp)
peerConnection?.addIceCandidate(candidate)
}
// MARK: - 摄像头切换
fun switchCamera() {
videoCapturer?.switchCamera(null) // null = 自动切换前后摄
Log.d(TAG, "📷 摄像头已切换")
}
// MARK: - 渲染
/** 绑定本地视频渲染 */
fun attachLocalVideo(view: SurfaceViewRenderer) {
view.init(eglBase.eglBaseContext, null)
view.setMirror(true) // 前置摄像头镜像
localVideoTrack?.addSink(view)
}
/** 绑定远端视频渲染 */
fun attachRemoteVideo(view: SurfaceViewRenderer) {
view.init(eglBase.eglBaseContext, null)
view.setMirror(false)
remoteVideoTrack?.addSink(view)
}
/** 设置音频输出(听筒/扬声器) */
fun setSpeakerOn(enabled: Boolean) {
val audioManager = appContext.getSystemService(Context.AUDIO_SERVICE) as android.media.AudioManager
audioManager.mode = android.media.AudioManager.MODE_IN_COMMUNICATION
audioManager.isSpeakerphoneOn = enabled
}
// MARK: - 释放
fun disconnect() {
peerConnection?.close()
peerConnection = null
stopLocalCapture()
factory.dispose()
eglBase.release()
Log.d(TAG, "🔌 已断开连接")
}
// MARK: - 工具
/** 创建摄像头采集器(优先用 Camera2) */
private fun createCameraCapturer(): CameraVideoCapturer? {
val enumerator = Camera2Enumerator(appContext)
val deviceNames = enumerator.deviceNames
// 优先前置摄像头
val frontCamera = deviceNames.firstOrNull { enumerator.isFrontFacing(it) }
val targetCamera = frontCamera ?: deviceNames.firstOrNull()
return targetCamera?.let { enumerator.createCapturer(it, null) }
}
}
// MARK: - 简化 SdpObserver
class SimpleSdpObserver : SdpObserver {
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onSetSuccess() {}
override fun onCreateFailure(p0: String?) {}
override fun onSetFailure(p0: String?) {}
}
3.3、Android 信令客户端
// SignalingClient.kt
// WebSocket 信令客户端,与 iOS 端 SignalingClient 协议完全对齐
import android.util.Log
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import okhttp3.*
import org.json.JSONObject
class SignalingClient(
private val serverUrl: String,
private val roomId: String,
private val clientId: String = java.util.UUID.randomUUID().toString()
) {
companion object {
private const val TAG = "Signaling"
private const val PING_INTERVAL_MS = 15_000L
}
private val client = OkHttpClient.Builder()
.pingInterval(PING_INTERVAL_MS, java.util.concurrent.TimeUnit.MILLISECONDS)
.build()
private var webSocket: WebSocket? = null
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// 事件
private val _events = MutableSharedFlow<SignalingEvent>()
val events: SharedFlow<SignalingEvent> = _events.asSharedFlow()
sealed class SignalingEvent {
data class Offer(val sdp: String) : SignalingEvent()
data class Answer(val sdp: String) : SignalingEvent()
data class IceCandidate(
val sdp: String,
val sdpMLineIndex: Int,
val sdpMid: String
) : SignalingEvent()
object Connected : SignalingEvent()
object Disconnected : SignalingEvent()
data class Error(val message: String) : SignalingEvent()
}
// MARK: - 连接
fun connect() {
val request = Request.Builder()
.url(serverUrl)
.addHeader("X-Room-Id", roomId)
.addHeader("X-Client-Id", clientId)
.build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
Log.d(TAG, "✅ WebSocket 已连接")
_events.tryEmit(SignalingEvent.Connected)
}
override fun onMessage(webSocket: WebSocket, text: String) {
handleMessage(text)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
Log.e(TAG, "❌ WebSocket 错误: ${t.message}")
_events.tryEmit(SignalingEvent.Error(t.message ?: "未知错误"))
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
Log.d(TAG, "🔌 WebSocket 已关闭: $code $reason")
_events.tryEmit(SignalingEvent.Disconnected)
}
})
}
// MARK: - 发送信令消息
fun sendOffer(sdp: String) {
sendMessage("offer", sdp)
}
fun sendAnswer(sdp: String) {
sendMessage("answer", sdp)
}
fun sendIceCandidate(sdp: String, sdpMLineIndex: Int, sdpMid: String) {
val candidateJson = JSONObject().apply {
put("sdp", sdp)
put("sdpMLineIndex", sdpMLineIndex)
put("sdpMid", sdpMid)
}
sendMessage("candidate", candidateJson.toString())
}
fun disconnect() {
webSocket?.close(1000, "正常关闭")
scope.cancel()
}
// MARK: - 内部
private fun sendMessage(type: String, payload: String) {
val json = JSONObject().apply {
put("type", type)
put("payload", payload)
}
webSocket?.send(json.toString())
}
private fun handleMessage(text: String) {
try {
val json = JSONObject(text)
val type = json.getString("type")
val payload = json.getJSONObject("payload")
when (type) {
"offer" -> _events.tryEmit(
SignalingEvent.Offer(payload.getString("sdp"))
)
"answer" -> _events.tryEmit(
SignalingEvent.Answer(payload.getString("sdp"))
)
"candidate" -> _events.tryEmit(
SignalingEvent.IceCandidate(
sdp = payload.getString("sdp"),
sdpMLineIndex = payload.getInt("sdpMLineIndex"),
sdpMid = payload.getString("sdpMid")
)
)
else -> Log.w(TAG, "未知消息类型: $type")
}
} catch (e: Exception) {
Log.e(TAG, "消息解析失败: ${e.message}")
}
}
}
3.4、完整通话流程(Android 端)
// CallViewModel.kt
// 完整通话流程编排
class CallViewModel(
private val appContext: Context
) : ViewModel() {
private val rtcClient = WebRTCClient(appContext)
private var signalingClient: SignalingClient? = null
private val _connectionState = MutableStateFlow("未连接")
val connectionState: StateFlow<String> = _connectionState.asStateFlow()
// MARK: - 发起通话(呼叫方)
fun startCall(
signalingUrl: String,
roomId: String,
localView: SurfaceViewRenderer,
remoteView: SurfaceViewRenderer
) {
viewModelScope.launch {
// 1. 连接信令
val signaling = SignalingClient(signalingUrl, roomId)
signalingClient = signaling
signaling.connect()
// 等待信令连接
signaling.events
.filterIsInstance<SignalingClient.SignalingEvent.Connected>()
.first()
// 2. 本地采集 + 渲染
rtcClient.startLocalCapture()
rtcClient.attachLocalVideo(localView)
// 3. 创建 PeerConnection
rtcClient.createPeerConnection()
// 4. 监听远端视频
rtcClient.onRemoteVideoTrack = { track ->
track.addSink(remoteView)
}
// 5. 创建并发送 Offer
val offer = rtcClient.createOffer()
signaling.sendOffer(offer.description)
// 6. 监听 Answer
signaling.events
.filterIsInstance<SignalingClient.SignalingEvent.Answer>()
.collect { answer ->
rtcClient.setRemoteSdp(answer.sdp, SessionDescription.Type.ANSWER)
}
// 7. 监听 ICE Candidate
signaling.events
.filterIsInstance<SignalingClient.SignalingEvent.IceCandidate>()
.collect { ice ->
rtcClient.addRemoteIceCandidate(ice.sdp, ice.sdpMLineIndex, ice.sdpMid)
}
// 8. 发送本地 ICE
rtcClient.iceCandidates.collect { candidate ->
signaling.sendIceCandidate(
candidate.sdp,
candidate.sdpMLineIndex,
candidate.sdpMid
)
}
// 9. 连接状态
rtcClient.connectionState.collect { state ->
_connectionState.value = state.name
}
// 10. 开启扬声器
rtcClient.setSpeakerOn(true)
}
}
fun switchCamera() {
rtcClient.switchCamera()
}
override fun onCleared() {
super.onCleared()
rtcClient.disconnect()
signalingClient?.disconnect()
}
}
4、跨平台协议对齐清单
确保 iOS ↔ Android 通话可互通的检查表:
| 检查项 | iOS 配置 | Android 配置 | 对齐方式 |
|---|---|---|---|
| SDP 语义 | .unifiedPlan | UNIFIED_PLAN | 双端必须一致 |
| 视频编码 | H.264 High Profile | H.264 High Profile | 启用 H264HighProfile |
| 音频编码 | Opus (WebRTC 默认) | Opus (WebRTC 默认) | 默认一致 ✅ |
| ICE 策略 | .gatherOnce | GATHER_ONCE | 必须一致 |
| DTLS | 默认启用 | 默认启用 | 默认一致 ✅ |
| 信令协议 | JSON via WebSocket | JSON via WebSocket | 字段名对齐 |
| ICE Candidate 格式 | sdp + sdpMLineIndex + sdpMid | 同上 | 必须字段一一对应 |
4.1、信令协议规范(跨平台统一)
// Offer/Answer 消息
{"type": "offer", "payload": {"sdp": "v=0\r\no=..."}}
// ICE Candidate 消息
{"type": "candidate", "payload": {
"sdp": "candidate:...",
"sdpMLineIndex": 0,
"sdpMid": "0"
}}
5、Claude Code 审查记录
| AI 输出问题 | 平台 | 修正 |
|---|---|---|
Android 未创建 EglBase | Android | 硬编依赖 EGL 上下文,加了 EglBase.create() 初始化 |
Android setRemoteDescription 没等回调就返回 | Android | 生产环境需通过 CountDownLatch 或协程等待 onSetSuccess |
| 跨平台 SDP 语义不一致 | 两方 | iOS 默认 Unified Plan,Android 需显式设置 UNIFIED_PLAN |
| Android 前置摄像头没镜像 | Android | SurfaceViewRenderer.setMirror(true) |
CameraVideoCapturer 初始化缺少 CapturerObserver | Android | 补充 videoSource?.capturerObserver 作为参数 |
6、跨平台通话效果
| 指标 | iOS → iOS | Android → Android | iOS ↔ Android |
|---|---|---|---|
| 首帧时间 | 0.8-1.2s | 1.0-1.5s | 1.2-2.0s |
| ICE 连接时间 | 0.5-1s | 0.8-1.5s | 0.8-2s |
| 音频延迟 | 80-120ms | 100-150ms | 100-180ms |
| 视频码率 (720p) | 1.5-2.5 Mbps | 1.5-2.5 Mbps | 1.5-2.5 Mbps |
| CPU 占用 (编码) | 8-15% | 10-20% | — |
学习和提升音视频开发技术,欢迎你加入我们的知识星球

版权声明:本文内容转自互联网,本文观点仅代表作者本人。本站仅提供信息存储空间服务,所有权归原作者所有。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至1393616908@qq.com 举报,一经查实,本站将立刻删除。