项目总览:四大步骤

  1. 【第一步】搭建并测试信令服务器:建立一个通信中转站,让APP和WEB可以互相传递消息。

  2. 【第二步】构建基础的WEB接收端:创建一个能连接信令服务器,并准备好接收视频流的网页。

  3. 【第三步】构建基础的APP发送端:创建一个能连接信令服务器,并准备好发送视频流的APP。

  4. 【第四步】联调与实现完整WebRTC流程:将APP的屏幕画面真正通过WebRTC传输到WEB端并播放。

第一步:搭建并测试信令服务器

目标: 创建一个简单的Node.js服务器,它能接收客户端(未来是APP和WEB)的连接,并将一个客户端发来的消息转发给另一个。我们将用两个浏览器标签页来模拟APP和WEB,以验证服务器功能是否正常。

技术栈:

  • Node.js: JavaScript运行环境。请确保您的电脑已安装。可以在命令行输入 node -v 查看。

  • Express: 一个流行的Node.js Web框架,用于快速搭建HTTP服务。

  • Socket.IO: 一个强大的实时通信库,封装了WebSocket,让双向通信变得非常简单。

    操作指南

    在您喜欢的位置(例如桌面)打开命令行工具(如Terminal, PowerShell, CMD),然后执行以下命令:

    Generated bash
    # 1. 创建一个新文件夹作为项目目录
    mkdir webrtc-project
    cd webrtc-project
    
    # 2. 在项目内再为服务器创建一个子目录
    mkdir signaling-server
    cd signaling-server
    
    # 3. 初始化Node.js项目,生成 package.json 文件
    npm init -y
    
    # 4. 安装所需的库
    npm install express socket.io

现在你的 signaling-server 文件夹里应该有 node_modules 文件夹和 package.json 文件了。

在 signaling-server 文件夹里,创建一个名为 server.js 的文件,然后将以下代码复制进去:

Generated javascript

// 引入所需模块
const express = require('express');
const http = require('http');
const { Server } = require("socket.io");

// 初始化
const app = express();
const httpServer = http.createServer(app);
const io = new Server(httpServer, {
    // 配置CORS,允许来自任何源的连接,方便开发
    cors: {
        origin: "*",
    }
});

const PORT = 3000; // 我们使用3000端口

// 当有新的客户端连接时触发
io.on('connection', (socket) => {
    console.log(`[连接成功] 一个新客户端连接了,ID: ${socket.id}`);

    // 监听客户端发来的 'message' 事件
    socket.on('message', (data) => {
        // 打印收到的消息
        console.log(`[收到消息] 从 ${socket.id} 收到消息:`, data);
        
        // **核心逻辑:将消息广播给除了自己以外的所有其他客户端**
        socket.broadcast.emit('message', data);
        console.log(`[转发消息] 已将消息广播给其他客户端`);
    });

    // 当客户端断开连接时触发
    socket.on('disconnect', () => {
        console.log(`[连接断开] 客户端 ${socket.id} 已断开连接`);
    });
});

// 让服务器监听指定端口
httpServer.listen(PORT, () => {
    console.log(`信令服务器已启动,正在监听端口: http://localhost:${PORT}`);
    console.log('请确保您的防火墙已允许3000端口的TCP连接。');
});

代码解析:

  • 我们创建了一个HTTP服务器,并附加了Socket.IO。

  • io.on('connection', ...): 这是入口,每当有新的浏览器或APP连接上来,这里的代码就会执行一次。

  • socket.on('message', ...): 我们定义了一个名为 message 的事件。当任何一个客户端发送 message 事件时,服务器就会收到。

  • socket.broadcast.emit('message', data): 这是关键!broadcast 意味着“广播”,它会将收到的 data 转发给除发送者之外的所有其他连接者。这正是我们需要的“信鸽”功能。

  1. 获取本机IP地址:

    • Windows: 在命令行输入 ipconfig,找到“无线局域网适配器 WLAN”或“以太网适配器”下的 "IPv4 地址"。

    • macOS: 在命令行输入 ifconfig | grep "inet ",找到 en0 (Wi-Fi) 或 en1 (以太网) 对应的IP地址。

    • 假设你的IP地址是 192.168.1.10。

  2. 启动服务器:
    在 signaling-server 目录下,运行命令:

    Generated bash
    node server.js

