Android实现WebRTC的android端互连(附带源码)
Android实现 WebRTC 的 Android 端互连(端到端 P2P)
一、项目背景详细介绍
WebRTC(Web Real-Time Communication)是用于浏览器和移动端的实时音视频与数据传输技术。它内建了音视频采集、编码、点对点传输、NAT/防火墙穿透(ICE/STUN/TURN)等功能。移动端常见用例包括:实时通话、远程协作、低延迟直播互动、物联网视频监控等。
Android 端对接 WebRTC 通常包含两部分:
-
媒体和网络栈(使用 Google 的 WebRTC 原生库或封装库)
-
信令通道(应用自定义,负责 SDP/ICE 交换与房间管理)
本示例实现两部 Android 设备通过信令服务器互相发现并建立点对点音视频通话。
二、项目需求详细介绍
-
两台 Android 手机可发起/接收通话(点对点)。
-
支持摄像头视频采集与播放、麦克风音频采集与传输。
-
使用 WebSocket 作为信令通道(Node.js 示例服务器)。
-
支持前/后摄像头切换、开始/停止本地视频。
-
兼容现代 Android(AndroidX、Camera2/CameraX 可选)。
-
简单、可运行的参考实现,便于扩展(屏幕共享、数据通道、多人房间)。
三、相关技术详细介绍
-
WebRTC Native for Android(org.webrtc 包):PeerConnection、PeerConnectionFactory、MediaConstraints、VideoCapturer、SurfaceViewRenderer 等。
-
SurfaceViewRenderer:WebRTC 提供的高效视频渲染器。
-
OkHttp WebSocket / 原生 WebSocket:用于实现信令通道。
-
Node.js + ws:示例信令服务器,负责转发 SDP/ICE 与房间管理。
-
STUN / TURN:用于 NAT 穿透(示例使用公共 STUN,生产必须准备 TURN)。
四、实现思路详细介绍
-
信令服务器(Node.js):管理客户端连接、房间/会话、转发 SDP/ICE 消息。
-
Android 客户端:
-
初始化 PeerConnectionFactory(启用硬件编码/解码可选)。
-
创建本地媒体(VideoCapturer + AudioSource/VideoSource),渲染本地预览。
-
连接信令服务器,加入房间(或交换 role:caller/callee)。
-
发起或接收 offer/answer,交换 ICE 候选。
-
PeerConnection 建立后进行媒体传输与渲染。
-
-
错误处理:网络中断、重连、ICE 失败、权限拒绝等处理。
-
可选:使用 TURN 服务以保证在受限 NAT 下也能连通。
五、完整实现代码
/* ============================================================
文件:package.json (信令服务器依赖)
说明:Node.js 简单信令服务器,使用 ws 库。部署后使用 wss 更安全。
============================================================ */
/*
{
"name": "webrtc-signaling",
"version": "1.0.0",
"dependencies": {
"ws": "^8.0.0"
}
}
*/
/* ============================================================
文件:signaling-server.js (Node.js + ws 信令服务器示例)
说明:极简转发实现,生产应增加鉴权、房间管理、HTTPS/WSS 和错误处理
启动命令: node signaling-server.js
============================================================ */
///
// --- signaling-server.js ---
const WebSocket = require('ws');
const PORT = 8888;
const wss = new WebSocket.Server({ port: PORT });
console.log('Signaling server listening on ws://0.0.0.0:' + PORT);
// 简单房间映射:roomId -> Set of sockets
const rooms = new Map();
wss.on('connection', function connection(ws) {
ws.on('message', function incoming(message) {
try {
const msg = JSON.parse(message);
// Expect messages: {type: "join", room:"room1"} or {type:"offer"/"answer"/"ice", room:"room1", payload:...}
const { type, room, payload } = msg;
if (type === 'join') {
ws.room = room;
if (!rooms.has(room)) rooms.set(room, new Set());
rooms.get(room).add(ws);
console.log('Client joined room', room, 'size=', rooms.get(room).size);
// optionally notify others
return;
}
// forward message to other peers in same room
if (!room || !rooms.has(room)) return;
const peers = rooms.get(room);
peers.forEach(peer => {
if (peer !== ws && peer.readyState === WebSocket.OPEN) {
peer.send(JSON.stringify({ type, payload }));
}
});
} catch (e) {
console.error('Invalid message', e);
}
});
ws.on('close', function() {
const room = ws.room;
if (room && rooms.has(room)) {
rooms.get(room).delete(ws);
if (rooms.get(room).size === 0) rooms.delete(room);
}
});
});
console.log('Simple signaling server started.');
/* ============================================================
Android 客户端:Kotlin(单 Activity 示例 + 辅助类)
说明:
- 使用 org.webrtc 包(WebRTC Android SDK)
- 使用 OkHttp 的 WebSocket 实现信令客户端(也可用 Java-WebSocket)
- 请在 app 的 build.gradle 中添加相应依赖(见下)
============================================================ */
/*
在 app/build.gradle 中添加(示例):
// WebRTC Android: 请在实际项目中确认最新可用版本或引入预编译 aar/jitpack
implementation 'org.webrtc:google-webrtc:1.0.32006' // <- 示例,请查最新版
// OkHttp 用于 websocket
implementation 'com.squareup.okhttp3:okhttp:4.9.3'
// AndroidX
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
// 如果使用 CameraX 可加入 CameraX 依赖;但下面示例用 WebRTC 自带 Camera1Capturer(兼容性注意)
*/
/* ============================================================
文件:AndroidManifest.xml (权限)
============================================================ */
/*
<manifest ...>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application ...>
<activity android:name=".MainActivity"> ... </activity>
</application>
</manifest>
*/
/* ============================================================
文件:res/layout/activity_main.xml
============================================================ */
/// layout/activity_main.xml
/*
<?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/local_view"
android:layout_width="120dp"
android:layout_height="160dp"
android:layout_margin="8dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<!-- 远端渲染 -->
<org.webrtc.SurfaceViewRenderer
android:id="@+id/remote_view"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintBottom_toTopOf="@+id/controls"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"/>
<!-- 操作按钮 -->
<LinearLayout
android:id="@+id/controls"
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"
android:layout_margin="12dp">
<Button android:id="@+id/btn_join" android:text="Join" android:layout_margin="8dp"/>
<Button android:id="@+id/btn_call" android:text="Call" android:layout_margin="8dp"/>
<Button android:id="@+id/btn_hang" android:text="Hangup" android:layout_margin="8dp"/>
<Button android:id="@+id/btn_switch" android:text="Switch Cam" android:layout_margin="8dp"/>
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
*/
/* ============================================================
文件:MainActivity.kt
============================================================ */
/// MainActivity.kt
package com.example.webrtcdemo
import android.Manifest
import android.content.pm.PackageManager
import android.os.Bundle
import android.util.Log
import android.widget.Button
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import okhttp3.*
import okio.ByteString
import org.json.JSONObject
import org.webrtc.*
import java.util.*
class MainActivity : AppCompatActivity() {
companion object {
const val TAG = "WebRTC-Demo"
const val SIGNALING_SERVER_URL = "ws://your-server-ip:8888" // <-- 替换为你的信令服务器地址
const val ROOM_ID = "room1"
}
// UI
private lateinit var localView: SurfaceViewRenderer
private lateinit var remoteView: SurfaceViewRenderer
private lateinit var btnJoin: Button
private lateinit var btnCall: Button
private lateinit var btnHang: Button
private lateinit var btnSwitch: Button
// WebRTC
private lateinit var peerConnectionFactory: PeerConnectionFactory
private var peerConnection: PeerConnection? = null
private var localVideoTrack: VideoTrack? = null
private var localAudioTrack: AudioTrack? = null
private var videoCapturer: CameraVideoCapturer? = null
private lateinit var eglBase: EglBase
// Signaling
private val client = OkHttpClient()
private var webSocket: WebSocket? = null
// ICE servers (示例中使用公共 STUN,生产请配置自有 TURN)
private val iceServers = listOf(
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// bind views (ensure ids match layout)
localView = findViewById(R.id.local_view)
remoteView = findViewById(R.id.remote_view)
btnJoin = findViewById(R.id.btn_join)
btnCall = findViewById(R.id.btn_call)
btnHang = findViewById(R.id.btn_hang)
btnSwitch = findViewById(R.id.btn_switch)
// 权限申请
val permissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions()
) { perms ->
val granted = perms[Manifest.permission.CAMERA] == true && perms[Manifest.permission.RECORD_AUDIO] == true
if (granted) init()
else finish()
}
permissionLauncher.launch(arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO))
}
private fun init() {
// 初始化 EGL & SurfaceViewRenderer
eglBase = EglBase.create()
localView.init(eglBase.eglBaseContext, null)
localView.setZOrderMediaOverlay(true)
remoteView.init(eglBase.eglBaseContext, null)
// 初始化 PeerConnectionFactory
val options = PeerConnectionFactory.InitializationOptions.builder(this)
.setEnableInternalTracer(true)
.createInitializationOptions()
PeerConnectionFactory.initialize(options)
val encoderFactory = DefaultVideoEncoderFactory(
eglBase.eglBaseContext, /* enableIntelVp8Encoder */ true, /* enableH264HighProfile */ true
)
val decoderFactory = DefaultVideoDecoderFactory(eglBase.eglBaseContext)
val factoryOptions = PeerConnectionFactory.Options()
peerConnectionFactory = PeerConnectionFactory.builder()
.setOptions(factoryOptions)
.setVideoEncoderFactory(encoderFactory)
.setVideoDecoderFactory(decoderFactory)
.createPeerConnectionFactory()
// 创建本地媒体
startLocalMedia()
// 信令连接
connectSignaling()
// UI 按钮
btnJoin.setOnClickListener { joinRoom() }
btnCall.setOnClickListener { createOffer() }
btnHang.setOnClickListener { hangup() }
btnSwitch.setOnClickListener { switchCamera() }
}
private fun startLocalMedia() {
// 创建 Audio
val audioConstraints = MediaConstraints()
val audioSource = peerConnectionFactory.createAudioSource(audioConstraints)
localAudioTrack = peerConnectionFactory.createAudioTrack("ARDAMSa0", audioSource)
localAudioTrack?.setEnabled(true)
// 创建 VideoCapturer(示例使用 Camera1 捕获,若需要 Camera2 或 CameraX 请替换)
videoCapturer = createCameraCapturer()
val surfaceTextureHelper = SurfaceTextureHelper.create("CaptureThread", eglBase.eglBaseContext)
val videoSource = peerConnectionFactory.createVideoSource(false)
videoCapturer?.initialize(surfaceTextureHelper, this, videoSource.capturerObserver)
videoCapturer?.startCapture(1280, 720, 30)
localVideoTrack = peerConnectionFactory.createVideoTrack("ARDAMSv0", videoSource)
localVideoTrack?.addSink(localView)
}
private fun createCameraCapturer(): CameraVideoCapturer? {
// Try to use Camera2Capturer if needed; here try front camera first
val enumerator: CameraEnumerator = if (Camera2Enumerator.isSupported(this)) Camera2Enumerator(this) else Camera1Enumerator(true)
val deviceNames = enumerator.deviceNames
var capturer: CameraVideoCapturer? = null
// Prefer front
for (name in deviceNames) {
if (enumerator.isFrontFacing(name)) {
capturer = enumerator.createCapturer(name, null)
if (capturer != null) return capturer
}
}
// Else any
for (name in deviceNames) {
if (!enumerator.isFrontFacing(name)) {
capturer = enumerator.createCapturer(name, null)
if (capturer != null) return capturer
}
}
return null
}
private fun connectSignaling() {
val request = Request.Builder().url(SIGNALING_SERVER_URL).build()
webSocket = client.newWebSocket(request, object : WebSocketListener() {
override fun onOpen(ws: WebSocket, response: Response) {
Log.d(TAG, "WS open")
// join room
val joinMsg = JSONObject()
joinMsg.put("type", "join")
joinMsg.put("room", ROOM_ID)
ws.send(joinMsg.toString())
}
override fun onMessage(ws: WebSocket, text: String) {
Log.d(TAG, "WS message: $text")
runOnUiThread { handleSignalingMessage(text) }
}
override fun onMessage(ws: WebSocket, bytes: ByteString) {}
override fun onClosing(ws: WebSocket, code: Int, reason: String) { ws.close(1000, null) }
override fun onFailure(ws: WebSocket, t: Throwable, response: Response?) { Log.e(TAG, "WS failure", t) }
})
}
private fun handleSignalingMessage(message: String) {
try {
val json = JSONObject(message)
val type = json.optString("type")
val payload = json.optJSONObject("payload")
when (type) {
"offer" -> {
val sdp = payload!!.getString("sdp")
onRemoteOffer(sdp)
}
"answer" -> {
val sdp = payload!!.getString("sdp")
onRemoteAnswer(sdp)
}
"ice" -> {
val candidate = payload!!.getString("candidate")
val sdpMid = payload.getString("sdpMid")
val sdpMLineIndex = payload.getInt("sdpMLineIndex")
val iceCandidate = IceCandidate(sdpMid, sdpMLineIndex, candidate)
peerConnection?.addIceCandidate(iceCandidate)
}
}
} catch (e: Exception) {
Log.e(TAG, "handleSignalingMessage error", e)
}
}
private fun createPeerConnection() {
val rtcConfig = PeerConnection.RTCConfiguration(iceServers)
// optional config tweaks: tcpCandidatePolicy, bundlePolicy, rtcpMuxPolicy...
peerConnection = peerConnectionFactory.createPeerConnection(rtcConfig, object : PeerConnection.Observer {
override fun onSignalingChange(newState: PeerConnection.SignalingState) {}
override fun onIceConnectionChange(newState: PeerConnection.IceConnectionState) {}
override fun onIceConnectionReceivingChange(receiving: Boolean) {}
override fun onIceGatheringChange(newState: PeerConnection.IceGatheringState) {}
override fun onIceCandidate(candidate: IceCandidate) {
// forward ICE to signaling
val msg = JSONObject()
msg.put("type", "ice")
msg.put("room", ROOM_ID)
val payload = JSONObject()
payload.put("candidate", candidate.sdp)
payload.put("sdpMid", candidate.sdpMid)
payload.put("sdpMLineIndex", candidate.sdpMLineIndex)
msg.put("payload", payload)
webSocket?.send(msg.toString())
}
override fun onIceCandidatesRemoved(candidates: Array<IceCandidate>) {}
override fun onAddStream(stream: MediaStream) {}
override fun onRemoveStream(stream: MediaStream) {}
override fun onDataChannel(dc: DataChannel) {}
override fun onRenegotiationNeeded() {}
override fun onAddTrack(receiver: RtpReceiver, streams: Array<out MediaStream>) {
// handle remote track
val trackList = receiver.track()
val track = trackList
// receiver.track() can be VideoTrack or AudioTrack
if (track is VideoTrack) {
track.addSink(remoteView)
}
}
})
// add local tracks to pc
val stream = peerConnectionFactory.createLocalMediaStream("ARDAMS")
localAudioTrack?.let { stream.addTrack(it) }
localVideoTrack?.let { stream.addTrack(it) }
peerConnection?.addStream(stream)
}
private fun joinRoom() {
createPeerConnection()
// Do nothing else — wait for an offer or createOffer to call
}
private fun createOffer() {
if (peerConnection == null) createPeerConnection()
val constraints = MediaConstraints()
constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveAudio", "true"))
constraints.mandatory.add(MediaConstraints.KeyValuePair("OfferToReceiveVideo", "true"))
peerConnection?.createOffer(object : SdpObserver {
override fun onCreateSuccess(desc: SessionDescription) {
peerConnection?.setLocalDescription(object : SdpObserver {
override fun onSetSuccess() {
// send offer via signaling
val msg = JSONObject()
msg.put("type", "offer")
msg.put("room", ROOM_ID)
val payload = JSONObject()
payload.put("sdp", desc.description)
msg.put("payload", payload)
webSocket?.send(msg.toString())
}
override fun onSetFailure(p0: String?) { Log.e(TAG,"setLocalDescription fail $p0") }
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, desc)
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String) { Log.e(TAG, "createOffer fail $error") }
override fun onSetFailure(error: String) {}
}, constraints)
}
private fun onRemoteOffer(sdp: String) {
if (peerConnection == null) createPeerConnection()
val desc = SessionDescription(SessionDescription.Type.OFFER, sdp)
peerConnection?.setRemoteDescription(object : SdpObserver {
override fun onSetSuccess() {
// create answer
peerConnection?.createAnswer(object : SdpObserver {
override fun onCreateSuccess(answerDesc: SessionDescription) {
peerConnection?.setLocalDescription(object : SdpObserver {
override fun onSetSuccess() {
// send answer
val msg = JSONObject()
msg.put("type", "answer")
msg.put("room", ROOM_ID)
val payload = JSONObject()
payload.put("sdp", answerDesc.description)
msg.put("payload", payload)
webSocket?.send(msg.toString())
}
override fun onSetFailure(p0: String?) {}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, answerDesc)
}
override fun onSetSuccess() {}
override fun onCreateFailure(error: String) { Log.e(TAG,"createAnswer fail $error") }
override fun onSetFailure(error: String) {}
}, MediaConstraints())
}
override fun onSetFailure(p0: String?) {}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, desc)
}
private fun onRemoteAnswer(sdp: String) {
val desc = SessionDescription(SessionDescription.Type.ANSWER, sdp)
peerConnection?.setRemoteDescription(object : SdpObserver {
override fun onSetSuccess() {}
override fun onSetFailure(p0: String?) {}
override fun onCreateSuccess(p0: SessionDescription?) {}
override fun onCreateFailure(p0: String?) {}
}, desc)
}
private fun hangup() {
try {
peerConnection?.close()
peerConnection = null
} catch (e: Exception) { }
}
private fun switchCamera() {
if (videoCapturer is CameraVideoCapturer) {
(videoCapturer as CameraVideoCapturer).switchCamera(null)
}
}
override fun onDestroy() {
super.onDestroy()
webSocket?.close(1000, null)
videoCapturer?.stopCapture()
localVideoTrack?.dispose()
localAudioTrack?.dispose()
peerConnectionFactory.dispose()
localView.release()
remoteView.release()
eglBase.release()
}
// Simple SdpObserver helper
open class SdpObserver : SdpObserver {
override fun onCreateSuccess(sessionDescription: SessionDescription) {}
override fun onSetSuccess() {}
override fun onCreateFailure(s: String) {}
override fun onSetFailure(s: String) {}
}
}
火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。
更多推荐
所有评论(0)