移动端超级实用工具Scrcpy操作分享 (下)

Posted 开河大大

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了移动端超级实用工具Scrcpy操作分享 (下)相关的知识,希望对你有一定的参考价值。

接上文:

移动端超级实用工具Scrcpy操作分享 (上)

移动端超级实用工具Scrcpy操作分享 (中)

19)关闭电源

要在关闭scrcpy时关闭设备屏幕

scrcpy --power-off-on-close

20)开机开机

默认情况下,在启动时,设备处于开机状态。

要防止此行为:

scrcpy --no-power-on

21)显示触摸

对于演示,显示物理接触(在物理设备上)可能很有用。

android 在Developers options中提供了这个功能。

Scrcpy提供了一个选项,可以在启动时启用此功能并在退出时恢复初始值:

scrcpy --show-touches
scrcpy -t

22)禁用屏保

默认情况下,scrcpy不会阻止屏幕保护程序在计算机上运行。

要禁用它:

scrcpy --disable-screensaver

输入控制

23)旋转设备屏幕

按MOD+r在纵向和横向模式之间切换。

请注意,只有当前台的应用程序支持请求的方向时,它才会旋转。

24)复制粘贴

任何时候安卓剪贴板发生变化,都会自动同步到电脑剪贴板。

任何Ctrl快捷方式都会转发到设备。尤其:

Ctrl+c 通常复制
Ctrl+x 通常剪切
Ctrl+v 通常粘贴(在计算机到设备剪贴板同步之后)

这通常会按您预期的那样工作。

不过,实际行为取决于活动的应用程序。例如, TermuxCtrl改为在+上发送 SIGINT c,而K-9 Mail 撰写了一条新消息。

在这种情况下复制、剪切和粘贴(但仅在 Android >= 7 上支持):

MOD+c 注入COPY
MOD+x 注入CUT
MOD+v 注入PASTE(在计算机到设备剪贴板同步之后)

此外,MOD++将计算机Shift剪贴板v文本作为一系列按键事件注入。这在组件不接受文本粘贴(例如在Termux中)时很有用,但它可能会破坏非 ASCII 内容。

警告:将计算机剪贴板粘贴到设备(通过 Ctrl+v或MOD+ v)会将内容复制到 Android 剪贴板中。因此,任何 Android 应用程序都可以读取其内容。您应该避免以这种方式粘贴敏感内容(如密码)。

以编程方式设置设备剪贴板时,某些 Android 设备的行为不符合预期。提供了一个选项--legacy-paste来更改Ctrl+v和MOD+的行为,v以便它们也将计算机剪贴板文本作为一系列键事件注入(与MOD+ Shift+的方式相同v)。

要禁用自动剪贴板同步,请使用 --no-clipboard-autosync.

25)双指缩放

模拟“双指缩放”:Ctrl+ click-and-move。

更准确地说,Ctrl在按下左键单击按钮的同时按住 。在释放左键单击按钮之前,所有鼠标移动都会相对于屏幕中心缩放和旋转内容(如果应用程序支持)。

从技术上讲,scrcpy在屏幕中心反转的位置从“虚拟手指”生成额外的触摸事件。

26)物理键盘模拟 (HID) -- 重点

默认情况下,scrcpy使用 Android 密钥或文本注入:它可以在任何地方使用,但仅限于 ASCII。

或者,scrcpy可以在 Android 上模拟物理 USB 键盘以提供更好的输入体验(使用USB HID over AOAv2):虚拟键盘被禁用,它适用于所有字符和 IME。

但是,它仅在设备通过 USB 连接时有效。

注意:在 Windows 上,它可能仅在OTG 模式下工作,而不是在镜像时工作(如果 USB 设备已经被其他进程(如adb 守护进程)打开,则无法打开它)。

要启用此模式:

scrcpy --hid-keyboard

scrcpy -K # short version

如果由于某种原因失败(例如,因为设备未通过 USB 连接),它会自动回退到默认模式(在控制台中显示日志)。这允许在通过 USB 和 TCP/IP 连接时使用相同的命令行选项。

在这种模式下,原始密钥事件(扫描码)被发送到设备,独立于主机密钥映射。因此,如果您的键盘布局不匹配,则必须在 Android 设备上进行配置,路径为 Settings → System → Languages and input → Physical keyboard