如果一切顺利,你会看到:

Generated code

信令服务器已启动,正在监听端口: http://localhost:3000
请确保您的防火墙已允许3000端口的TCP连接。

创建测试网页:
为了验证服务器功能,我们快速创建一个HTML文件。在 webrtc-project 根目录 (与 signaling-server 同级) 下,创建一个名为 test.html 的文件,并粘贴以下代码:

Generated html

<!DOCTYPE html>
<html>
<head>
    <title>信令服务器测试</title>
</head>
<body>
    <h1>信令服务器测试</h1>
    <p>你的ID: <span id="myId"></span></p>
    <input type="text" id="messageInput" placeholder="输入要发送的消息">
    <button onclick="sendMessage()">发送消息</button>
    <h2>收到的消息:</h2>
    <div id="messages"></div>

    <!-- 引入Socket.IO客户端库 -->
    <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
    <script>
        // !!! 重要:请将这里的IP地址换成你自己的电脑IP地址 !!!
        const SERVER_IP = '192.168.1.10'; 
        const socket = io(`http://${SERVER_IP}:3000`);

        socket.on('connect', () => {
            console.log('成功连接到信令服务器! My ID is', socket.id);
            document.getElementById('myId').innerText = socket.id;
        });

        socket.on('message', (data) => {
            console.log('收到一条广播消息:', data);
            const msgDiv = document.createElement('div');
            msgDiv.innerText = `收到消息: ${data.text}`;
            document.getElementById('messages').appendChild(msgDiv);
        });

        function sendMessage() {
            const input = document.getElementById('messageInput');
            const message = input.value;
            if (message) {
                console.log('发送消息:', message);
                socket.emit('message', { text: message });
                input.value = '';
            }
        }
    </script>
</body>
</html>
  1. 注意: 一定要把代码中的 SERVER_IP 变量值改成你上一步查到的实际IP地址

  2. 进行测试:

    • 在你的电脑上,用浏览器(推荐Chrome)打开 test.html 文件(可以直接双击文件打开)。

    • 再打开一个浏览器标签页,同样访问 test.html 文件。

    • 现在你有了两个客户端。

    • 在其中一个页面的输入框里输入 "你好",点击“发送消息”。

    • 观察另一个页面,你应该会看到 "收到的消息: 你好"。

    • 同时,观察你运行node server.js的那个命令行窗口,你应该能看到服务器打印出的连接、接收和转发日志。

如果测试成功,恭喜你!项目最基础、也是最关键的通信骨架已经搭建完毕。 我们的“信鸽”已经准备好为APP和WEB传递密信了。

如果遇到问题(例如网页无法连接),请检查:

  • test.html 里的IP地址是否正确。

  • 你的电脑防火墙是否阻止了3000端口的访问。

  • 手机和电脑是否连接在同一个Wi-Fi网络下。

完成这一步后我们就可以进入第二步:构建基础的WEB接收端了。

第二步:构建基础的WEB接收端

目标: 创建一个网页,它能:

  1. 连接到我们刚搭建的信令服务器。

  2. 初始化WebRTC的核心对象 RTCPeerConnection。

  3. 编写好处理信令(Offer, Answer, Candidate)的逻辑,处于“随时准备接收视频”的状态。

  4. 在收到远程视频流时,能将其播放出来。

在这一步,我们还不会真正收到来自APP的视频,但我们会把所有的“管道”和“接口”都准备好。

操作指南

