Scrcpy视频同步源码分析

这篇具有很好参考价值的文章主要介绍了Scrcpy视频同步源码分析。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

什么是Scrcpy

https://github.com/Genymobile/scrcpy

        Scrcpy是genymobile开源的一款手机镜像软件,通过对手机音视频的采集和同步,可以实现在PC平台上控制手机的功能。

官方解释:此应用程序镜像通过 USB 或 TCP/IP 连接的 Android 设备(视频和音频),并允许使用计算机的键盘和鼠标控制设备。 它不需要任何根访问权限。 它适用于 Linux、Windows 和 macOS。

因为它的易用性,所以广受好评,那么,它又是怎么实现这个易用性的呢?还是得解读一下。

源码分析

Scrcpy是通过app_process的方法,首先将dex或者jar文件push到Android设备中/data/local/tmp中,然后通过adb shell进行调用。一般来讲,访问/data文件夹都需要root权限,而tmp文件夹提供了shell权限就能够访问的方法,因此使用app_process的二进制文件大多数情况下都是放置在/data/local/tmp文件夹下。

这里scrcpy存在一个Feature。当我们运行scrcpy并且投屏成功后,用adb shell到/data/local/tmp文件夹下却无法找到对应的app_process文件,可以尝试着使用

top | grep scrcpy

进行查看,当前scrcpy已经完全加载到内存中并运行的时候,就会将/data/local/tmp文件夹下的二进制运行文件删除掉,不留下一点痕迹。 

除此以外,其他的功能应该分为三大块,视频同步,音频同步以及控制同步,接下来我们一块块进行剖析。

1. 视频同步

视频流同步,主要的代码在ScreenEncoder.java里面的streamScreen()方法,通过do while循环的方式实时获取截图并且编码成视频流,具体代码如下:

do {
    ScreenInfo screenInfo = device.getScreenInfo();
    Rect contentRect = screenInfo.getContentRect();

    // include the locked video orientation
    Rect videoRect = screenInfo.getVideoSize().toRect();
    format.setInteger(MediaFormat.KEY_WIDTH, videoRect.width());
    format.setInteger(MediaFormat.KEY_HEIGHT, videoRect.height());

    Surface surface = null;
    try {
        mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        surface = mediaCodec.createInputSurface();

        // does not include the locked video orientation
        Rect unlockedVideoRect = screenInfo.getUnlockedVideoSize().toRect();
        int videoRotation = screenInfo.getVideoRotation();
        int layerStack = device.getLayerStack();
        setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);

        mediaCodec.start();

        alive = encode(mediaCodec, streamer);
        // do not call stop() on exception, it would trigger an IllegalStateException
        mediaCodec.stop();
    } catch (IllegalStateException | IllegalArgumentException e) {
        Ln.e("Encoding error: " + e.getClass().getName() + ": " + e.getMessage());
        if (!prepareRetry(device, screenInfo)) {
            throw e;
        }
        Ln.i("Retrying...");
        alive = true;
    } finally {
        mediaCodec.reset();
        if (surface != null) {
            surface.release();
        }
    }
} while (alive);

主要就是 截图 -> 编码两部分

(1)截图

        截图的具体方式主要如下,开启一个surface的事务,设置displaySurface, 设置投影,设置层次,关闭事务。

private static void setDisplaySurface(IBinder display, Surface surface, int orientation, Rect deviceRect, Rect displayRect, int layerStack) {
    SurfaceControl.openTransaction();
    try {
        SurfaceControl.setDisplaySurface(display, surface);
        SurfaceControl.setDisplayProjection(display, orientation, deviceRect, displayRect);
        SurfaceControl.setDisplayLayerStack(display, layerStack);
    } finally {
        SurfaceControl.closeTransaction();
    }
}

这边需要查看一下相关的android源码 core/java/android/view/SurfaceControl.java - platform/frameworks/base.git - Git at Google (googlesource.com)

private static native void nativeSetDisplaySurface(long transactionObj, IBinder displayToken, long nativeSurfaceObject);
private static native void nativeSetDisplayLayerStack(long transactionObj, IBinder displayToken, int layerStack);
private static native void nativeSetDisplayProjection(long transactionObj, IBinder displayToken, int orientation, int l, int t, int r, int b, int L, int T, int R, int B);