这个设置页面可以直接启动:

adb shell am start -a android.settings.HARD_KEYBOARD_SETTINGS

但是,该选项仅在启用 HID 键盘(或连接物理键盘)时可用。

27)物理鼠标模拟 (HID)

与物理键盘模拟类似,可以模拟物理鼠标。同样,它仅在设备通过 USB 连接时才有效。

默认情况下,scrcpy使用具有绝对坐标的 Android 鼠标事件注入。通过模拟物理鼠标,在Android设备上出现一个鼠标指针,注入鼠标的相对运动、点击和滚动。

要启用此模式:

scrcpy --hid-mouse
scrcpy -M  # short version

您还可以添加--forward-all-clicks转发所有鼠标按钮

启用此模式后,计算机鼠标将被“捕获”(鼠标指针从计算机上消失并出现在 Android 设备上)。

特殊捕获键,Alt或者Super,切换(禁用或启用)鼠标捕获。使用其中之一将鼠标的控制权交还给计算机。

28)文本注入首选项

键入文本时会生成两种事件:

  • 按键事件,表示按键被按下或释放;

  • 文本事件,表示已输入文本。

默认情况下,字母是使用键事件注入的,因此键盘在游戏中的行为符合预期(通常是 WASD 键)。

但这可能会导致问题。如果遇到这样的问题,可以通过以下方式避免:

scrcpy --prefer-text

(但这会破坏游戏中的键盘行为)

相反,您可以强制始终注入原始键事件:

scrcpy --raw-key-events

这些选项对 HID 键盘没有影响(在此模式下所有按键事件都作为扫描码发送)。

文件丢弃

29)安装APK

要安装 APK,请将 APK 文件(以 结尾.apk)拖放到scrcpy 窗口。

没有视觉反馈,日志打印到控制台。

将文件推送到设备

要将文件推送到/sdcard/Download/设备上,请将(非 APK)文件拖放到scrcpy窗口。

没有视觉反馈,日志打印到控制台。

目标目录可以在启动时更改:

scrcpy --push-target=/sdcard/Movies/

30)将文件推送到设备

要将文件推送到/sdcard/Download/设备上,请将(非 APK)文件拖放到scrcpy窗口。

没有视觉反馈,日志打印到控制台。

目标目录可以在启动时更改:

scrcpy --push-target=/sdcard/Movies/

31)快捷键

在下面的列表中,MOD是快捷方式修饰符。默认情况下,它是 (left)Alt或 (left) Super。

它可以使用更改--shortcut-mod。可能的键是lctrl, rctrl, lalt,ralt和。例如:lsuperrsuper

#使用 RCtrl 作为快捷方式

scrcpy --shortcut-mod=rctrl  #使用右侧ctrl作为快捷方式
scrcpy --shortcut-mod=lctrl+lalt,lsuper  #使用 LCtrl+LAlt 或 LSuper 作为快捷方式

Super通常是Windows或Cmd键。

1)双击黑色边框将其移除。

2)如果屏幕关闭,右键单击可打开屏幕,否则按 BACK。

3)第 4 个和第 5 个鼠标按钮,如果您的鼠标有它们。

4)对于开发中的本机应用程序,MENU触发开发菜单。

5)仅限 Android >= 7。

通过释放并再次按下该键来执行具有重复键的快捷方式。例如,要执行“展开设置面板”:

1. 按住 并按住MOD。
2. 然后双击n。
3. 最后,释放MOD。

所有Ctrl+快捷方式都转发到设备,因此它们由活动应用程序处理

最后,遇到cannot connect to 192.168.X.X:5555: 由于目标计算机积极拒绝,无法连接。 (10061)

解决办法:

1.首先检查要连接的手机是否已开启adb调试

2.开启手机adb服务端口及adb调试功能

D:\\Program Files (x86)\\scrcpy-win64-v1.21>adb shell # 进入安卓系统shell
PD1831:/ $ setprop service.adb.tcp.port 5555 #设置adb服务端口为5555,打开adb网络调试功能
PD1831:/ $ setprop service.adb.tcp.port -1  # 打开adb的usb调试功能。
PD1831:/ $ exit # 退出shell

3.cmd输入命令 adb tcpip 5555 让设备在 5555 端口监听 TCP/IP 连接。

