vdisplay 虚拟屏
vdisplay 虚拟屏
vdisplay 是 ScriptX 新增的虚拟屏模块。你可以把它先理解成一套“脚本里临时创建一个额外显示器,然后把点击、输入、截图、开应用这些动作都指到这个显示器上”的能力。
它当前已经不是单独孤零零的一个模块了,而是和下面这些地方接到了一起:
app.launchApp(...)/app.launch(...)/app.launchPackage(...)app.openAppSetting(...)/app.openUrl(...)/app.uninstall(...)app.startActivity(options)images.captureScreen(...)automator.takeScreenshot(...)/auto.takeScreenshot(...)auto.setWindowFilter(...)click(...)/longClick(...)/press(...)/swipe(...)Tap(...)/Swipe(...)back(...)/home(...)keys.Home(...)/keys.Back(...)/keys.Text(...)/keys.KeyCode(...)
所以这一页不只是讲“怎么建一个虚拟屏”,也会把你后面怎么把其他 API 指到这个虚拟屏上,一起讲清楚。
先记住这 18 条
- 这个模块的全局对象名是
vdisplay,兼容别名是$vdisplay。 vdisplay.create(...)成功后返回的是一个会话对象,下面文档里统一叫“session”。- 一个 session 里最关键的两个标识是:
session.id和session.displayId。 session.id形如vdisplay-1、vdisplay-2;displayId则是系统层真正的显示器 id。vdisplay.create(...)可以传对象,也可以直接按(width, height, dpi)这种位置参数写。width/height不传或传了无效值时,会回退到当前真实屏幕尺寸。dpi不传时,会回退到当前设备显示密度。title为空时,默认标题是ScriptX Virtual Display。backend当前只有两种值:"local"和"shell"。vdisplay.get(...)/vdisplay.destroy(...)认 3 类参数:session 对象、session id 字符串、displayId数字。showPreview(...)需要悬浮窗权限;没权限会直接抛异常,不是返回false。showPreview(rotated, callback)的回调拿到的是(x, y, action, displayId),不是MotionEvent对象本身。capture(...)返回Image | null;captureToFile(...)/captureTo(...)返回boolean。captureToFile(...)当前实际写的是 PNG。press(...)本质上是“同一点的长时长 swipe”,默认按压时长是350ms。swipe(...)默认时长是260ms。- 这组 API 里存在一批历史别名,比如
Create、GetDisplayId、LaunchApp、CaptureToFile,当前源码里它们确实还暴露着,所以这一页会全部列出来。 - 传给其他模块做虚拟屏目标参数时,最稳的做法永远是直接传
vdisplay.create()返回的 session 对象。
这一页统一的 4 个词
后面几页里会反复提到虚拟屏相关参数。为了避免一会儿叫 target、一会儿叫 session、一会儿又叫作用域,这里先统一一下叫法:
| 统一叫法 | 具体指什么 |
|---|---|
session 对象 | vdisplay.create() 返回的那个对象,例如 screen |
session id 字符串 | 形如 "vdisplay-1" 的字符串 |
虚拟屏目标参数 | 一整类“用来指向某块显示器”的参数,通常可以是 displayId、session 对象、session id 字符串,或者 target 风格对象 |
自动化显示作用域 | 通过 auto.setWindowFilter(screen)、auto.setWindowFilter(7) 等写法绑定出来的当前默认显示器上下文 |
后面如果我写“它支持虚拟屏目标参数”,默认就按这一表理解,不再每次从头展开一遍。
虚拟屏到底是什么
最容易理解的方式是把它想成:
- 你平时手机上看的那个主屏幕,是
Display.DEFAULT_DISPLAY vdisplay.create(...)会再申请出一个额外显示器- 这个显示器可以有自己的分辨率、自己的
displayId - 你可以把应用启动到它上面,再往这个显示器里点、滑、发按键、输入文本、截图
它和“普通截图”或者“无障碍窗口过滤”不是一个层级:
- 截图模块解决的是“怎么拿到图”
- 无障碍模块解决的是“怎么找节点”
vdisplay解决的是“动作和画面到底发生在哪块显示器上”
vdisplay.create(options) / vdisplay.Create(options)
创建一个新的虚拟屏 session。
const screen = vdisplay.create({
width: 720,
height: 1280,
dpi: 320,
title: "登录流程测试屏"
});
支持的两种写法
1. 对象写法
const screen = vdisplay.create({
width: 720,
height: 1280,
dpi: 320,
title: "Demo"
});
2. 位置参数写法
const screen = vdisplay.create(720, 1280, 320);
const screen2 = vdisplay.Create(1080, 1920);
参数
| 字段 / 位置 | 类型 | 可填值 | 默认值 | 说明 |
|---|---|---|---|---|
width / 第 1 个参数 | number | > 0 的整数 | 当前真实屏幕宽度 | 虚拟屏宽度 |
height / 第 2 个参数 | number | > 0 的整数 | 当前真实屏幕高度 | 虚拟屏高度 |
dpi / densityDpi / 第 3 个参数 | number | > 0 的整数 | 当前设备 densityDpi | 虚拟屏密度 |
title | string | 任意非空文本 | ScriptX Virtual Display | 预览窗口标题和会话标题 |
返回值
VDisplaySession
也就是下面会频繁提到的 session 对象。
真实行为
width/height只要小于等于0,就会退回当前真实屏幕尺寸。dpi不传时,会按当前设备显示密度取值。title去掉首尾空格后如果还是空,会回退到默认标题。- session id 字符串会自动递增,形如:
vdisplay-1vdisplay-2
- 当前后端会自动二选一:
- 能走 shell 托管时,用
shell - 否则走本地
local
- 能走 shell 托管时,用
示例:最小创建
const screen = vdisplay.create();
log(screen.id);
log(screen.displayId);
log(screen.width);
log(screen.height);
log(screen.backend);
示例:做一块 720x1280 的测试屏
const screen = vdisplay.create({
width: 720,
height: 1280,
dpi: 320,
title: "720P 测试屏"
});
log(JSON.stringify({
id: screen.id,
displayId: screen.displayId,
width: screen.width,
height: screen.height,
backend: screen.backend
}, null, 2));
vdisplay.list()
列出当前所有虚拟屏 session 的摘要信息。
const list = vdisplay.list();
log(JSON.stringify(list, null, 2));
返回值
Array<object>
每一项通常包含这些字段:
| 字段 | 类型 | 说明 |
|---|---|---|
id | string | session id 字符串,例如 vdisplay-1 |
displayId | number | 系统显示器 id |
width | number | 宽度 |
height | number | 高度 |
densityDpi | number | 密度 |
backend | string | "local" 或 "shell" |
title | string | 当前标题 |
previewVisible | boolean | 当前有没有预览悬浮窗 |
示例:把所有虚拟屏打印出来
vdisplay.list().forEach(function (item, index) {
log(
`#${index + 1} id=${item.id} displayId=${item.displayId} ` +
`size=${item.width}x${item.height} backend=${item.backend} preview=${item.previewVisible}`
);
});
vdisplay.get(sessionLike)
按 session 对象、session id 字符串或 displayId 拿回一个 session 对象。
const a = vdisplay.get("vdisplay-1");
const b = vdisplay.get(7);
const c = vdisplay.get(screen);
参数
| 可传值 | 例子 | 说明 |
|---|---|---|
| session 对象 | vdisplay.get(screen) | 最稳的写法 |
| session id 字符串 | vdisplay.get("vdisplay-1") | 直接按 id 查 |
| displayId 数字 | vdisplay.get(7) | 直接按显示器 id 查 |
返回值
VDisplaySession | null
要注意什么
这一项和别的“target 解析”API 不完全一样。vdisplay.get(...) 当前不是那种通吃任意嵌套 target 对象的解析器。
也就是说:
vdisplay.get({ target: screen });
这种写法不要当成稳定接口来依赖。最稳的仍然是直接传:
screen"vdisplay-1"7
示例
const screen = vdisplay.create({ width: 540, height: 960 });
const again = vdisplay.get(screen.id);
log(again ? again.displayId : "not found");
vdisplay.destroy(sessionLike)
销毁一个虚拟屏 session。
vdisplay.destroy(screen);
vdisplay.destroy("vdisplay-1");
vdisplay.destroy(7);
参数
和 vdisplay.get(...) 一样,支持:
- session 对象
- session id 字符串
displayId数字
返回值
boolean
| 结果 | 含义 |
|---|---|
true | 找到并成功销毁 |
false | 没找到对应 session,或销毁前就已经不存在 |
真实行为
- 如果这个 session 还开着预览窗口,会先把预览关掉。
- 然后才真正销毁 display session。
- 销毁后,原 session 对象还在 JS 变量里,但它指向的底层 session 已经不存在了。
示例
const screen = vdisplay.create({ title: "临时测试屏" });
log(vdisplay.destroy(screen)); // true
log(vdisplay.destroy(screen)); // false
vdisplay.destroyAll()
销毁当前所有虚拟屏 session,并关闭它们的预览窗口。
const count = vdisplay.destroyAll();
log(`destroyed = ${count}`);
返回值
number
表示本次实际销毁了多少个 session。
示例
vdisplay.create({ title: "A" });
vdisplay.create({ title: "B" });
vdisplay.create({ title: "C" });
const count = vdisplay.destroyAll();
log(`一次性销毁了 ${count} 个虚拟屏`);
session.id
session 的字符串 id。
类型
string
典型值
vdisplay-1vdisplay-2
什么时候最有用
- 做日志
- 保存会话标识
- 传给
images.captureScreen("vdisplay-1")
示例
const screen = vdisplay.create();
log(screen.id);
session.displayId
session 对应的系统显示器 id。
类型
number
什么时候最有用
- 你要显式调用某些支持
displayId的 API - 你想做底层 display 调试
示例
const screen = vdisplay.create();
log(screen.displayId);
Tap(100, 200, screen.displayId);
session.width
虚拟屏宽度。
类型
number
示例
const screen = vdisplay.create({ width: 720, height: 1280 });
log(screen.width); // 720
session.height
虚拟屏高度。
类型
number
示例
const screen = vdisplay.create({ width: 720, height: 1280 });
log(screen.height); // 1280
session.densityDpi
虚拟屏 densityDpi。
类型
number
示例
const screen = vdisplay.create({ dpi: 320 });
log(screen.densityDpi);
session.backend
虚拟屏当前后端类型。
类型
string
当前可能出现的值
| 值 | 含义 |
|---|---|
"local" | 本地虚拟屏后端 |
"shell" | shell 托管虚拟屏后端 |
示例
const screen = vdisplay.create();
log(`backend=${screen.backend}`);
session.title
当前 session 标题。
类型
string
说明
- 它会反映
create(...)时传入的title - 后续调用
setTitle(...)后,这个值也会跟着变
示例
const screen = vdisplay.create({ title: "登录调试屏" });
log(screen.title);
session.previewVisible
当前 session 是否已经打开预览窗口。
类型
boolean
示例
const screen = vdisplay.create();
log(screen.previewVisible); // false
screen.showPreview();
sleep(300);
log(screen.previewVisible); // true
session.getDisplayId() / session.GetDisplayId()
取当前 session 的 displayId。
返回值
number | null
什么时候它有意义
绝大多数时候,直接读 session.displayId 就够了。
这个方法更像是给旧写法保留的函数式入口。
示例
const screen = vdisplay.create();
log(screen.getDisplayId());
log(screen.GetDisplayId());
session.setTitle(title) / session.SetTitle(title)
修改当前 session 标题。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
title | string | 新标题;空白字符串会被忽略,不会清成空 |
返回值
boolean
| 结果 | 含义 |
|---|---|
true | 修改成功 |
false | session 已不存在 |
真实行为
- 标题会先做
trim() - 纯空白不会把原标题清空
- 如果这个 session 已经开了预览窗口,预览标题也会同步改
示例
const screen = vdisplay.create({ title: "旧标题" });
screen.setTitle("新标题");
log(screen.title);
session.launchApp(packageName) / session.LaunchApp(packageName)
把某个应用启动到当前虚拟屏里。
const result = screen.launchApp("com.android.settings");
log(JSON.stringify(result, null, 2));
参数
| 参数 | 类型 | 说明 |
|---|---|---|
packageName | string | 目标包名;空字符串会失败 |
返回值
object
常见字段:
| 字段 | 类型 | 说明 |
|---|---|---|
success | boolean | 是否成功 |
via | string | 常见为 context 或 shell |
displayId | number | 目标虚拟屏 displayId |
sessionId | string | 当前 session id 字符串 |
packageName | string | 你传入的包名 |
shellMode | string | 某些 shell 路径下会带出来,例如当前 shell 工作模式 |
error | string | 失败原因 |
stdout | string | 某些 shell 路径的输出 |
contextError | string | 先尝试 context 启动失败时的错误信息 |
什么时候适合用它
- 你已经创建好了虚拟屏,只想把一个普通应用首页扔进去
- 你不想自己写
app.startActivity({ target: screen, ... })
示例:启动系统设置
const screen = vdisplay.create({
width: 720,
height: 1280,
title: "设置调试屏"
});
const result = screen.launchApp("com.android.settings");
log(JSON.stringify(result, null, 2));
session.tap(x, y) / session.Tap(x, y)
在当前虚拟屏里点一个坐标。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
x | number | 横坐标 |
y | number | 纵坐标 |
返回值
boolean
说明
- 这里的坐标是当前虚拟屏自己的像素坐标
- 不是主屏坐标
- 最终会走到这个虚拟屏对应的 display input 路径
示例
screen.tap(360, 640);
screen.Tap(360, 700);
session.press(x, y, durationMs?) / session.Press(x, y, durationMs?)
在当前虚拟屏里按住某个点一段时间。
参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
x | number | 无 | 横坐标 |
y | number | 无 | 纵坐标 |
durationMs | number | 350 | 按压时长,单位毫秒 |
返回值
boolean
示例
screen.press(360, 640, 800);
session.swipe(x1, y1, x2, y2, durationMs?) / session.Swipe(x1, y1, x2, y2, durationMs?)
在当前虚拟屏里滑动。
参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
x1 | number | 无 | 起点 X |
y1 | number | 无 | 起点 Y |
x2 | number | 无 | 终点 X |
y2 | number | 无 | 终点 Y |
durationMs | number | 260 | 滑动时长 |
返回值
boolean
示例
screen.swipe(360, 1080, 360, 280);
screen.Swipe(360, 1080, 360, 280, 320);
session.key(value) / session.keyCode(value) / session.Key(value) / session.KeyCode(value)
给当前虚拟屏派发按键。
参数
| 参数 | 类型 | 可填值 | 说明 |
|---|---|---|---|
value | number | string | 3、4、"home"、"KEYCODE_BACK" 这类 | 最终会按 KeyCode 解析 |
返回值
boolean
怎么理解这 4 个名字
当前源码里它们最后都走的是同一条“按键派发”路径,所以:
key(...)keyCode(...)Key(...)KeyCode(...)
现在都可以视为“给这块虚拟屏发一个按键事件”。
示例
screen.key("home");
screen.keyCode("KEYCODE_BACK");
screen.Key(24);
screen.KeyCode("menu");
session.text(text) / session.Text(text)
往当前虚拟屏注入文本。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
text | string | 要输入的文本 |
返回值
boolean
真实行为
- 空字符串会直接返回
true %会转义成%25- 空格会转义成
%s
所以你平时直接写普通字符串就行,不需要自己再手工转一次。
示例
screen.text("hello scriptx");
screen.Text("100% done");
session.showPreview(rotated?, callback?) / session.ShowPreviewWindow(rotated?)
打开虚拟屏预览悬浮窗。
screen.showPreview(false, function (x, y, action, displayId) {
log(x, y, action, displayId);
});
参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
rotated | boolean | false | 是否把预览画面旋转 90 度显示 |
callback | function | null | 触摸预览画面时的回调 |
返回值
boolean
会不会抛异常
会。最典型的一种就是没有悬浮窗权限时:
vdisplay preview requires overlay permission
callback 会收到什么
按当前源码,回调参数依次是:
| 位置 | 类型 | 含义 |
|---|---|---|
| 第 1 个参数 | number | 映射回虚拟屏后的 X |
| 第 2 个参数 | number | 映射回虚拟屏后的 Y |
| 第 3 个参数 | number | MotionEvent.actionMasked |
| 第 4 个参数 | number | 当前虚拟屏 displayId |
常见 action 值:
| 值 | 含义 |
|---|---|
0 | ACTION_DOWN |
1 | ACTION_UP |
2 | ACTION_MOVE |
一个很重要的细节
预览窗口的触摸回调只负责把坐标告诉你,不会自动帮你完成点击。
如果你想“点预览就等于点虚拟屏”,要自己在回调里再调一次:
screen.tap(x, y)
示例:点预览就同步点虚拟屏
screen.showPreview(false, function (x, y, action) {
if (action == 0) {
screen.tap(x, y);
}
});
ShowPreviewWindow(rotated?) 和 showPreview(...) 的区别
ShowPreviewWindow(rotated?)是旧名字- 它不接收回调参数
- 真要做交互,优先写
showPreview(rotated, callback)
session.hidePreview() / session.HidePreviewWindow()
关闭当前 session 的预览窗口。
返回值
boolean
| 结果 | 含义 |
|---|---|
true | 当前有预览并且已关闭 |
false | 当前根本没开预览 |
示例
screen.hidePreview();
session.setPreviewWindowSize(width, height) / session.SetPreviewWindowSize(width, height)
修改预览窗口里图像区域的尺寸。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
width | number | 新宽度 |
height | number | 新高度 |
返回值
boolean
要注意什么
只有预览已经打开时才会成功。
如果你还没先 showPreview(),它会返回 false。
示例
screen.showPreview();
screen.setPreviewWindowSize(260, 460);
session.setPreviewWindowPos(x, y) / session.SetPreviewWindowPos(x, y)
修改预览悬浮窗位置。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
x | number | 左上角 X |
y | number | 左上角 Y |
返回值
boolean
示例
screen.showPreview();
screen.setPreviewWindowPos(40, 120);
session.setTouchCallback(callback) / session.SetTouchCallback(callback)
给已经打开的预览窗口补充或替换触摸回调。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
callback | function | null | 新回调;传 null 可以清掉 |
返回值
boolean
要注意什么
这也是“预览已经存在时”才有意义。
如果预览窗口还没创建,会返回 false。
示例
screen.showPreview();
screen.setTouchCallback(function (x, y, action) {
if (action == 0) {
log(`down at ${x}, ${y}`);
}
});
session.capture(timeoutMs?)
抓当前虚拟屏的一帧图像。
参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
timeoutMs | number | 1500 | 最长等待多久拿到一帧 |
返回值
Image | null
真实行为
- timeout 小于很小阈值时,内部仍会抬到一个最小安全值
- 如果当前后端直接抓帧失败,还会尝试镜像抓取和最近一帧缓存
示例
const image = screen.capture();
if (image) {
try {
log(`${image.width()} x ${image.height()}`);
} finally {
image.recycle();
}
}
session.captureTo(path, timeoutMs?)
把虚拟屏截图直接写到文件。
参数
| 参数 | 类型 | 默认值 | 说明 |
|---|---|---|---|
path | string | 无 | 输出文件路径 |
timeoutMs | number | 1500 | 截图超时 |
返回值
boolean
说明
这项当前本质上就是 captureToFile(...) 的别名。
示例
screen.captureTo("./output/vdisplay-home.png");
session.captureToFile(path, timeoutMs?) / session.CaptureToFile(path, timeoutMs?)
把虚拟屏截图直接写到文件。
参数
和 captureTo(...) 一样。
返回值
boolean
一个细节
当前底层实际写的是 PNG。
也就是说,就算你文件名自己写成 .jpg,脚本层这里也不要把它当成“真的会按 JPG 编码”来依赖。
示例
if (!screen.captureToFile("./output/step1.png")) {
log("保存失败");
}
session.destroy() / session.Destroy()
销毁当前 session 本身。
返回值
boolean
真实行为
- 会先尝试关闭预览窗口
- 再销毁 session
示例
const screen = vdisplay.create();
screen.destroy();
target / screen / display / vdisplay / session / displayId
这一节专门讲:把虚拟屏交给别的模块时,到底怎么传虚拟屏目标参数最稳。
下面这些 API 已经会解析虚拟屏目标参数:
app.launchApp(...)app.launch(...)app.launchPackage(...)app.openAppSetting(...)app.uninstall(...)app.openUrl(...)app.startActivity(options)images.captureScreen(...)automator.takeScreenshot(...)/auto.takeScreenshot(...)auto.setWindowFilter(...)click(...)/longClick(...)/press(...)/swipe(...)Tap(...)/Swipe(...)back(...)/home(...)- 一批
keys.*(...)
这些 API 现在通常认哪些 target 写法
| 写法 | 例子 | 是否推荐 | 说明 |
|---|---|---|---|
| 直接传 session 对象 | app.openUrl(url, screen) | 最推荐 | 最不容易写错 |
| 直接传 session id 字符串 | images.captureScreen("vdisplay-1") | 推荐 | 前提是 session 还存在 |
直接传 displayId 数字 | Tap(100, 200, screen.displayId) | 推荐 | 更底层 |
对象里写 target | app.startActivity({ target: screen, ... }) | 推荐 | 最常见的 options 写法 |
对象里写 screen | images.captureScreen({ screen, path }) | 推荐 | 明确表达“这是屏幕目标” |
对象里写 display / vdisplay / session | auto.takeScreenshot({ display: screen }) | 可用 | 当前源码都认 |
对象里写 displayId | app.startActivity({ displayId: 7, ... }) | 可用 | 直接写数字也行 |
另外,如果你手里本来就是一个 session 风格对象,顶层带着这些字段时,当前很多接入了虚拟屏解析器的 API 也能直接认:
__vdisplaySessionIdsessionIdiddisplayId
这也是为什么直接把 vdisplay.create() 返回值一路往后传,通常最省事。
一个最稳的经验
如果你自己前面已经写了:
const screen = vdisplay.create(...);
那后面所有接虚拟屏目标参数的地方,优先都直接传这个 screen 变量。
示例:把 URL 打到虚拟屏
const screen = vdisplay.create({ width: 720, height: 1280 });
app.openUrl("https://www.example.com", screen);
示例:把 startActivity 指到虚拟屏
const screen = vdisplay.create();
const result = app.startActivity({
action: "android.intent.action.VIEW",
data: "https://www.example.com/help",
target: screen,
flags: "NEW_TASK"
});
log(JSON.stringify(result, null, 2));
示例:从虚拟屏截图
const screen = vdisplay.create();
const image = images.captureScreen(screen);
try {
log(image.width(), image.height());
} finally {
image.recycle();
}
示例:让自动化显示作用域锁到某块虚拟屏
const screen = vdisplay.create();
auto.setWindowFilter(screen);
log(currentPackage());
log(currentActivity());
一段完整的新手示例
下面这段可以帮助你把“创建、启动、预览、点击、输入、截图、销毁”串起来看一遍:
auto.waitFor();
const screen = vdisplay.create({
width: 720,
height: 1280,
dpi: 320,
title: "虚拟屏演示"
});
log(JSON.stringify({
id: screen.id,
displayId: screen.displayId,
width: screen.width,
height: screen.height,
backend: screen.backend
}, null, 2));
screen.showPreview(false, function (x, y, action) {
if (action == 0) {
log(`preview down: ${x}, ${y}`);
}
});
const launchResult = screen.launchApp("com.android.settings");
log(JSON.stringify(launchResult, null, 2));
sleep(1500);
screen.swipe(360, 1100, 360, 280, 320);
sleep(500);
screen.key("back");
sleep(500);
const image = screen.capture();
if (image) {
try {
images.save(image, "./output/vdisplay-demo.png");
} finally {
image.recycle();
}
}
screen.hidePreview();
screen.destroy();
什么时候优先看别的页
- 想看把应用或 URL 指到虚拟屏的更多写法:看 app 应用能力
- 想看
images.captureScreen(...)吃虚拟屏目标参数的细节:看 images 图像与截图 - 想看
auto.setWindowFilter(...)、auto.takeScreenshot(...)在虚拟屏下怎么工作:看 automator 自动化操作-模块 - 想看
Tap / Swipe / back / home / KeyCode这些全局函数或按键函数怎么带displayId:看 coordinates 自动化操作-坐标 和 keys 按键模拟