从源码中可以看到,主要的这三个方法都是native方法,native的代码被封装起来是无法直接用Android的接口调用的,因此在scrcpy的SurfaceControl.java中,通过反射对native方法进行调用。对这三个方法的具体解释和原理,可以参考Android VirtualDisplay解析 - 简书 (jianshu.com),上面对整个录屏流程讲解得特别清楚,这边就不加赘述。

不过这样看来,实际上具体的步骤跟minicap是完全一样的,只是实现的方式不一样,在scrcpy中通过java反射的方式,调用java封装好的native静态方法,而minicap中通过参考AOSP的源码用C++的方法直接调用Android方法。

Minicap截图原理分析_Edward.W的博客-CSDN博客

相比minicap需要对所有不用的sdk版本打不同的包,scrcpy直接build成android应用,通过反射的方法获取截图会更加稳妥,兼容性强一些,但是本质的方法一样。

(2) 编码

        上一块内容把比较重要的截图内容实现了,之后就是如何将截图编码成视频流了。为什么要编码成视频流呢?我想这边主要是降低传输过程的压力。在minicap中,把截图YUV编码成JPEG图片,这是一个有损压缩的过程,而且JPEG图像依旧比较大,如果在FPS高的情况下,对传输压力特别大。而于此相比,视频流的压力就显得小了很多。

        源码中主要是通过Android内置的多媒体操作框架MediaCodec实现的。

 MediaCodec mediaCodec = createMediaCodec(codec, encoderName);
 MediaFormat format = createFormat(codec.getMimeType(), videoBitRate, maxFps, codecOptions);

通过createMediaCodec的方法创建一个mediaCodec对象用于编码。

mediaCodec.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
surface = mediaCodec.createInputSurface();

通过createInputSurface方法设置好输入来源,这要只要surface每次获取截图,就能自动作为mediacodec的输入流。

然后在encode方法中进行编码,通过getOutputBuffer方法获取输出流,同时用streamer传输给接收流的端口。

private boolean encode(MediaCodec codec, Streamer streamer) throws IOException {
        boolean eof = false;
        MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();

        while (!consumeRotationChange() && !eof) {
            int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);
            try {
                if (consumeRotationChange()) {
                    // must restart encoding with new size
                    break;
                }

                eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
                if (outputBufferId >= 0) {
                    ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);

                    boolean isConfig = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0;
                    if (!isConfig) {
                        // If this is not a config packet, then it contains a frame
                        firstFrameSent = true;
                        consecutiveErrors = 0;
                    }

                    streamer.writePacket(codecBuffer, bufferInfo);
                }
            } finally {
                if (outputBufferId >= 0) {
                    codec.releaseOutputBuffer(outputBufferId, false);
                }
            }
        }

        return !eof;
    }

2. 音频同步

        音频同步的入口位置在Server.java文件中的这几行。主要有两种,一种是rarRecorder,一种是audioEncoder,分别为发送原始音频和发送编码后的音频。

Streamer为音频通道,就是要把音频最终发到哪里去, AudioCodec是音频编码器。

原始音频的内容比较简单,就是直接采集音频并且发送到streamer,内容包含在编码音频里,所以这边只讲编码音频这一块,也就是AudioEncoder。

 if (audio) {
                AudioCodec audioCodec = options.getAudioCodec();
                Streamer audioStreamer = new Streamer(connection.getAudioFd(), audioCodec, options.getSendCodecMeta(),
                        options.getSendFrameMeta());
                AsyncProcessor audioRecorder;
                if (audioCodec == AudioCodec.RAW) {
                    audioRecorder = new AudioRawRecorder(audioStreamer);
                } else {
                    audioRecorder = new AudioEncoder(audioStreamer, options.getAudioBitRate(), options.getAudioCodecOptions(),
                            options.getAudioEncoder());
                }
                asyncProcessors.add(audioRecorder);
            }

在AudioEncoder开始的时候,就是起了一个新的thread执行encode操作(文件AudioEncoder.java),这个encode函数里,先判断是不是Android 11及以上的版本,然后主要是创建了4个线程分别执行4个内容

