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

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

写在前面

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 实现相应数据包的构造即可。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 实现构造数据包的部分代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 前端部分代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
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 的通道进行通信,不过这也加大了代码的复杂程度。

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

按键映射

此处介绍一下按键映射的总体实现方式,并不涉及具体按钮的功能实现。

事件监听

本项目使用 Tauri+Vue 进行开发,如果只需要简单的按键绑定,那么使用 Vue 的 @keyup.xxx 写死就好了。但是如果需要对大量按键进行动态的监听,还是需要用原生的方法 element.addEventListenerkeyup 等事件进行监听。

由于本项目中不仅有键盘按键映射,还有鼠标按键映射。因此添加 keydown, keyup, mousedown, mouseup 事件的监听,然后根据 event.code 之类的属性来确定当前按下的键,视情况执行对应的回调。

对于一些特殊的功能(比如施放技能等)还需要鼠标位置作为参数。因此要添加 mousemove 事件的监听,以获得鼠标位置。

1
2
3
4
function handleMouseMove(event: MouseEvent) {
mouseX = event.clientX;
mouseY = event.clientY;
}

不过有一点缺陷:当鼠标移出元素所在区域后就难以再通过 mousemove 来继续获取鼠标位置(如果为 window 添加此监听时,当鼠标在窗口内按下,哪怕拖动到窗口外仍然可触发 mousemove 事件)

事件处理

对于一次按键,本项目将其分为三个阶段分别处理:

  1. 按下 down,在按下瞬间触发一次
  2. 按住 loop,在按下过程中循环快速触发
  3. 抬起 up,在抬起瞬间触发一次

为了方便事件处理过程,本项目使用 Map<string, boolean> 来存储所有需要监听按键的按下状态,使用三个 Map<string, () => Promise<void>> 来存储不同阶段的回调函数。如此,大大简化了处理函数的逻辑。

1
2
3
4
const downKeyMap: Map<string, boolean> = new Map();
const downKeyCBMap: Map<string, () => Promise<void>> = new Map();
const loopDownKeyCBMap: Map<string, () => Promise<void>> = new Map();
const upKeyCBMap: Map<string, () => Promise<void>> = new Map();

按下、抬起

很简单, keydownkeyup 就分别对应着按下和抬起这两个阶段。

但是对于 keydown 事件,如果长时间按住一个键,那么将会重复触发 keydown 事件,这就不符合按下阶段只触发一次的特性。此时可以通过判断 event.repeattrue 时忽略此事件来解决这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function keydownHandler(event: KeyboardEvent) {
event.preventDefault();
if (event.repeat) return;
if (downKeyMap.has(event.code)) {
downKeyMap.set(event.code, true);
// execute the down callback (if there is) asyncily
let cb = downKeyCBMap.get(event.code);
if (cb) cb();
}
}

function keyupHandler(event: KeyboardEvent) {
event.preventDefault();
if (downKeyMap.has(event.code)) {
downKeyMap.set(event.code, false);
// execute the up callback (if there is) asyncily
let cb = upKeyCBMap.get(event.code);
if (cb) cb();
}
}

按住

对于按住阶段,需要快速的触发相关回调。

比如在施放技能的功能中,只有足够高的触发频率,才能在按住对应按键时及时发送相关的触摸事件,从而保证技能施放的方向能及时更新。一般来说,触发频率越高,更新越及时,操作就表现得越流畅。当然,触发频率过高也会消耗大量的性能,甚至导致程序卡顿。

在按住的事件处理,经过了好几次的调整才最终达到理想的效果。

keydown

最初,我尝试在 keydown 事件中当 event.repeattrue 时进行处理。

但是,在 Windows 系统中需要按下一段时间后才会开始快速触发事件,而在 macOS 系统中则是固定较低频率的循环触发事件。这样显然无法达到预期的效果。

mousemove

然后,我尝试在 mousemove 事件中处理,因为只要鼠标在移动就能快速触发大量 mousemove 事件。

但是,这样做首先就存在一些性能方面的问题。特别是最初的事件处理逻辑中都是同步代码,处理时间相对较长时甚至会影响到后续 mousemove 事件的触发频率,表现为延迟、卡顿。

最重要的是,当鼠标不移动时处理逻辑将不会执行。这同样无法达到预期的效果。

