Scrcpy Mask实现原理剖析,如何像模拟器一样用键鼠控制你的安卓设备?前端可视化、按键映射篇
写在前面
Scrcpy Mask 是我近期开发的一款跨平台桌面客户端,是为了在电脑上像模拟器一样用键鼠控制你的安卓设备(打手游)。
这期,主要讲讲如何在前端实现可视化编辑和按键映射。
按键映射
此处介绍一下按键映射的总体实现方式,并不涉及具体按钮的功能实现。
事件监听
本项目使用 Tauri+Vue 进行开发,如果只需要简单的按键绑定,那么使用 Vue 的 @keyup.xxx
写死就好了。但是如果需要对大量按键进行动态的监听,还是需要用原生的方法 element.addEventListener
对 keyup
等事件进行监听。
由于本项目中不仅有键盘按键映射,还有鼠标按键映射。因此添加 keydown
, keyup
, mousedown
, mouseup
事件的监听,然后根据 event.code
之类的属性来确定当前按下的键,视情况执行对应的回调。
对于一些特殊的功能(比如施放技能等)还需要鼠标位置作为参数。因此要添加 mousemove
事件的监听,以获得鼠标位置。
function handleMouseMove(event: MouseEvent) {
mouseX = event.clientX;
mouseY = event.clientY;
}
不过有一点缺陷:当鼠标移出元素所在区域后就难以再通过 mousemove
来继续获取鼠标位置(如果为 window
添加此监听时,当鼠标在窗口内按下,哪怕拖动到窗口外仍然可触发 mousemove
事件)
事件处理
对于一次按键,本项目将其分为三个阶段分别处理:
- 按下 down,在按下瞬间触发一次
- 按住 loop,在按下过程中循环快速触发
- 抬起 up,在抬起瞬间触发一次
为了方便事件处理过程,本项目使用 Map<string, boolean>
来存储所有需要监听按键的按下状态,使用三个 Map<string, () => Promise<void>>
来存储不同阶段的回调函数。如此,大大简化了处理函数的逻辑。
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();
按下、抬起
很简单, keydown
和 keyup
就分别对应着按下和抬起这两个阶段。
但是对于 keydown
事件,如果长时间按住一个键,那么将会重复触发 keydown
事件,这就不符合按下阶段只触发一次的特性。此时可以通过判断 event.repeat
为 true
时忽略此事件来解决这个问题。
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.repeat
为 true
时进行处理。
但是,在 Windows 系统中需要按下一段时间后才会开始快速触发事件,而在 macOS 系统中则是固定较低频率的循环触发事件。这样显然无法达到预期的效果。
mousemove
然后,我尝试在 mousemove
事件中处理,因为只要鼠标在移动就能快速触发大量 mousemove
事件。
但是,这样做首先就存在一些性能方面的问题。特别是最初的事件处理逻辑中都是同步代码,处理时间相对较长时甚至会影响到后续 mousemove
事件的触发频率,表现为延迟、卡顿。
最重要的是,当鼠标不移动时处理逻辑将不会执行。这同样无法达到预期的效果。
setInterval
,settimeout
后来,我尝试使用 setInterval
来固定间隔触发,并且将处理逻辑都改为异步代码进行处理。
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 系统中都表现正常。
function execLoopCB() {
loopDownKeyCBMap.forEach((cb) => {
cb();
});
requestAnimationFrame(execLoopCB);
}
可视化编辑配置
要实现可视化配置,最主要的就是两部分内容:
- 根据配置文件渲染不同按钮
- 为按钮添加拖拽移动功能
渲染
在 Vue 中根据配置文件渲染按钮并不困难,只是比较繁琐。
首先,需要设定好容器的样式。注意通过 position: relative
来为子元素创建绝对定位参考:
.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
进行渲染即可:
<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>
其中,每个按钮的位置可以使用绝对定位来设置:
<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
,如此可以保证坐标点处于按钮的中心。
拖拽
按钮需要能够拖拽来调整位置,可以通过添加事件监听来实现。
最初,我想当然的为按钮元素添加了 mousedown
,mousemove
,mouseup
事件监听。但是这样实现存在一个严重的问题:
mousemove
和 mouseup
只会当鼠标在元素上时才触发,如果鼠标移动过快等原因导致移出元素所在位置后,事件就无法触发。
因此,正确的方式应该是仅仅为按钮添加 mousedown
事件监听,然后在 mousedown
事件处理中为 window
添加 mousemove
和 mouseup
事件监听,最后在 mouseup
事件处理中动态移除 mousemove
和 mouseup
的监听。
为
window
添加监听的好处是:哪怕拖拽时鼠标移动到窗口外,事件仍然会正常触发。
而在 mousemove
的事件处理中,不仅仅需要根据当前移动的偏移量修改响应式变量的坐标相关数据,还要将修改后的坐标局限在容器的范围内,因为显然坐标超出范围后,按钮是看不见的。
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);
}
}
最后
今天的分享就这么多,关于一些按键功能的具体实现、坐标计算等下期继续。
转载自:https://juejin.cn/post/7367620233140748299