(1)AudioCapture

        a). 开启一个workaround,由于android 11需要前台应用才可以获取音频,但是app_process并不是一个应用,所以必须启用workaround,本质上就是用Intent启动一个com.android.shell.HeapDumpActivity,这样系统就能把当前的app_process识别为一个前台应用。

Intent intent = new Intent(Intent.ACTION_MAIN);
                intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
                intent.addCategory(Intent.CATEGORY_LAUNCHER);
                intent.setComponent(new ComponentName(FakeContext.PACKAGE_NAME, "com.android.shell.HeapDumpActivity"));
                ServiceManager.getActivityManager().startActivityAsUserWithFeature(intent);
                // Wait for activity to start
                SystemClock.sleep(150);

        b).创建一个录音机(系统类,AudioRecord类),同样的,在sdk版本大于31时必须要设置context,录音机的Context也是借用shell的context来实现,同时设置录音源为"REMOTE_SUBBMIX",这个设置相当于把当前设备播放的声音直接截断了远程播放,而不在本地播放。

/**
 * Audio source for a submix of audio streams to be presented remotely.
 * <p>
 * An application can use this audio source to capture a mix of audio streams
 * that should be transmitted to a remote receiver such as a Wifi display.
 * While recording is active, these audio streams are redirected to the remote
 * submix instead of being played on the device speaker or headset.
 * </p><p>
 * Certain streams are excluded from the remote submix, including
 * {@link AudioManager#STREAM_RING}, {@link AudioManager#STREAM_ALARM},
 * and {@link AudioManager#STREAM_NOTIFICATION}.  These streams will continue
 * to be presented locally as usual.
 * </p><p>
 * Capturing the remote submix audio requires the
 * {@link android.Manifest.permission#CAPTURE_AUDIO_OUTPUT} permission.
 * This permission is reserved for use by system components and is not available to
 * third-party applications.
 * </p>
 */
@RequiresPermission(android.Manifest.permission.CAPTURE_AUDIO_OUTPUT)
public static final int REMOTE_SUBMIX = 8;

(2) mediaCodecThread

这个Thread只是一个handlerThread,用于mediaCodec的CallBack,真正运行的应该是EncoderCallback()这个函数。这个函数里面实现了两个方法,onInputBufferAvailable和onOutputBufferAvailable,实际上就是设置了两个队列,inputTask和outputTask,每次将Index写入到队列里以免经过编码器之后得到的结果在某个位置无序了。

inputAvailable -> 设置 index -> mediaCodec编码(取出index,并且进行编码)-> 编码结果 -> ouptutAvailable -> 写入队列(index, 编码结果)

private final BlockingQueue<InputTask> inputTasks = new ArrayBlockingQueue<>(64);
private final BlockingQueue<OutputTask> outputTasks = new ArrayBlockingQueue<>(64);

public void onInputBufferAvailable(MediaCodec codec, int index) {
            try {
                inputTasks.put(new InputTask(index));
            } catch (InterruptedException e) {
                end();
            }
        }

        @Override
        public void onOutputBufferAvailable(MediaCodec codec, int index, MediaCodec.BufferInfo bufferInfo) {
            try {
                outputTasks.put(new OutputTask(index, bufferInfo));
            } catch (InterruptedException e) {
                end();
            }
        }

(3)inputThread

        获取index(从inputTasks队列)->创建输入缓冲区->读取音频内容到缓冲区->开始排队编码

        从InputTask队列里面获取一个Index,然后用mediaCodec.getInputBuffer,将取出来的task的序号作为音频编码器的输入的标记,创建一个缓冲区,这样只需要每次将获取到的音频数据读取到这个缓冲区都能够自动作为音频编码器的输入。

最后直接将capture的内容读取到该缓冲区,调用queueInputBuffer排队编码就行了。

InputTask task = inputTasks.take();
ByteBuffer buffer = mediaCodec.getInputBuffer(task.index);
int r = capture.read(buffer, READ_SIZE, bufferInfo);
if (r < 0) {
    throw new IOException("Could not read audio: " + r);
}
mediaCodec.queueInputBuffer(task.index, bufferInfo.offset, bufferInfo.size, bufferInfo.presentationTimeUs, bufferInfo.flags);

