如何把一台手机的屏幕投到另一台手机上

发布于:2025-07-05 ⋅ 阅读:(13) ⋅ 点赞:(0)

如何把一台手机的屏幕投到另一台手机上

在PC上,我们可以用Scrcpy将手机的屏幕投屏过来,并且可以直接在PC上操作手机

而用手机控制手机,一般的做法是用一些远程工具(例如RustDesk、向日葵等)传输视频流,然后通过无障碍服务进行模拟操作,这种方式需要目标设备开启无障碍服务。

如果只需要传输视频流而不需要模拟操作,其实可以借助adb和scrcpy来完成,由于scrcpy并没有适配安卓arm64版本,所以我们需要自行对视频流进行解析,下图是这一方案的流程图,注意,这种方案下,控制端需要root权限

image.png

方案难点

  1. 控制端使用ADB
    Scrcpy的视频流传输在本地的localabstract,需要通过adb将此端口转发出来,这在作为控制端的安卓设备上并不容易实现
  2. 被控端ADB授权
    使用OTG线连接两台设备时,可以像PC一样对设备进行手动授权。而使用无线方式连接,需要确保wifi ADB开启,同时也需要授权
  3. 视频流解析
    Scrcpy的视频流基于screenrecord获取,而screenrecord传输的并非是标准协议的视频,而是H264视频裸流,需要自己解析视频数据播放

效果展示

手机投屏手机

流程

被控端

  1. 无感投屏(可选,需要Root)
    正常流程下,需要先用OTG线连接两个设备,然后在弹出的对话框中给控制端ADB调试授权。如果要实现无感,可以将ADB的调试授权校验关闭,同时自动开启wifi adb

    此功能可以用Magisk模块实现,需要装Magisk,如果你此前没有装过,可以(参考教程),然后刷入Wifi ADB模块),此模块只打开了wifi ADB并监听5555端口,你也可以自行编写模块,在模块中添加system.prop,内容如下

persist.sys.usb.config=adb
sys.usb.config=adb
persist.sys.usb.ffbm-02.func=adb
persist.sys.usb.qmmi.func=adb
sys.usb.configfs=1
persist.security.adbinput=1
persist.security.adbinstall=1
persist.security.uks_opened=1
ro.secure=0
ro.adb.secure=0
service.adb.tcp.port=5555

控制端

需要root权限,如果没有先去获取Root

安装ADB

同样通过Magisk模块来安装ADB,(模块地址)

安装完毕后,切换到su用户,可以在命令行下使用abd和fastboot命令

推送并启动Scrcpy-Server

在Scrcpy发布页面下载Scrcpy-Server

image.png

推送并启动

# 推送scrcpy-server到tmp目录
adb push scrcpy-server /data/local/tmp
# 启动scrcpy服务 传输h264裸流
adb shell su -c "CLASSPATH=/data/local/tmp/scrcpy-server app_process / com.genymobile.scrcpy.Server 3.3.1 tunnel_forward=true audio=false control=false cleanup=false raw_stream=true"
# 转发被控端的scrcpy端口到控制端的11234端口
adb forward tcp:11234 localabstract:scrcpy

至此,scrcpy-server已经启动完成并向控制端的11234端口推送视频流

解析并播放视频流

解析视频流并渲染到Surface,Kotlin代码如下

package com.example.scrcpyclient

import android.media.MediaCodec
import android.media.MediaFormat
import android.view.Surface
import java.net.Socket
import java.nio.ByteBuffer
import kotlin.concurrent.thread

class H264StreamDecoder(
    private val surface: Surface,
    private val ip: String,
    private val port: Int
) {
    private var codec: MediaCodec? = null
    private var running = false

    fun start() {
        running = true
        thread {
            try {
                val socket = Socket(ip, port)
                val input = socket.getInputStream()

                codec = MediaCodec.createDecoderByType("video/avc")
                val format = MediaFormat.createVideoFormat("video/avc", 1280, 720)
                codec?.configure(format, surface, null, 0)
                codec?.start()

                val buffer = ByteArray(4096)
                var nalBuffer = ByteArray(0)

                while (running) {
                    val len = input.read(buffer)
                    if (len <= 0) break

                    // 拼接到 NAL 缓冲区
                    nalBuffer += buffer.copyOf(len)

                    // 解析 NAL 单元 (以 0x00000001 开头)
                    while (true) {
                        val startCodeIndex = nalBuffer.indexOfStartCode()
                        if (startCodeIndex < 0) break
                        val nextStartCodeIndex = nalBuffer.indexOfStartCode(startCodeIndex + 4)

                        if (nextStartCodeIndex < 0) break

                        val nal = nalBuffer.copyOfRange(startCodeIndex, nextStartCodeIndex)
                        feedDecoder(nal)
                        nalBuffer = nalBuffer.copyOfRange(nextStartCodeIndex, nalBuffer.size)
                    }
                }

                codec?.stop()
                codec?.release()
                input.close()
                socket.close()
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }

    fun stopDecoding() {
        running = false
    }

    private fun feedDecoder(nal: ByteArray) {
        val codec = codec ?: return
        val index = codec.dequeueInputBuffer(10000)
        if (index >= 0) {
            val inputBuffer = codec.getInputBuffer(index)
            inputBuffer?.clear()
            inputBuffer?.put(nal)
            codec.queueInputBuffer(index, 0, nal.size, System.nanoTime() / 1000, 0)
        }

        val bufferInfo = MediaCodec.BufferInfo()
        var outIndex = codec.dequeueOutputBuffer(bufferInfo, 10000)
        while (outIndex >= 0) {
            codec.releaseOutputBuffer(outIndex, true)
            outIndex = codec.dequeueOutputBuffer(bufferInfo, 0)
        }
    }

    private fun ByteArray.indexOfStartCode(fromIndex: Int = 0): Int {
        for (i in fromIndex until size - 4) {
            if (this[i] == 0x00.toByte() &&
                this[i + 1] == 0x00.toByte() &&
                this[i + 2] == 0x00.toByte() &&
                this[i + 3] == 0x01.toByte()
            ) {
                return i
            }
        }
        return -1
    }
}

为什么不用直接screenrecord?

调用screenrecord,可以直接捕获屏幕输出视频流,例如执行以下命令,可以直接调用ffplay播放手机屏幕视频流

adb exec-out screenrecord --output-format=h264 - | ffplay -flags low_delay -framerate 60 -probesize 32 -sync video -an

究其原因,是因为scrcpy封装了除了视频流传输以外的很多视频处理功能,例如视频裁剪、旋转、翻转等,详细命令配置可以(参考这里),并且scrcpy对于screenrecord的配置优化合理,适用于大多数场景。

需要特别指出的时,scrcpy的命令参数不一定和scrcpy-server的启动参数完全一致,例如对于设备旋转翻转,scrcpy使用orientation,而对应的scrcpy-server的启动参数为capture_orientation,,具体的参数可以(查阅scrcpy源码)


网站公告

今日签到

点亮在社区的每一天
去签到