4.cmd 输入命令 adb connect ip:端口 即可连接成功

scrcpy服务端代码分析

源码

 Git clone https://github.com/barry-ran/QtScrcpy.git

Server端

入口文件 src/main/java/com/genymobile/scrcpy/Server.java

入口main函数

public static void main(String... args) throws Exception {
       //用来捕捉整个程序的异常
       Thread.setDefaultUncaughtExceptionHandler(new Thread.UncaughtExceptionHandler() {
            @Override
            public void uncaughtException(Thread t, Throwable e) {
                Ln.e("Exception on thread " + t, e);
                suggestFix(e);
            }
        });

        Options options = createOptions(args);  //解析参数配置,总共有14个参数

        Ln.initLogLevel(options.getLogLevel());

        scrcpy(options);  //下一步
    }

下一步scrcpy函数

private static void scrcpy(Options options) throws IOException {
    ……
    final Device device = new Device(options);
    try (DesktopConnection connection = DesktopConnection.open(device, tunnelForward)) {  //创建socket连接
            ScreenEncoder screenEncoder = new ScreenEncoder(options.getSendFrameMeta(), options.getBitRate(), options.getMaxFps(), codecOptions);  //屏幕编码

            if (options.getControl()) {
                final Controller controller = new Controller(device, connection);

                // asynchronous
                startController(controller);  //指令流监听并做处理,使用的线程,内部死循环监听处理
                startDeviceMessageSender(controller.getSender());  //监听剪切板事件

                device.setClipboardListener(new Device.ClipboardListener() {
                    @Override
                    public void onClipboardTextChanged(String text) {
                        controller.getSender().pushClipboardText(text);
                    }
                });
            }

            try {
                // synchronous
                screenEncoder.streamScreen(device, connection.getVideoFd());  //视频流编码
            } catch (IOException e) {
                // this is expected on close
                Ln.d("Screen streaming stopped");
            }
        }
    }

创建localsocket,视频流和指令流同属一个socket

public static DesktopConnection open(Device device, boolean tunnelForward) throws IOException {
        LocalSocket videoSocket;
        LocalSocket controlSocket;
        if (tunnelForward) {
            LocalServerSocket localServerSocket = new LocalServerSocket(SOCKET_NAME);
            try {
                videoSocket = localServerSocket.accept(); //创建socket并处于阻塞状态
                // send one byte so the client may read() to detect a connection error
                videoSocket.getOutputStream().write(0);  //发送一个字节数据,客户端第一次连上接收到的数据
                try {
                    controlSocket = localServerSocket.accept();
                } catch (IOException | RuntimeException e) {
                    videoSocket.close();
                    throw e;
                }
            } finally {
                localServerSocket.close();
            }
        } else {
            videoSocket = connect(SOCKET_NAME);  //视频流socket
            try {
                controlSocket = connect(SOCKET_NAME);  //控制指令socket
            } catch (IOException | RuntimeException e) {
                videoSocket.close();
                throw e;
            }
        }

        DesktopConnection connection = new DesktopConnection(videoSocket, controlSocket);
        Size videoSize = device.getScreenInfo().getVideoSize();
		//往视频流socket上发送设备名,宽度,高度
        connection.send(Device.getDeviceName(), videoSize.getWidth(), videoSize.getHeight());
        return connection;
    }

开启指令流监听

private static void startController(final Controller controller) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    controller.control();  //control()函数
                } catch (IOException e) {
                    // this is expected on close
                    Ln.d("Controller stopped");
                }
            }
        }).start();
    }

control()函数

public void control() throws IOException {
        // on start, power on the device
        if (!device.isScreenOn()) {
            device.injectKeycode(KeyEvent.KEYCODE_POWER);

            // dirty hack
            // After POWER is injected, the device is powered on asynchronously.
            // To turn the device screen off while mirroring, the client will send a message that
            // would be handled before the device is actually powered on, so its effect would
            // be "canceled" once the device is turned back on.
            // Adding this delay prevents to handle the message before the device is actually
            // powered on.
            SystemClock.sleep(500);
        }

        //死循环来处理指令流事件
        while (true) {
            handleEvent();
        }
    }

事件类型处理