(4)outputThread 

        和Input thread相对应的,input thread是把音频捕获的内容放置到编码器MediaCodec里,那output thread就是将编码完成的音频流从编码器中获取出来,不用过多解释。就是获取输出缓冲区,然后将缓冲器的数据通过streamer输出去。

        获取index(从OutputTasks队列)-> 获取编码结果到缓冲区 -> 将缓冲区内容输出

private void outputThread(MediaCodec mediaCodec) throws IOException, InterruptedException {
    streamer.writeAudioHeader();

    while (!Thread.currentThread().isInterrupted()) {
        OutputTask task = outputTasks.take();
        ByteBuffer buffer = mediaCodec.getOutputBuffer(task.index);
        try {
            streamer.writePacket(buffer, task.bufferInfo);
        } finally {
            mediaCodec.releaseOutputBuffer(task.index, false);
        }
    }
}

3. 控制同步

控制器的入口位置在Server.java文件中的这两行。

if (control) {
    Controller controller = new Controller(device, connection, options.getClipboardAutosync(), options.getPowerOn());
    device.setClipboardListener(text -> controller.getSender().pushClipboardText(text));
    asyncProcessors.add(controller);
}


for (AsyncProcessor asyncProcessor : asyncProcessors) {
    asyncProcessor.start();
}

        实际上相当于new一个Controller对象,然后调用这个controller对象的start方法,只是这边控制和音频写成了模块的形式,然后异步执行,使代码理解起来更加清晰。这样也方便了后续添加其他模块时可以直接add到AsyncProcessor里。

        整个app_process的思想是,视频流在mainprocessor,然后其他的模块都处于asyncprocessor,只要视频流不断,最基础的功能就能够存在。

        接下来我们来看Controller.java文件,在这里面开启了两个线程,control和sender,分别是从控制端口接收数据和发送数据。首先我们来看control。

        当接收到命令请求的时候,就会开始进行handleEvent,根据不同的事件做不同的处理,同时也可以添加一些自己设定的操作,只需要保证接收端和Android端的命令能识别就行了。

while (!Thread.currentThread().isInterrupted()) {
            handleEvent();
}

(1)输入

