Scrcpy Mask实现原理剖析,如何像模拟器一样用键鼠控制你的安卓设备?架构、通信篇

850 阅读5分钟

写在前面

Scrcpy Mask 是我近期开发的一款跨平台桌面客户端,是为了在电脑上像模拟器一样用键鼠控制你的安卓设备(打手游)。

简单来说原理是:实现了 Scrcpy 客户端,然后通过 scrcpy-server 来控制安卓设备。本文主要是剖析一下项目的一些细节,供大家参考。

框架选择

首先,项目开始前我就决定不论用什么框架,一定要带上 Rust,作为一个 Rust 实战项目。

Tarui 具有启动和运行速度快,构建体积小等优点。但是对开发难度更大,目前仍然存在很多未修复的问题。

Electron 具有兼容性好,API 丰富等优点。但是体积庞大,性能稍差,虽然可以通过 Rust 编写 node.addon 来提高性能,但终究是有很多性能损耗。

而对于本项目,尽可能低延迟也就是高性能是首选。

其实,Rust 还可以使用一些 GUI 库比如 egui 等等来构建桌面软件,这样的性能才是最优秀的。但是本人水平有限,使用那些 Gui 库很难实现配置可视化编辑等交互复杂的功能。综合考虑,选择了 Tauri 框架。

软件架构

本项目实现的基本逻辑是通过监听鼠标、键盘的动作,将相应的数据包发送给运行在 Android 设备上的 scrcpy-server 程序,scrcpy-server 根据收到的数据包执行相应的操作。

scrcpy-server 程序和桌面客户端需要通过 Socket 进行连接,遵守 scrcpy-server 定义的应用层协议进行通信。

由于Web不支持建立普通的Tcp连接,仅支持WebSocket,HTTP等高级协议,所以只能通过调用Rust暴露的接口来建立连接和通信。这样做的缺点是Web和Rust通信要经过内部通信序列化传输,造成一定的延迟和性能损耗。

由此确定了软件的架构如图:

软件架构.png

控制流程

主要依据 scrcpy 的控制流程: scrcpy 开发者文档

控制流程.png

控制协议

在 scrcpy 的源码中给出了控制协议序列化和反序列化的测试集,由此分析出控制协议的各种数据包结构,然后用 rust 实现相应数据包的构造即可。

以其中注入按键码的数据包为例:

struct sc_control_msg msg = {
    .type = SC_CONTROL_MSG_TYPE_INJECT_KEYCODE,
    .inject_keycode = {
        .action = AKEY_EVENT_ACTION_UP,
        .keycode = AKEYCODE_ENTER,
        .repeat = 5,
        .metastate = AMETA_SHIFT_ON | AMETA_SHIFT_LEFT_ON,
    },
};
// 对应的二进制数据结构为
const uint8_t expected[] = {
    SC_CONTROL_MSG_TYPE_INJECT_KEYCODE,
    0x01, // AKEY_EVENT_ACTION_UP
    0x00, 0x00, 0x00, 0x42, // AKEYCODE_ENTER
    0x00, 0x00, 0x00, 0X05, // repeat
    0x00, 0x00, 0x00, 0x41, // AMETA_SHIFT_ON | AMETA_SHIFT_LEFT_ON
};

可以看出 type 字段占 1 个字节,action 字段占 1 个字节,keycode 字段占 4 个字节,repeat 字段 4 个字节,metastate 字段 4 个字节

rust 实现构造数据包的部分代码为:

pub fn gen_inject_key_ctrl_msg(
    ctrl_msg_type: u8,
    action: u8,
    keycode: u32,
    repeat: u32,
    metastate: u32,
) -> Vec<u8> {
    let mut buf = vec![0; 14];
    buf[0] = ctrl_msg_type;
    buf[1] = action;
    binary::write_32be(&mut buf[2..6], keycode);
    binary::write_32be(&mut buf[6..10], repeat);
    binary::write_32be(&mut buf[10..14], metastate);
    buf
}

在 Rust 后端暴露接受相应参数的接口,然后在 Web 前端使用 TypeScript 定义好 actionkeycode 等等枚举,实现调用接口的方法,即可最终完成 Web 前端接受用户输入,遵守控制协议发送相应数据包给安卓设备上的 scrcpy-server 程序,完成对应的控制操作。

Web 前端部分代码为:

export enum AndroidKeycode {
AKEYCODE_UNKNOWN         = 0,
AKEYCODE_SOFT_LEFT       = 1,
AKEYCODE_SOFT_RIGHT      = 2,
AKEYCODE_HOME            = 3,
// omit more enumerations here...
}

export async function sendInjectKeycode(payload: InjectKeycode) {
  await sendControlMsg({
    msgType: ControlMsgType.ControlMsgTypeInjectKeycode,
    msgData: payload,
  });
}

interface InjectKeycode {
  action: AndroidKeyEventAction;
  keycode: AndroidKeycode;
  repeat: number;
  metastate: AndroidMetastate;
}

多线程通信

本项目中存在多个线程,各个线程需要协作以控制程序的正常运行。而多线程协作的关键就是通信。

多线程通信的结构图大致如下:

多线程通信.png

简单捋一捋通信相关的流程:

  1. Tarui 前端发送指令,通过 IPC 通信通知 Tauri 后端线程开始控制流程。
  2. Tarui 前端监听 Tauri 后端发来的特定事件,并在收到事件时根据事件信息展示相关内容与用户交互。
  3. Tauri 前端在收到用户交互时,发送特定事件到 Tauri 后端。
  4. Tauri 后端启动安卓设备上的 Scrcpy-server 程序,新建 Socket 客户端线程,连接到 Scrcpy-server。
  5. Socket 客户端拆分为 reader 和 writer 两个线程,分别创建 Tokio 通道与 Tauri 后端线程进行通信。
  6. Tauri 后端线程监听 3. 中来自前端的特定事件,并在收到事件时将相关信息通过 Tokio 通道转发到 Socket writer 线程,从而转化为向 Scrcpy-server 发送数据包。
  7. Socket reader 线程则接收 Scrcpy-server 发来的数据包,通过 Tokio 通道转发到 Tauri 后端,最终通过 2. 中的事件发送给前端。

由于 Rust 的安全特性,通过共享变量来实现多线程通信不太可行,正确的方式是通过 Tokio 的通道进行通信,不过这也加大了代码的复杂程度。

这个多线程通信比较绕,表达能力有限,见谅。

最后

今天的分享就这么多,还有一些关于按键映射处理细节、坐标计算等等放在下期吧。