private void handleEvent() throws IOException {
        ControlMessage msg = connection.receiveControlMessage();  //接收控制流消息
        switch (msg.getType()) {
            case ControlMessage.TYPE_INJECT_KEYCODE:
                if (device.supportsInputEvents()) {
                    injectKeycode(msg.getAction(), msg.getKeycode(), msg.getMetaState());
                }
                break;
            case ControlMessage.TYPE_INJECT_TEXT:
                if (device.supportsInputEvents()) {
                    injectText(msg.getText());
                }
                break;
            case ControlMessage.TYPE_INJECT_TOUCH_EVENT:
                if (device.supportsInputEvents()) {
                    injectTouch(msg.getAction(), msg.getPointerId(), msg.getPosition(), msg.getPressure(), msg.getButtons());
                }
                break;
            case ControlMessage.TYPE_INJECT_SCROLL_EVENT:  //以屏幕滚动事件为例
                if (device.supportsInputEvents()) {
                    injectScroll(msg.getPosition(), msg.getHScroll(), msg.getVScroll());
                }
                break;
            case ControlMessage.TYPE_BACK_OR_SCREEN_ON:
                if (device.supportsInputEvents()) {
                    pressBackOrTurnScreenOn();
                }
                break;
            case ControlMessage.TYPE_EXPAND_NOTIFICATION_PANEL:
                device.expandNotificationPanel();
                break;
            case ControlMessage.TYPE_COLLAPSE_NOTIFICATION_PANEL:
                device.collapsePanels();
                break;
            case ControlMessage.TYPE_GET_CLIPBOARD:
                String clipboardText = device.getClipboardText();
                if (clipboardText != null) {
                    sender.pushClipboardText(clipboardText);  //剪切板事件
                }
                break;
            case ControlMessage.TYPE_SET_CLIPBOARD:
                boolean paste = (msg.getFlags() & ControlMessage.FLAGS_PASTE) != 0;
                setClipboard(msg.getText(), paste);
                break;
            case ControlMessage.TYPE_SET_SCREEN_POWER_MODE:
                if (device.supportsInputEvents()) {
                    int mode = msg.getAction();
                    boolean setPowerModeOk = device.setScreenPowerMode(mode);
                    if (setPowerModeOk) {
                        Ln.i("Device screen turned " + (mode == Device.POWER_MODE_OFF ? "off" : "on"));
                    }
                }
                break;
            case ControlMessage.TYPE_ROTATE_DEVICE:
                device.rotateDevice();
                break;
            default:
                // do nothing
        }
    }

接收控制流消息

public ControlMessage receiveControlMessage() throws IOException {
        ControlMessage msg = reader.next();
        while (msg == null) {
            reader.readFrom(controlInputStream);  //向控制流的输入通道内读取数据
            msg = reader.next();  //循环读取
        }
        return msg;
    }

以屏幕滚动事件为例

private boolean injectScroll(Position position, int hScroll, int vScroll) {
        long now = SystemClock.uptimeMillis();
        Point point = device.getPhysicalPoint(position);
        if (point == null) {
            // ignore event
            return false;
        }

        MotionEvent.PointerProperties props = pointerProperties[0];
        props.id = 0;

        MotionEvent.PointerCoords coords = pointerCoords[0];
        coords.x = point.getX();
        coords.y = point.getY();
        coords.setAxisValue(MotionEvent.AXIS_HSCROLL, hScroll);
        coords.setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll);

        //事件类型为ACTION_SCROLL
        MotionEvent event = MotionEvent
                .obtain(lastTouchDown, now, MotionEvent.ACTION_SCROLL, 1, pointerProperties, pointerCoords, 0, 0, 1f, 1f, DEVICE_ID_VIRTUAL, 0,
                        InputDevice.SOURCE_TOUCHSCREEN, 0);
        return device.injectEvent(event);  //事件注入机制
    }

事件注入机制

public boolean injectEvent(InputEvent inputEvent, int mode) {
        if (!supportsInputEvents()) {
            throw new AssertionError("Could not inject input event if !supportsInputEvents()");
        }

        if (displayId != 0 && !InputManager.setDisplayId(inputEvent, displayId)) {
            return false;
        }

        return serviceManager.getInputManager().injectInputEvent(inputEvent, mode);
    }

 使用了安卓内核的事件注入机制;
 到此,控制流server端分析完

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