在你的项目根目录 webrtc-project 下,创建一个新的文件夹 web-client。在 web-client 文件夹里,创建两个文件:

  • index.html (网页结构)

  • main.js (JavaScript逻辑)

Generated code

webrtc-project/
├── signaling-server/
│   ├── ...
├── web-client/
│   ├── index.html   <-- 新建
│   └── main.js      <-- 新建
└── test.html

将以下代码粘贴到 web-client/index.html 文件中:

Generated html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebRTC 监控接收端</title>
    <style>
        body { font-family: sans-serif; background-color: #f0f0f0; margin: 0; padding: 20px; }
        h1 { text-align: center; color: #333; }
        #video-container {
            max-width: 800px;
            margin: 20px auto;
            background-color: black;
            border: 1px solid #ccc;
            box-shadow: 0 0 10px rgba(0,0,0,0.1);
        }
        video {
            width: 100%;
            height: auto;
            display: block;
        }
        #status {
            text-align: center;
            margin-top: 10px;
            font-size: 1.2em;
            color: #666;
        }
    </style>
</head>
<body>

    <h1>WebRTC 监控接收端</h1>

    <div id="video-container">
        <!-- 视频将在这里播放 -->
        <video id="remoteVideo" autoplay playsinline></video>
    </div>

    <p id="status">正在等待APP连接...</p>

    <!-- 引入Socket.IO客户端库 -->
    <script src="https://cdn.socket.io/4.7.2/socket.io.min.js"></script>
    <!-- 引入我们自己的JS逻辑 -->
    <script src="main.js"></script>

</body>
</html>

代码解析:

  • 我们创建了一个简单的布局,包含一个标题、一个用于显示状态的段落 <p id="status">。

  • 最核心的是 <video id="remoteVideo"> 标签。autoplay 属性让视频在有数据时自动播放,playsinline 在移动设备上很重要,能让视频在页面内播放而不是全屏。

  • 最后,我们引入了 socket.io.js 和我们即将编写的 main.js。

这是本步骤的核心。将以下代码粘贴到 web-client/main.js 文件中:

Generated javascript

// --- 1. 配置 ---
// !!! 重要:请将这里的IP地址换成你自己的电脑IP地址 !!!
const SERVER_IP = '192.168.1.10'; 
const SERVER_PORT = 3000;

// 获取HTML元素
const remoteVideo = document.getElementById('remoteVideo');
const statusDiv = document.getElementById('status');

// WebRTC变量
let peerConnection;

// --- 2. 初始化信令连接 ---
const socket = io(`http://${SERVER_IP}:${SERVER_PORT}`);

socket.on('connect', () => {
    console.log('成功连接到信令服务器!');
    statusDiv.textContent = '已连接到信令服务器,等待APP发起连接...';
});

// --- 3. 初始化WebRTC ---
function createPeerConnection() {
    console.log('创建 PeerConnection...');
    try {
        // 创建 RTCPeerConnection 对象
        peerConnection = new RTCPeerConnection({
            iceServers: [
                { urls: 'stun:stun.l.google.com:19302' } // 使用Google的公共STUN服务器
            ]
        });

        // 监听ICE Candidate事件,当找到候选者时,通过信令服务器发送给对方
        peerConnection.onicecandidate = (event) => {
            if (event.candidate) {
                console.log('发送 ICE candidate:', event.candidate);
                socket.emit('message', { type: 'candidate', payload: event.candidate });
            }
        };

        // 监听远程视频流轨道事件,这是最关键的一步
        peerConnection.ontrack = (event) => {
            console.log('接收到远程视频轨道!');
            statusDiv.textContent = '已接收到视频流!';
            if (remoteVideo.srcObject !== event.streams[0]) {
                remoteVideo.srcObject = event.streams[0];
            }
        };

        console.log('PeerConnection 创建成功!');

    } catch (e) {
        console.error('创建 PeerConnection 失败:', e);
        statusDiv.textContent = 'WebRTC 初始化失败,请检查浏览器支持情况。';
    }
}