这边输入按键的方法,也就是injectKeyCode方法,我们追根溯源,最终是使用的反射的方法,在InputManager里面调用InjectInputEvent。

 private Method getInjectInputEventMethod() throws NoSuchMethodException {
        if (injectInputEventMethod == null) {
            injectInputEventMethod = manager.getClass().getMethod("injectInputEvent", InputEvent.class, int.class);
        }
        return injectInputEventMethod;

源码在:core/java/android/hardware/input/InputManager.java - platform/frameworks/base - Git at Google (googlesource.com)

这边的话可以看到,这个injectInputEvent方法并不是native的方法,为什么需要用反射进行调用呢。这里涉及到一个问题,因为app_process是不带有manifest来获取Permission的,通过adb shell进行调用的话只具备了shell权限。由于反射的是这个不带有uid的方法,而该方法是@hide的而且unsupportedappusage,所以还是需要用反射的方法进行调用。

 /**
     * Injects an input event into the event system on behalf of an application.
     * The synchronization mode determines whether the method blocks while waiting for
     * input injection to proceed.
     * <p>
     * Requires the {@link android.Manifest.permission.INJECT_EVENTS} permission.
     * </p><p>
     * Make sure you correctly set the event time and input source of the event
     * before calling this method.
     * </p>
     *
     * @param event The event to inject.
     * @param mode The synchronization mode.  One of:
     * {@link android.os.InputEventInjectionSync.NONE},
     * {@link android.os.InputEventInjectionSync.WAIT_FOR_RESULT}, or
     * {@link android.os.InputEventInjectionSync.WAIT_FOR_FINISHED}.
     * @return True if input event injection succeeded.
     *
     * @hide
     */
    @RequiresPermission(Manifest.permission.INJECT_EVENTS)
    @UnsupportedAppUsage
    public boolean injectInputEvent(InputEvent event, int mode) {
        return injectInputEvent(event, mode, Process.INVALID_UID);
    }

(2)文本

        输入文本的时候,在Android端解析出来就是一整个string,解析的方法也是简单粗暴,逐字符注入到手机上。具体调用的方法如下:

private boolean injectChar(char c) {
        String decomposed = KeyComposition.decompose(c);
        char[] chars = decomposed != null ? decomposed.toCharArray() : new char[]{c};
        KeyEvent[] events = charMap.getEvents(chars);
        if (events == null) {
            return false;
        }
        for (KeyEvent event : events) {
            if (!device.injectEvent(event, Device.INJECT_MODE_ASYNC)) {
                return false;
            }
        }
        return true;
    }

        将每个字符通过decompose的方法转变成为键盘输入,然后同上面一样的通过InjectInputEvent进行输入。就相当于就是在手机键盘上输入这些字符。但是问题同样存在,对于Unicode字符,如中文字符或者其他的语言文字就无法Inject成功,因为手机上找不到对应的按键直接生成。这边的话需要通过粘贴板实现,而不是通过InjectKeyCode实现。

(3)触控

        这一块看起来像是最难的,实际上可能也是最简单的。在没看源码之前,可能会考虑得如何支持多点触控,按着屏幕移动或者说早期魅族的3d touch这种事件。而实际上这些内容在Android的操作系统层面都帮我们处理好了,只要我们按照一定的规范操作就行。

        主要的方法就是通过创建一个event方法,然后用injectInputEvent注入相应的事件进去。而其中的难点就在于,我们得解析“触控位置”,“触控压力(3D触控)”,“多点触控(设置Index)”等问题。

//获取具体触控的位置
 Point point = device.getPhysicalPoint(position);
//确定触控的点(多点触控)
int pointerIndex = pointersState.getPointerIndex(pointerId);

//设置触控压力(3D触控)
Pointer pointer = pointersState.get(pointerIndex);
pointer.setPoint(point);
pointer.setPressure(pressure);

//通过action确定是按下还是拖动还是弹起
// secondary pointers must use ACTION_POINTER_* ORed with the pointerIndex
if (action == MotionEvent.ACTION_UP) {
    action = MotionEvent.ACTION_POINTER_UP | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
    } else if (action == MotionEvent.ACTION_DOWN) {
    action = MotionEvent.ACTION_POINTER_DOWN | (pointerIndex << MotionEvent.ACTION_POINTER_INDEX_SHIFT);
    }

//创建event事件
MotionEvent event = MotionEvent.obtain(lastTouchDown, now, action, pointerCount, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0, source, 0);

        这个方法的难点在于scrcpy的客户端ui是在windows或者mac的窗口上,然后多点触控的话(比如触屏电脑,或者鼠标的左,中,右键同时有动作也会触发多点触控)在C++写的UI上不好自己辨别,所以这边需要在android上辨别pointerIndex。

        由于多点触控更多的情况下是在移动终端下,比如平板或者手机,如果对应的控制UI是运行在web浏览器上或者是小程序(微信小程序ios的pointerId有问题)之类的东西里,那么对应的pointerIndex就无需再计算,可以直接由web端或者小程序端收集,这样生成的多点触控会更加稳定。

(4)滑动

        滑动这个操作可能比较不熟悉,大家在手机上的操作主要都是触控产生的上下滑动。这边所说的滑动实际上比较像是鼠标中键的上下滑动,或者说是macbook触控板的滑动。

这个实现方法就比较简单了,跟触控类似的,还是创建一个motionevent,然后去实现这个动作。只不过创建的类型不一样。类型是MotionEvent.ACTION_SCROLL,这里Android里面都帮我们实现好了,就不再详细讲述。

 MotionEvent event = MotionEvent
                .obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, buttons, 1f, 1f, DEFAULT_DEVICE_ID, 0,
                        InputDevice.SOURCE_MOUSE, 0);
        return device.injectEvent(event, Device.INJECT_MODE_ASYNC);

主要的功能就这四个,另外的几个功能大同小异。文章来源地址https://www.toymoban.com/news/detail-664335.html