setIntervalsettimeout

后来,我尝试使用 setInterval 来固定间隔触发,并且将处理逻辑都改为异步代码进行处理。

1
2
3
4
5
setTimeout(() => {
loopDownKeyCBMap.forEach((cb) => {
cb(); // cb: () => Promise<void>
});
}, 100);

这样一番操作之后,效果提升显著,表现的基本很流畅了。

可惜的是,虽然在 Windows 系统中表现基本稳定,但是在 macOS 系统中由于 Tauri 使用的是 safari (Webkit) 浏览器控件, setInterval 执行一段时间后就会出现定时器无响应(即定时器无故停止)异常。着实让人痛苦。

除此之外, setInterval 的间隔很难把控。间隔太短会导致性能开销过大(甚至上一次触发还没结束下一个又开始了),隔太长又会导致操作非常不流畅。

我也尝试过 settimeout 嵌套并配合一个较短的间隔,但是效果差不多。而且同样没能解决 macOS 系统中遇到的无响应问题。

requestAnimationFrame

最终,找了很多资料后,我发现了 requestAnimationFrame 这个 API。

当你准备更新在屏动画时你应该调用此方法。这将使浏览器在下一次重绘之前调用你传入给该方法的动画函数(即你的回调函数)。回调函数执行次数通常是每秒 60 次,但在大多数遵循 W3C 建议的浏览器中,回调函数执行次数通常与浏览器屏幕刷新次数相匹配。

这个类似帧率的高触发频次,恰好符合了我对按住回调触发频率的要求,而且在 Windows 和 macOS 系统中都表现正常。

1
2
3
4
5
6
function execLoopCB() {
loopDownKeyCBMap.forEach((cb) => {
cb();
});
requestAnimationFrame(execLoopCB);
}

可视化编辑配置

要实现可视化配置,最主要的就是两部分内容:

  1. 根据配置文件渲染不同按钮
  2. 为按钮添加拖拽移动功能

渲染

在 Vue 中根据配置文件渲染按钮并不困难,只是比较繁琐。

首先,需要设定好容器的样式。注意通过 position: relative 来为子元素创建绝对定位参考:

1
2
3
4
5
6
7
8
.keyboard {
color: var(--light-color);
background-color: rgba(0, 0, 0, 0.5);
overflow: hidden;
position: relative;
user-select: none;
-webkit-user-select: none;
}

接着根据按钮的类型,编写好各种按钮组件。读取配置文件内容后,在容器中使用 v-for 进行渲染即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<template v-for="(_, index) in store.editKeyMappingList">
<KeySteeringWheel
v-if="store.editKeyMappingList[index].type === 'SteeringWheel'"
:index="index"
/>
<KeySkill
v-else-if="
store.editKeyMappingList[index].type === 'DirectionalSkill' ||
store.editKeyMappingList[index].type === 'DirectionlessSkill' ||
store.editKeyMappingList[index].type === 'TriggerWhenPressedSkill' ||
store.editKeyMappingList[index].type ===
'TriggerWhenDoublePressedSkill'
"
:index="index"
/>
<KeyObservation
v-else-if="store.editKeyMappingList[index].type === 'Observation'"
:index="index"
/>
<KeyCommon v-else :index="index" />
</template>

其中,每个按钮的位置可以使用绝对定位来设置:

1
2
3
4
5
6
7
8
9
10
11
12
<div
:class="{ active: isActive }"
:style="{
left: `${keyMapping.posX - 20}px`,
top: `${keyMapping.posY - 20}px`,
}"
@mousedown="dragHandler"
class="key-common"
ref="elementRef"
>
...
</div>

其中 -20 是因为按钮的尺寸为 40*40,如此可以保证坐标点处于按钮的中心。

拖拽

按钮需要能够拖拽来调整位置,可以通过添加事件监听来实现。

最初,我想当然的为按钮元素添加了 mousedownmousemovemouseup 事件监听。但是这样实现存在一个严重的问题:

mousemovemouseup 只会当鼠标在元素上时才触发,如果鼠标移动过快等原因导致移出元素所在位置后,事件就无法触发。

因此,正确的方式应该是仅仅为按钮添加 mousedown 事件监听,然后在 mousedown 事件处理中为 window 添加 mousemovemouseup 事件监听,最后在 mouseup 事件处理中动态移除 mousemovemouseup 的监听。