// --- 4. 监听信令消息 ---
socket.on('message', async (data) => {
    console.log('收到信令消息:', data);
    const { type, payload } = data;

    // 确保在处理Offer之前,peerConnection已经创建
    if (!peerConnection && type === 'offer') {
        createPeerConnection();
    }

    switch (type) {
        case 'offer':
            // 收到来自APP的Offer
            console.log('收到 Offer');
            statusDiv.textContent = '收到连接请求,正在应答...';
            await peerConnection.setRemoteDescription(new RTCSessionDescription(payload));
            
            console.log('创建 Answer...');
            const answer = await peerConnection.createAnswer();
            await peerConnection.setLocalDescription(answer);

            console.log('发送 Answer');
            socket.emit('message', { type: 'answer', payload: answer });
            statusDiv.textContent = '已发送应答,等待连接建立...';
            break;

        case 'candidate':
            // 收到来自APP的ICE Candidate
            if (peerConnection) {
                console.log('收到 Candidate,正在添加...');
                await peerConnection.addIceCandidate(new RTCIceCandidate(payload));
            }
            break;

        default:
            console.log('收到未知类型的消息:', type);
            break;
    }
});

// --- 5. 页面加载时自动初始化 ---
// 初始时,我们先不创建PeerConnection,等待APP发起Offer时再创建。
// 这样可以确保APP是呼叫方(Caller),WEB是接收方(Callee)。
console.log('WEB端已准备就绪,等待信令消息...');