到了这里,关于Scrcpy视频同步源码分析的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • scrcpy之将Android手机投屏到Linux电脑实践

    参考:https://zhuanlan.zhihu.com/p/366378837 电脑端安装投屏程序 手机端设置 手机端无需安装任何软件,只需开启【开发者选项】-【USB调试】及相关选项,比如我开启了【USB调试、USB调试(安全设置)、无线调试、USB安装】 开启【USB调试】相关选项后,用USB数据线连接电脑与手机,

    2024年02月09日
    浏览(56)
  • 基于Scrcpy的Android手机屏幕投影到电脑教程

    在执行某些项目的时候,有了获取手机屏幕画面的需求,遂整理了一下这方面的电脑教程。 在经过多次比较之后,选择了投屏效果比较好Scrcpy做为手机屏幕投屏的软件。 1.软件下载 scrcpy官网 Windows软件下载 在这里可能需要翻墙,没有梯子的伙伴可以从我的网盘中下载。 链接

    2024年02月11日
    浏览(46)
  • 基于scrcpy的Android群控项目重构,获取Android屏幕元素信息并编写自动化事件

    基于scrcpy的远程调试方案 基于scrcpy的Android群控项目重构 基于scrcpy的Android群控项目重构 进阶版 基于scrcpy的Android群控项目重构,获取Android屏幕元素信息并编写自动化事件(视频) 基于scrcpy的Android群控项目重构,获取Android屏幕元素信息并编写自动化事件(博客) 基于scrcpy的

    2024年02月16日
    浏览(70)
  • 【95 6K⭐】Scrcpy:一款免费、强大且实用的Android镜像投屏控制软件

    随着科技的不断发展,我们的生活中出现了越来越多的智能设备。尤其是智能手机,已经成为了我们日常生活中不可或缺的一部分。然而,有时候我们需要在电脑上操作手机,例如进行应用程序的调试、游戏挂机等。这时,拥有一款功能强大且丰富的投屏软件来满足我们的众

    2024年02月22日
    浏览(41)
  • 基于scrcpy的Android群控项目重构,集成Appium服务执行自动化测试用例

    基于scrcpy的Android群控项目重构 基于scrcpy的Android群控项目重构 进阶版 基于scrcpy的Android群控项目重构,获取Android屏幕元素信息并编写自动化事件(视频) 基于scrcpy的Android群控项目重构,获取Android屏幕元素信息并编写自动化事件(博客) 基于scrcpy的Android群控项目重构,集成

    2024年02月16日
    浏览(53)
  • scrcpy无线连接手机失败

    报错:由于目标计算机积极拒绝,无法连接。 (10061) (如果cmd报错可能显示乱码“鐢变簬鐩爣璁$畻鏈虹Н鏋佹嫆缁濓紝鏃犳硶杩炴帴”,因为把utf-8的文本解析成ANSI了) 安卓系统保护(解决方法参见:https://blog.csdn.net/adorable_/article/details/116500301) 安卓11以下,需要root。

    2024年02月14日
    浏览(33)
  • Scrcpy无线连接

    下载路径 SDK 平台工具版本说明  |  Android 开发者  |  Android Developers 解压放到不会经常动的路径,比如我放到C:Program FilesandroidSDK。 在系统环境变量新建ANDROID_HMOE,配置C:Program FilesandroidSDK,然后在path变量中增加%ANDROID_HOME%platform-tools和C:Program FilesandroidSDKplatform-tools,在

    2024年02月11日
    浏览(35)
  • Scrcpy手机投屏

    Scrcpy投屏(电脑操作手机)@TOC Android设备至少需要5.0以上版本(即API 21) 确保在电脑设备上启动了adb调试 在某些设备上,还需要启动其他选项以使用建买盘和鼠标。链接: 其它选项 adb调试的开启一般是多次点击手机系统的版本号,比如vivoS15pro:设置-系统管理-关于手机-版本

    2024年02月09日
    浏览(57)
  • 软件推荐:多屏协作scrcpy

    在百度百科中,关于多屏协作的定义是这样的:通过多屏协作,可以实现跨系统、跨设备协同,让手机与电脑、平板实现资源共享,协同操作,提高工作效率。不要馋华为小米的多屏协同了,来试试这个软件吧!(温馨提示:使用该软件门槛有点高,请酌情观看) 最近因为华

    2023年04月08日
    浏览(59)
  • Scrcpy远程控制Andorid手机

            Scrcpy是免费的开源屏幕镜像应用程序,允许您在Windows、macOS或Linux桌面上控制Android设备。 github地址:https://github.com/Genymobile/scrcpy         Android设备必须至少支持API 21 (Android 5.0),确认设备已打开以进行ADB调试,某些设备还需要打开可通过鼠标和键盘控制的其他

    2024年02月09日
    浏览(53)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包