window 添加监听的好处是:哪怕拖拽时鼠标移动到窗口外,事件仍然会正常触发。

而在 mousemove 的事件处理中,不仅仅需要根据当前移动的偏移量修改响应式变量的坐标相关数据,还要将修改后的坐标局限在容器的范围内,因为显然坐标超出范围后,按钮是看不见的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
function dragHandler(downEvent: MouseEvent) {
keyboardStore.activeButtonIndex = props.index;
keyboardStore.showButtonSettingFlag = false;
const oldX = keyMapping.value.posX;
const oldY = keyMapping.value.posY;
const element = elementRef.value;
if (element) {
// 根据容器尺寸确定最大坐标
const keyboardElement = document.getElementById(
"keyboardElement"
) as HTMLElement;
const maxX = keyboardElement.clientWidth - 20;
const maxY = keyboardElement.clientHeight - 20;

// 添加mousemove事件处理
const x = downEvent.clientX;
const y = downEvent.clientY;
const moveHandler = (moveEvent: MouseEvent) => {
let newX = oldX + moveEvent.clientX - x;
let newY = oldY + moveEvent.clientY - y;
newX = Math.max(20, Math.min(newX, maxX));
newY = Math.max(20, Math.min(newY, maxY));
keyMapping.value.posX = newX;
keyMapping.value.posY = newY;
};
window.addEventListener("mousemove", moveHandler);

// 添加mouseup事件处理
const upHandler = () => {
window.removeEventListener("mousemove", moveHandler);
window.removeEventListener("mouseup", upHandler);
if (oldX !== keyMapping.value.posX || oldY !== keyMapping.value.posY) {
keyboardStore.edited = true;
}
};
window.addEventListener("mouseup", upHandler);
}
}

技能键

技能键,在王者之类的游戏中是必不可少的。一般来说,一个技能的释放需要分为三个过程,恰好对应按键映射的三个阶段:

  1. 按下阶段,此时需要发送 touch down 来触摸技能键所在坐标
  2. 按住阶段,此时需要根据鼠标的位置发送 touch move 来移动触摸点
  3. 抬起阶段,此时需要根据鼠标的位置发送 touch up 来抬起触摸点

显然,触摸技能键所在坐标是非常简单。而难点在于如何根据鼠标位置计算出触摸点的新坐标。

我们可以从简单到困难的思考这个问题:

版本 1

触摸点相对技能键位置的偏移 = 鼠标相对蒙版中心位置的偏移。这样实现起来很简单,粗浅地实现了技能键的坐标计算。

1
2
3
4
5
6
7
8
9
function mouseToOffset(mouse: number, center: number) {
return mouse-center;
}

const centerX = maskSizeW * 0.5;
const centerY = maskSizeH * 0.5;

const targetX = skillX + mouseToOffset(mouseX, centerX);
const targetY = skillY + mouseToOffset(mouseY, centerY);

但是存在一个明显的问题:鼠标相对蒙版中心位置的偏移量可能非常大,直接作为触摸点的偏移量,不太合适。

此外,鼠标的坐标是相对窗口左上角的,并不是相对蒙版左上角的,这一点也需要调整。

版本 2

为了避免偏移量过大,我们可以将偏移限制在一个圆的范围内。此外,对于鼠标的坐标还需要减去蒙版左上角的坐标来进行转换。

![[圆坐标转换.png]]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const centerX = maskSizeW * 0.5;
const centerY = maskSizeH * 0.5;
// 减去蒙版坐标后再计算偏移量
const cOffsetX = clientPos.x - 70 - centerX;
const cOffsetY = clientPos.y - 30 - centerY;

// 计算距离
const offsetD = Math.sqrt(cOffsetX ** 2 + cOffsetY ** 2);
const maxD = 120;

if(offsetD>maxD){
offsetX = Math.round((maxD / offsetD) * cOffsetX),
offsetY = Math.round((maxD / offsetD) * cOffsetY),
}else{
offsetX = cOffsetX;
offsetY = cOffsetY;
}

