likes
comments
collection
share

Scrcpy Mask实现原理剖析,如何像模拟器一样用键鼠控制你的安卓设备?前端可视化、按键映射篇

作者站长头像
站长
· 阅读数 142

写在前面

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

这期,主要讲讲如何在前端实现可视化编辑和按键映射。

按键映射

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

事件监听

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

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

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

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>> 来存储不同阶段的回调函数。如此,大大简化了处理函数的逻辑。

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 时忽略此事件来解决这个问题。

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 来固定间隔触发,并且将处理逻辑都改为异步代码进行处理。

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);
}

可视化编辑配置

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

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

Scrcpy Mask实现原理剖析,如何像模拟器一样用键鼠控制你的安卓设备?前端可视化、按键映射篇

渲染

在 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,如此可以保证坐标点处于按钮的中心。

拖拽

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

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

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

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

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
评论
请登录