代码解析:

  • 配置 (1): 和上一步一样,务必修改 SERVER_IP 为你的电脑IP地址。

  • 信令连接 (2): 使用socket.io连接到服务器,并更新页面状态。

  • 初始化WebRTC (3):

    • createPeerConnection() 函数是核心。它创建RTCPeerConnection对象。

    • iceServers: 我们配置了一个公共的STUN服务器。STUN服务器帮助设备发现自己的公网IP和端口,这对于NAT穿透至关重要(即使在局域网内也很有用)。

    • onicecandidate: 当WebRTC找到一个可能的网络路径(ICE候选者)时,这个事件会被触发。我们必须把它通过信令服务器发送给对方。

    • ontrack: 这是魔法发生的地方! 当P2P连接建立,对方的视频流数据到达时,这个事件被触发。我们把接收到的流 event.streams[0] 赋值给 <video> 元素的 srcObject 属性,视频就会开始播放。

  • 监听信令消息 (4): 这是与信令服务器交互的核心逻辑。

    • 当收到 offer 类型的消息时,说明APP想和我们建立连接。我们把它设为“远程描述”,然后创建自己的“应答”(Answer),设为“本地描述”,再把这个Answer发回给APP。

    • 当收到 candidate 类型的消息时,我们把它添加到peerConnection中,帮助WebRTC建立网络连接。

  • 页面加载 (5): 我们让页面处于被动等待状态,一切由APP端发起。

  1. 确保信令服务器仍在运行。如果没有,请回到第一步的目录 signaling-server 并运行 node server.js。

  2. 用HTTP方式打开网页。直接双击index.html文件可能因安全策略导致JS无法正常工作。我们需要一个简单的HTTP服务器来托管web-client文件夹。

    • 方法一 (推荐): 如果你电脑上有Node.js,可以安装一个全局的HTTP服务器。

      Generated bash
      # 只需要安装一次
      npm install -g http-server
      
      # 进入 web-client 文件夹
      cd web-client 
      
      # 启动HTTP服务器
      http-server

      Use code with caution.Bash

      命令行会显示一个地址,通常是 http://127.0.0.1:8080 或 http://<你的IP>:8080。

    • 方法二: 如果你使用VS Code,可以安装一个叫 "Live Server" 的插件,右键点击index.html文件,选择 "Open with Live Server"。

  3. 在浏览器中访问页面。使用上一步得到的地址(如http://127.0.0.1:8080)打开网页。

  4. 打开浏览器开发者工具 (在Chrome中按F12或右键->检查),切换到 "Console" (控制台) 标签页。

你应该能看到:

  • 网页上显示:“已连接到信令服务器,等待APP发起连接...”。

  • 在控制台中,打印出:“成功连接到信令服务器!”和“WEB端已准备就绪,等待信令消息...”。

现在,你的WEB端已经像一个准备就绪的雷达站,静静地扫描着天空,等待着APP这个“飞机”的信号。

我们已经完成了第二步。这个网页现在已经具备了接收和播放WebRTC视频流的全部能力。下一步,我们将着手构建发射信号的“飞机”——APP端。

请确认WEB端运行正常后我们就可以开始最复杂的第三步:构建基础的APP发送端了。

第三步:构建基础的APP发送端 (Android)

目标: 创建一个Android应用,它能:

  1. 集成WebRTC和Socket.IO库。

  2. 请求屏幕录制权限。

  3. 连接到我们的信令服务器。

  4. 初始化WebRTC,并能创建和发送Offer。

  5. 处理来自WEB端的Answer和Candidate。

核心: 在这一步,我们先不急于将屏幕画面真正接入WebRTC,而是先搭建好APP端的WebRTC框架,并确保它能与WEB端成功完成信令交互。这叫“打通信令链路”。

技术栈:

  • Android Studio (请确保已安装)

  • Java 或 Kotlin (本教程将以Java为例,逻辑与Kotlin相通)

  • Google官方WebRTC库

  • Socket.IO官方Java客户端库

    操作指南
  • 打开Android Studio。

  • 选择 "File" -> "New" -> "New Project..."。

  • 选择 "Empty Views Activity" 模板,点击 "Next"。

  • 填写项目信息:

    • Name: WebRTC Sender (或其他你喜欢的名字)

    • Package name: (例如 com.example.webrtcsender)

    • LanguageJava

    • Minimum SDK: 选择 API 21: Android 5.0 (Lollipop) 或更高版本。WebRTC和屏幕录制需要这个版本以上。

  • 打开项目左侧导航栏 Gradle Scripts 下的 build.gradle (Module :app) 文件,在 dependencies 代码块中添加以下两行:

    Generated groovy
    dependencies {
        // ... 其他默认依赖 ...
    
        // 添加WebRTC库
        implementation 'org.webrtc:google-webrtc:1.0.32006' // 使用一个稳定版本
    
        // 添加Socket.IO客户端库
        implementation ('io.socket:socket.io-client:2.0.1') {
            // 排除它自带的 okhttp,避免与Android项目中的版本冲突
            exclude group: 'org.json', module: 'json'
        }
    }
  • 点击 "Finish",等待项目构建完成。

    添加后,Android Studio顶部会提示 "Sync Now",点击它,等待库下载完成。

    打开 app/src/main/AndroidManifest.xml 文件,在 <manifest> 标签内,<application> 标签外,添加必要的权限:

    Generated xml
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <!-- 如果需要用摄像头测试,可以加上相机权限 -->
    <!-- <uses-permission android:name="android.permission.CAMERA" /> -->
    <!-- <uses-permission android:name="android.permission.RECORD_AUDIO" /> -->

同时,为了允许与本地的http协议信令服务器通信(默认高版本Android禁止),需要在 <application> 标签内添加 android:usesCleartextTraffic="true":

Generated xml

<application
    android:allowBackup="true"
    android:icon="@mipmap/ic_launcher"
    android:label="@string/app_name"
    android:roundIcon="@mipmap/ic_launcher_round"
    android:supportsRtl="true"
    android:theme="@style/Theme.WebRTCSender"
    android:usesCleartextTraffic="true">  <!-- 添加这一行 -->
    
    <activity ...>
        ...
    </activity>
</application>

打开 app/src/main/res/layout/activity_main.xml,我们创建一个简单的按钮来触发连接。将内容替换为:

Generated 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"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <Button
        android:id="@+id/start_button"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="开始连接"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

这是最复杂的部分。我们将把所有逻辑都放在 MainActivity.java 中。请仔细阅读注释,理解每一步的作用。

将 MainActivity.java 的全部内容替换为以下代码:

Generated java

package com.example.webrtcsender; // 请确保这是你自己的包名

import androidx.appcompat.app.AppCompatActivity;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import org.json.JSONException;
import org.json.JSONObject;
import org.webrtc.IceCandidate;
import org.webrtc.MediaConstraints;
import org.webrtc.PeerConnection;
import org.webrtc.PeerConnectionFactory;
import org.webrtc.SdpObserver;
import org.webrtc.SessionDescription;
import java.net.URISyntaxException;
import java.util.ArrayList;
import java.util.List;
import io.socket.client.IO;
import io.socket.client.Socket;

public class MainActivity extends AppCompatActivity {

    private static final String TAG = "MainActivity";
    // !!! 重要:请将这里的IP地址换成你自己的电脑IP地址 !!!
    private static final String SERVER_URL = "http://192.168.1.10:3000";

    private Socket socket;
    private PeerConnection peerConnection;
    private PeerConnectionFactory peerConnectionFactory;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 1. 初始化WebRTC
        initWebRTC();

        // 2. 找到按钮并设置点击事件
        Button startButton = findViewById(R.id.start_button);
        startButton.setOnClickListener(v -> {
            // 3. 连接信令服务器
            connectToSignalingServer();
        });
    }

    private void initWebRTC() {
        // 初始化PeerConnectionFactory
        PeerConnectionFactory.initialize(PeerConnectionFactory.InitializationOptions.builder(this).createInitializationOptions());
        peerConnectionFactory = PeerConnectionFactory.builder().createPeerConnectionFactory();
    }

    private void connectToSignalingServer() {
        try {
            // 4. 创建Socket实例并连接
            socket = IO.socket(SERVER_URL);
            
            // 5. 设置信令事件监听
            socket.on(Socket.EVENT_CONNECT, args -> {
                Log.d(TAG, "成功连接到信令服务器!");
                // 连接成功后,可以开始创建PeerConnection并发起呼叫
                runOnUiThread(() -> {
                    call();
                });
            }).on("message", args -> {
                // 监听从WEB端发来的消息 (Answer, Candidate)
                JSONObject data = (JSONObject) args[0];
                try {
                    String type = data.getString("type");
                    Log.d(TAG, "收到消息,类型: " + type);

                    if (type.equals("answer")) {
                        handleAnswer(data.getJSONObject("payload"));
                    } else if (type.equals("candidate")) {
                        handleRemoteCandidate(data.getJSONObject("payload"));
                    }
                } catch (JSONException e) {
                    e.printStackTrace();
                }
            }).on(Socket.EVENT_DISCONNECT, args -> {
                Log.d(TAG, "与信令服务器断开连接");
            });

            // 启动连接
            socket.connect();

        } catch (URISyntaxException e) {
            e.printStackTrace();
        }
    }

    private void call() {
        Log.d(TAG, "开始呼叫流程...");
        
        // 6. 创建PeerConnection
        List<PeerConnection.IceServer> iceServers = new ArrayList<>();
        iceServers.add(PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer());
        
        peerConnection = peerConnectionFactory.createPeerConnection(iceServers, new PeerConnection.Observer() {
            @Override
            public void onIceCandidate(IceCandidate iceCandidate) {
                // 当本地找到ICE候选者时,发送给对方
                Log.d(TAG, "onIceCandidate: " + iceCandidate);
                try {
                    JSONObject payload = new JSONObject();
                    payload.put("type", "candidate");
                    payload.put("label", iceCandidate.sdpMLineIndex);
                    payload.put("id", iceCandidate.sdpMid);
                    payload.put("candidate", iceCandidate.sdp);

                    JSONObject message = new JSONObject();
                    message.put("type", "candidate");
                    message.put("payload", payload);
                    
                    socket.emit("message", message);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            // 其他回调暂时忽略
            @Override public void onSignalingChange(PeerConnection.SignalingState signalingState) {}
            @Override public void onIceConnectionChange(PeerConnection.IceConnectionState iceConnectionState) {}
            @Override public void onIceConnectionReceivingChange(boolean b) {}
            @Override public void onIceGatheringChange(PeerConnection.IceGatheringState iceGatheringState) {}
            @Override public void onIceCandidatesRemoved(IceCandidate[] iceCandidates) {}
            @Override public void onAddStream(org.webrtc.MediaStream mediaStream) {}
            @Override public void onRemoveStream(org.webrtc.MediaStream mediaStream) {}
            @Override public void onDataChannel(org.webrtc.DataChannel dataChannel) {}
            @Override public void onRenegotiationNeeded() {}
            @Override public void onAddTrack(org.webrtc.RtpReceiver rtpReceiver, org.webrtc.MediaStream[] mediaStreams) {}
        });

        // 7. 创建Offer
        // 注意:即使我们现在不发送任何视频流,也需要创建Offer来启动连接过程
        // 'OfferToReceiveVideo': true 表示我们虽然不发,但准备好接收对方(如果有)的视频
        MediaConstraints sdpMediaConstraints = new MediaConstraints();
        sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveVideo", "false"));
        sdpMediaConstraints.mandatory.add(new MediaConstraints.KeyValuePair("OfferToReceiveAudio", "false"));

        peerConnection.createOffer(new SdpObserver() {
            @Override
            public void onCreateSuccess(SessionDescription sessionDescription) {
                Log.d(TAG, "Offer 创建成功!");
                peerConnection.setLocalDescription(this, sessionDescription);
                
                // 将Offer发送给信令服务器
                try {
                    JSONObject payload = new JSONObject();
                    payload.put("type", sessionDescription.type.canonicalForm());
                    payload.put("sdp", sessionDescription.description);
                    
                    JSONObject message = new JSONObject();
                    message.put("type", "offer");
                    message.put("payload", payload);
                    
                    socket.emit("message", message);
                    Log.d(TAG, "Offer 已发送");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }

            @Override public void onSetSuccess() { Log.d(TAG, "setLocalDescription 成功!"); }
            @Override public void onCreateFailure(String s) { Log.e(TAG, "Offer 创建失败: " + s); }
            @Override public void onSetFailure(String s) { Log.e(TAG, "setLocalDescription 失败: " + s); }
        }, sdpMediaConstraints);
    }

    private void handleAnswer(JSONObject answerSdp) {
        Log.d(TAG, "处理 Answer");
        try {
            String sdp = answerSdp.getString("sdp");
            peerConnection.setRemoteDescription(new SdpObserver() {
                @Override public void onCreateSuccess(SessionDescription sessionDescription) {}
                @Override public void onSetSuccess() { Log.d(TAG, "setRemoteDescription 成功!"); }
                @Override public void onCreateFailure(String s) {}
                @Override public void onSetFailure(String s) { Log.e(TAG, "setRemoteDescription 失败: " + s); }
            }, new SessionDescription(SessionDescription.Type.ANSWER, sdp));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    private void handleRemoteCandidate(JSONObject candidate) {
        Log.d(TAG, "处理远程 Candidate");
        try {
            peerConnection.addIceCandidate(new IceCandidate(
                candidate.getString("id"),
                candidate.getInt("label"),
                candidate.getString("candidate")
            ));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        if (socket != null) {
            socket.disconnect();
            socket.close();
        }
        if (peerConnection != null) {
            peerConnection.close();
        }
    }
}

代码解析(必读):

  • 配置 (SERVER_URL)再次强调,把IP地址换成你的电脑IP!

  • initWebRTC(): 初始化WebRTC工厂类,这是创建其他WebRTC对象的基础。

  • connectToSignalingServer(): 创建socket.io客户端,设置各种事件监听(连接成功、收到消息、断开连接),然后发起连接。

  • call(): 这是APP作为“呼叫方”的核心流程。

    1. 创建PeerConnection实例,并传入STUN服务器和一个Observer。这个Observer非常重要,onIceCandidate回调就在这里,用于将本地找到的网络路径发送出去。

    2. 调用peerConnection.createOffer()。这是向WEB端发出的“我们连接吧”的请求。

    3. 在createOffer的onCreateSuccess回调中,先调用setLocalDescription将这个Offer设置为自己的状态,然后通过socket.emit把它发送给信令服务器。

  • handleAnswer() / handleRemoteCandidate(): 这两个方法分别用于处理从WEB端通过信令服务器转发来的Answer和Candidate消息。

现在,我们到了激动人心的时刻!我们将让APP和WEB第一次“对话”。

  1. 启动所有组件:

    • 信令服务器: 确保 signaling-server 目录下的 node server.js 正在运行。

    • WEB接收端: 确保 web-client 正在被 http-server 托管,并在浏览器中打开了 index.html 页面。打开浏览器的开发者工具(F12)并切换到Console标签页,这是观察WEB端日志的关键!

  2. 运行APP:

    • 用USB线连接你的Android手机到电脑(确保已开启开发者模式和USB调试)。

    • 在Android Studio中,点击顶部的绿色三角运行按钮 (▶),选择你的手机作为目标设备。

    • APP会安装并启动。

  3. 开始测试:

    • 在手机上,点击“开始连接”按钮。

    • 同时观察三方的日志!

      • Android Studio Logcat: 过滤TAG为MainActivity,你应该能看到连接服务器、创建Offer、发送Offer、收到Answer、收到Candidate、设置成功等一系列日志。

      • 浏览器开发者工具Console: 你应该能看到收到Offer、创建Answer、发送Answer、收到Candidate等日志。

      • 信令服务器命令行窗口: 你会看到客户端连接、消息接收和转发的日志。

预期结果:
如果一切顺利,APP和WEB端会通过信令服务器交换一系列SDP和ICE Candidate信息。虽然此时WEB端仍然是黑屏(因为我们还没发送视频),但P2P连接的“握手”过程已经完成了!你可能会在WEB端的status区域看到状态从“等待连接”变为“收到请求”、“发送应答”等。

如果连接成功建立,说明最复杂的部分已经打通。

如果失败,如何排查?

  • 连接不上信令服务器: 检查APP和WEB端的IP地址配置是否正确;检查手机和电脑是否在同一Wi-Fi下;检查电脑防火墙。

  • 日志中出现createOffer或createAnswer失败: 通常是WebRTC初始化问题或参数错误。

  • 日志中出现setLocal/RemoteDescription失败: 可能是SDP格式问题,或者状态机错误(比如在错误的时间调用了它)。

  • 仔细比对三方的日志,看消息是否正确地从一端发出,经过服务器,然后被另一端接收。

完成这一步,我们就等于建好了数据传输的“管道”。下一步,也是最后一步,就是往这个管道里“灌水”——将APP的屏幕画面数据灌进去。

请完成这一步的测试,确认信令交互成功后,我们再进行最后一步。

第四步:注入屏幕视频流并完成监控

目标:

  1. APP端:获取屏幕录制权限,创建屏幕视频采集器,将视频轨道(Video Track)添加到PeerConnection中。

  2. WEB端:无需修改代码,它已经准备好接收并播放任何到来的视频流。

此部分代码可使用AI自动生成,不是很复杂。

Logo

中国智能体开发者社区,聚焦智能体与大模型开发,提供前沿资讯、实用工具链、开源项目及行业案例。通过技术沙龙、开发者大赛等活动,促进经验交流与协作,助力开发者快速构建创新智能应用。

更多推荐