这样偏移量都在一个圆内,也是显得有模有样了。但还是有一些问题:

  1. 鼠标只要距离中心稍微远一点,就会达到 maxD,这样很难精细的控制施法范围较大的技能
  2. maxD=120 但是这个 120 是相对鼠标在蒙版上的距离,而蒙版的比例尺和安卓设备分辨率的比例尺是不一致的

版本 3

对于问题 1,可以通过一个缩放来解决。首先,我们假设鼠标移动的最远距离为半个屏幕高度。从而计算出鼠标实际距离和最大距离的比例,将最终 maxD 根据这个比例进行缩放。

1
2
3
4
5
// ..。
const rangeD = maskSizeH - centerY;
const factor = Math.max(offsetD / rangeD, 1);
offsetX = Math.round((maxD / offsetD) * cOffsetX * factor),
offsetY = Math.round((maxD / offsetD) * cOffsetY * factor),

对于问题 2,只需要将 maxD 先转换为相对设备分辨率的值即可:

1
const maxLength = (120 / maskSizeH) * screenSizeH;

这样处理之后,鼠标移动对于技能的偏移量就显得不再那么灵敏了。

这样又出现了一个问题:有的技能的范围很小,需要灵敏度高一点,最好这个灵敏度是可调的。

版本 4

所以,我们添加一个百分比参数 range,值为 0~100。0 代表无穷灵敏,即不论鼠标偏移量多小,都直接移动 maxLength 的距离。100 则代表版本 3 中的情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const rangeD = (maskSizeH - centerY) * range * 0.01;
if (offsetD >= rangeD) {
// include the case of rangeD == 0
return {
offsetX: Math.round((maxLength / offsetD) * cOffsetX),
offsetY: Math.round((maxLength / offsetD) * cOffsetY),
};
} else {
const factor = offsetD / rangeD;
return {
offsetX: Math.round((cOffsetX / rangeD) * maxLength * factor),
offsetY: Math.round((cOffsetY / rangeD) * maxLength * factor),
};
}

如此,这个技能释放其实就没什么问题了。

但是对于王者荣耀,还存在两个影响技能指向准确性的问题:

  1. 游戏中角色所在位置并不是屏幕的正中心,而是有一定的偏移
  2. 游戏中视角并不是垂直向下的,而是存在一个投影,这可以从技能指示范围是一个椭圆而不是圆看出

版本 5

版本 4 中的问题,可以在这个 issue 中查看:

[BUG] The skill indicator direction isn’t consistent with the mouse in “The Honor of Kings” · Issue #5

其实解决并没有那么困难,只是一时可能想不到。

  1. 根据截图看出这个偏移量是蒙版高度的 0.066 倍,只要将初始的鼠标 y 坐标减去这个偏移量即可
  2. 根据截图看出椭圆的长短轴比例为 450 : 315,所以只要 cOffsetX*0.7 即可

在此给出最终的技能键偏移计算算法,现在技能释放的方向就非常准确了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
function clientPosToSkillOffset(
clientPos: { x: number; y: number },
range: number
): { offsetX: number; offsetY: number } {
const maxLength = (120 / maskSizeH) * screenSizeH;
const centerX = maskSizeW * 0.5;
const centerY = maskSizeH * 0.5;

// 解决问题1
clientPos.y -= maskSizeH * 0.066;

// 解决问题2
const cOffsetX = (clientPos.x - 70 - centerX) * 0.7;
const cOffsetY = clientPos.y - 30 - centerY;
const offsetD = Math.sqrt(cOffsetX ** 2 + cOffsetY ** 2);
if (offsetD == 0) {
return {
offsetX: 0,
offsetY: 0,
};
}

const rangeD = (maskSizeH - centerY) * range * 0.01;
if (offsetD >= rangeD) {
// include the case of rangeD == 0
return {
offsetX: Math.round((maxLength / offsetD) * cOffsetX),
offsetY: Math.round((maxLength / offsetD) * cOffsetY),
};
} else {
const factor = offsetD / rangeD;
return {
offsetX: Math.round((cOffsetX / rangeD) * maxLength * factor),
offsetY: Math.round((cOffsetY / rangeD) * maxLength * factor),
};
}
}

题外话

当时想怎么写这个技能键坐标算法的时候,掏出初中的知识画了个草稿哈哈哈

作者

AkiChase

发布于

2024-05-12

更新于

2024-05-12

许可协议