监听剪切板事件

private static void startDeviceMessageSender(final DeviceMessageSender sender) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    sender.loop();
                } catch (IOException | InterruptedException e) {
                    // this is expected on close
                    Ln.d("Device message sender stopped");
                }
            }
        }).start();
    }

循环监听

public void loop() throws IOException, InterruptedException {
        while (true) {
            String text;
            synchronized (this) {
                while (clipboardText == null) {
                    wait();
                }
                text = clipboardText;
                clipboardText = null;
            }
            DeviceMessage event = DeviceMessage.createClipboard(text);  //安卓剪切板事件
            connection.sendDeviceMessage(event);
        }
    }

视频流相关逻辑
视频流编码

public void streamScreen(Device device, FileDescriptor fd) throws IOException {
        Workarounds.prepareMainLooper();

        try {
            internalStreamScreen(device, fd);
        } catch (NullPointerException e) {
            // Retry with workarounds enabled:
            // <https://github.com/Genymobile/scrcpy/issues/365>
            // <https://github.com/Genymobile/scrcpy/issues/940>
            Ln.d("Applying workarounds to avoid NullPointerException");
            Workarounds.fillAppInfo();
            internalStreamScreen(device, fd);
        }
    }

    private void internalStreamScreen(Device device, FileDescriptor fd) throws IOException {
        MediaFormat format = createFormat(bitRate, maxFps, codecOptions);
        device.setRotationListener(this);
        boolean alive;
        try {
            do {
                MediaCodec codec = createCodec();  //创建编码器-h264 MediaFormat.MIMETYPE_VIDEO_AVC
                IBinder display = createDisplay();
                ScreenInfo screenInfo = device.getScreenInfo();
                Rect contentRect = screenInfo.getContentRect();
                // include the locked video orientation
                Rect videoRect = screenInfo.getVideoSize().toRect();
                // does not include the locked video orientation
                Rect unlockedVideoRect = screenInfo.getUnlockedVideoSize().toRect();
                int videoRotation = screenInfo.getVideoRotation();
                int layerStack = device.getLayerStack();

                setSize(format, videoRect.width(), videoRect.height());
                configure(codec, format);  //配置编码器,包括码流,fps,I帧时间间隔等
                Surface surface = codec.createInputSurface();  //创建输入缓冲区
                setDisplaySurface(display, surface, videoRotation, contentRect, unlockedVideoRect, layerStack);
                codec.start();
                try {
                    alive = encode(codec, fd);  //屏幕编码
                    // do not call stop() on exception, it would trigger an IllegalStateException
                    codec.stop();
                } finally {
                    destroyDisplay(display);
                    codec.release();
                    surface.release();
                }
            } while (alive);
        } finally {
            device.setRotationListener(null);
        }
    }

屏幕编码

private boolean encode(MediaCodec codec, FileDescriptor fd) throws IOException {
        boolean eof = false;
        MediaCodec.BufferInfo bufferInfo = new MediaCodec.BufferInfo();  //编码缓冲区

        while (!consumeRotationChange() && !eof) {
            int outputBufferId = codec.dequeueOutputBuffer(bufferInfo, -1);  //获取输出区的缓冲的索引
            eof = (bufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0;
            try {
                if (consumeRotationChange()) {
                    // must restart encoding with new size
                    break;
                }
                if (outputBufferId >= 0) {
                    ByteBuffer codecBuffer = codec.getOutputBuffer(outputBufferId);  //根据索引获取数据

                    if (sendFrameMeta) {
                        writeFrameMeta(fd, bufferInfo, codecBuffer.remaining());  //先将帧元数据信息发送
                    }

                    IO.writeFully(fd, codecBuffer);  //对外发送编码后的帧数据
                }
            } finally {
                if (outputBufferId >= 0) {
                    codec.releaseOutputBuffer(outputBufferId, false);  //根据索引释放缓冲区
                }
            }
        }

        return !eof;
    }

以上是关于移动端超级实用工具Scrcpy操作分享 (下)的主要内容,如果未能解决你的问题,请参考以下文章

manjaro下使用scrcpy安卓设备投屏

scrcpy服务端代码分析

scrcpy服务端代码分析

Mac下安装使用scrcpy

Mac下安装使用scrcpy

安卓投屏工具---scrcpy