Android实现 WebRTC 的 Android 端互连(端到端 P2P)

一、项目背景详细介绍

WebRTC(Web Real-Time Communication)是用于浏览器和移动端的实时音视频与数据传输技术。它内建了音视频采集、编码、点对点传输、NAT/防火墙穿透(ICE/STUN/TURN)等功能。移动端常见用例包括:实时通话、远程协作、低延迟直播互动、物联网视频监控等。

Android 端对接 WebRTC 通常包含两部分:

  1. 媒体和网络栈(使用 Google 的 WebRTC 原生库或封装库)

  2. 信令通道(应用自定义,负责 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)。


四、实现思路详细介绍

  1. 信令服务器(Node.js):管理客户端连接、房间/会话、转发 SDP/ICE 消息。

  2. Android 客户端

    • 初始化 PeerConnectionFactory(启用硬件编码/解码可选)。

    • 创建本地媒体(VideoCapturer + AudioSource/VideoSource),渲染本地预览。

    • 连接信令服务器,加入房间(或交换 role:caller/callee)。

    • 发起或接收 offer/answer,交换 ICE 候选。

    • PeerConnection 建立后进行媒体传输与渲染。

  3. 错误处理:网络中断、重连、ICE 失败、权限拒绝等处理。

  4. 可选:使用 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) {}
    }
}

Logo

火山引擎视频云技术社区,是面向 AI 音视频开发者的技术交流平台。这里汇聚源自抖音、豆包等亿级 DAU 产品的 RTC、直播、点播、AI 媒体处理、音视频互动技术,提供接入指南、最佳实践、性能调优、场景案例、Demo 代码、开源项目、白皮书和 API 文档。社区汇聚官方工程师与一线开发者,为 AI 视频通话、数字人、AI 视频处理等应用的开发与落地提供技术支持。

更多推荐