automator 自动化操作-模块
automator 自动化操作-模块
这一页专门讲 ScriptX 里已经落地的自动化模块,也就是:
auto这一组无障碍自动化入口automator.takeScreenshot()/auto.takeScreenshot()auto.saveScreenRoots()selector()和一整套文本 / id / 类名 / 包名筛选器UiObject、UiObjectCollection、UiRectcurrentPackage()/currentActivity()
这里我会尽量按“第一次接触也能照着写”的方式来展开。不是只列一个方法名,而是把这些地方也讲清楚:
- 参数到底能传什么。
- 哪些值是固定可选值。
- 省略参数时会发生什么。
- 返回值失败时是
null、false还是空集合。 - 哪些地方会抛异常。
- 哪些写法看起来像能用,实际上容易踩坑。
这一页只覆盖当前 ScriptX 源码已经实现的接口,不按 Auto.js 全量目录去脑补不存在的 API。坐标点击、按压、滑动已经单独拆到 coordinates 自动化操作-坐标。
先分清对象形态
这一组 API 现在有一个很关键的运行时前提,文档先提前讲清楚,不然后面很多“为什么这样写能用 / 那样写不行”会看着别扭。
auto是一个直接挂进全局 scope 的标准作用域对象。automator也是一个直接挂进全局 scope 的标准作用域对象。auto这个对象本身额外实现了“可直接调用”,所以auto()仍然成立。automator当前不是可调用对象,别写automator()。auto.service、auto.windows、auto.root、auto.rootInActiveWindow、auto.windowRoots这几个是 getter 属性,不是函数,所以不要补()
也就是说,下面这些写法现在都属于正常写法:
const a = auto;
const waitFor = auto.waitFor;
const shot = automator.takeScreenshot;
waitFor();
const root = a.root;
const image = shot();
image.recycle();
但也要注意一件事:
const root = auto.root取到的是当次读取出来的结果,不是“后面会自动跟着页面变化的活引用”。- 想拿最新窗口、最新 root、最新 service,就重新再读一次
auto.xxx。
先记住这 16 条
- 这一整组能力的前提是无障碍服务可用;服务没起来时,很多 API 不是返回空,而是直接抛错或返回
false。 auto/automator现在都是标准作用域对象;其中auto还能直接调用,automator不能。auto.service、auto.windows、auto.root这一组是getter 属性,不是auto.root()这种函数调用。- 这几个 getter 每次读取都会重新取当前值;你先保存到变量里也能正常用,但那只是当次结果,不会自动刷新。
auto()是“尝试就绪”,auto.waitFor()才是“等到就绪再继续”。auto.setMode("fast")管的是窗口快照缓存策略,不是检索算法本身。auto.setSearchMode("normal" | "smart" | "fast")管的是查找流程:要不要先走快速搜索、找不到时要不要回退。auto.setFlags(...)当前源码里只会保存并返回字符串列表,还没有看到别的逻辑真正读取这组 flag。auto.setWindowFilter(filterOrTarget)现在不只认函数;它还能直接吃虚拟屏目标参数或null,并把整套自动化显示作用域锁到指定显示器上。selector()和它后面的链式条件不会原地修改旧对象,每次都是返回一个新的UiSelector。text()、desc()、className()、packageName()这种精确匹配方法,如果你不传参数,实际是在匹配“空值 / 没值”。textContains()、textStartsWith()、textEndsWith()这类方法如果你把参数漏掉了,底层会拿空字符串参与匹配,结果往往会非常宽。findOne()和waitFor()在当前实现里本质上是同一套等待逻辑;不传超时就是一直等,传无效超时通常会退成“立刻查一次”。UiObject.click()会先尝试无障碍节点动作,失败后再回退到按控件中心点点击;clickCenter()则是直接点中心,不走节点动作。UiObject.find(null)、UiObjectCollection.find(null)、selector().find()这种“空选择器”写法,等价于“不过滤,整棵子树全收”,很容易一次拿到很多节点。auto.saveScreenRoots(savePath, windowId?)会把当前屏幕布局树和截图一起导出成一个.zip包,特别适合做无障碍页面取证、发给别人排查,或者后面自己回放分析。
auto(mode?)
auto 是一个直接放在全局 scope 里的标准作用域对象。只是这个对象本身实现成了函数对象,所以你既能把它当对象用,也能直接调用它。
最常见的两种写法:
auto();
auto("fast");
下面这些写法按当前源码也都是稳定的:
const a = auto;
const waitFor = auto.waitFor;
waitFor();
a.setSearchMode("smart");
参数
| 参数 | 类型 | 可填值 | 默认值 | 说明 |
|---|---|---|---|---|
mode | string | "normal"、"fast" | 不传时不改当前模式 | 先设置模式,再尝试让无障碍服务进入可用状态 |
返回值
boolean
真实行为
- 如果你传了
mode,它会先调用auto.setMode(mode)。 - 然后尝试让无障碍服务进入可用状态。
- 如果服务已经可用,返回
true。 - 如果服务正在准备但还没连上,可能返回
false。 - 如果系统明确判断“无障碍没启用”,当前实现可能会先拉起系统无障碍设置页,然后抛出异常。
什么时候用
- 你只想做一次“轻量就绪检查”。
- 你准备自己决定失败时是提示用户、退出脚本,还是继续别的逻辑。
示例
try {
if (!auto()) {
toast("无障碍还没就绪,稍后再试");
exit();
}
} catch (err) {
toastLog(`auto() 失败: ${err}`);
exit();
}
auto("fast");
const ok = text("允许").findOne(800);
if (ok) {
ok.click();
}
auto.waitFor()
这是更适合新手上手的入口。
auto.waitFor();
参数
无
返回值
boolean
真实行为
- 会一直等到无障碍服务真的可用。
- 如果系统判断服务没启用,源码会尝试拉起无障碍设置页。
- 等待期间每轮大约会 sleep
120ms再继续轮询。
和 auto() 的区别
| 写法 | 特点 |
|---|---|
auto() | 尝试就绪,不保证等到成功 |
auto.waitFor() | 阻塞等待,直到服务可用才继续 |
示例
toast("请确认无障碍已经开启");
auto.waitFor();
toast("无障碍已就绪");
auto.setMode(value)
auto.setMode("normal");
auto.setMode("fast");
参数
| 参数 | 类型 | 可填值 | 默认值 / 空值 | 说明 |
|---|---|---|---|---|
value | string | "normal"、"fast" | null、"" 都按 "normal" 处理 | 设置窗口快照缓存策略 |
返回值
string
返回标准化后的模式名,也就是:
"normal""fast"
两种模式的差别
| 值 | 行为 |
|---|---|
"normal" | 不保留快照缓存;切回该模式时会顺手清缓存 |
"fast" | 活动窗口和显式窗口的快照会短暂复用,当前源码缓存 TTL 大约是 450ms |
要注意什么
- 这个模式只影响快照缓存,不直接决定选择器是不是走快速搜索。
- 非法值会直接抛异常。
auto mode must be fast or normal
示例
auto.waitFor();
auto.setMode("fast");
for (let i = 0; i < 5; i++) {
const node = text("消息").findOnce();
if (node) {
log(node.bounds().toString());
}
}
auto.setMode("normal"); // 页面刚发生明显变化时,重新走全量抓取更稳
auto.setSearchMode(value)
auto.setSearchMode("normal");
auto.setSearchMode("smart");
auto.setSearchMode("fast");
参数
| 参数 | 类型 | 可填值 | 默认值 / 空值 | 说明 |
|---|---|---|---|---|
value | string | "normal"、"smart"、"fast" | null、"" 都按 "normal" 处理 | 设置选择器查找流程 |
返回值
string
返回标准化后的模式名:
"normal""smart""fast"
三种模式真正差在哪
| 值 | 行为 |
|---|---|
"normal" | 不走快速搜索,直接按常规树遍历查 |
"smart" | 先尝试快速搜索;如果没命中,再回退到常规遍历 |
"fast" | 只走快速搜索;快速搜索没命中就直接当成没找到 |
哪些情况会影响结果
smart 和 fast 并不是“单纯更快”,它们依赖当前选择器能不能被优化。大致上:
text / desc / id / className / packageName这几组字符串条件更容易被优化。algorithm("BFS")、filter(fn)、setWindowFilter(filterOrTarget)这类写法会让快速路径受限,甚至完全关掉。fast模式可能因为不回退而漏掉节点,所以它适合你非常明确筛选条件、并且能接受“找不到就算没中”的场景。
额外行为
- 每次切换搜索模式都会自动清缓存。
- 非法值会抛异常。
auto search mode must be normal, smart or fast
示例
auto.waitFor();
auto.setSearchMode("smart");
const login = text("登录").findOne(1000);
if (login) {
login.click();
}
auto.setSearchMode("fast");
const one = id("title").findOnce();
log(one ? "hit" : "miss");
auto.setFlags(value)
auto.setFlags("demo");
auto.setFlags(["a", "b", "c"]);
auto.setFlags(null);
参数
| 传入值 | 当前行为 |
|---|---|
null / undefined | 清空并返回空数组 |
| 单个字符串 | 保存成单元素数组 |
| JS 数组 | 每项转字符串、去掉空白后保存 |
Java / Kotlin List | 同上 |
| 其他对象 | 直接 toString() 后保存成单元素数组 |
返回值
string[]
当前源码里的真实地位
这一点很重要:当前 setFlags() 只是把值存起来再返回。
在这份源码里,没有看到后续自动化查找逻辑去消费这组 flag。
所以你可以把它理解成:
- 预留配置口
- 你自己脚本里的元信息
- 后续扩展时可统一读取的一组标签
但不要把它当成“开了某个 flag 就一定会改变搜索行为”。
示例
const flags = auto.setFlags(["login-flow", "trace"]);
log(JSON.stringify(flags)); // ["login-flow","trace"]
auto.setFlags(null); // 清空
auto.clearCache()
auto.clearCache();
参数
无
返回值
true
会清什么
- 当前活动窗口缓存
- 按窗口 id 保存的显式窗口缓存
- 如果无障碍服务对象可用,还会通知服务侧清自动化缓存
什么时候该主动调
- 你刚完成了一次页面跳转
- 刚点击完按钮,界面结构已经明显变了
- 你在
fast模式下怀疑拿到的是旧快照
示例
const next = text("下一步").findOne(1000);
if (next && next.click()) {
auto.clearCache();
}
auto.setWindowFilter(filterOrTarget)
这个 API 现在已经分成了两种完全不同的用法:
- 传函数:按“窗口条件”筛窗口
- 传显示器目标:直接把自动化显示作用域锁到某块屏
最常见的 4 种写法是:
auto.setWindowFilter(function (window) {
return window.active;
});
auto.setWindowFilter(screen);
auto.setWindowFilter(7);
auto.setWindowFilter(null);
参数
| 写法 | 类型 | 是否支持 | 说明 |
|---|---|---|---|
function (window) { ... } | function | 支持 | 按窗口回调过滤 |
null / undefined | null | 支持 | 清除函数过滤和自动化显示作用域 |
7 | number | 支持 | 直接把作用域锁到 displayId = 7 |
"7" | string | 支持 | 纯数字字符串会按 displayId 解析 |
"vdisplay-1" | string | 支持 | 按虚拟屏 session id 字符串解析 |
screen | VDisplaySession | 支持 | 直接传 vdisplay.create() 返回值,最推荐 |
{ target: screen } / { screen: screen } | object | 支持 | 会继续往 target / screen / display / vdisplay / session / displayId 里解析 |
另外,如果你手里本来就是一个 session 风格对象,顶层带着这些字段时,当前实现也能直接认:
__vdisplaySessionIdsessionIdiddisplayId
返回值
boolean
当前实现里,只要参数合法并且内部完成切换,就返回 true。
如果参数类型不合法,不是返回 false,而是直接抛异常。
参数不合法时会报什么
当前源码会直接抛:
auto.setWindowFilter() expects a function, a display id, a vdisplay session or null
传函数时会发生什么
这是大家最熟悉的旧用法。
auto.setWindowFilter(function (window) {
return window.active;
});
这条路径下,源码会:
- 把你的函数保存成窗口过滤器
- 清掉之前可能存在的自动化显示作用域
- 清掉自动化缓存
- 后续查找时按“函数命中的窗口集合”来找节点
回调参数 window
回调会收到一个窗口信息对象,你最常会用到这些字段:
| 字段 | 类型 | 说明 |
|---|---|---|
window.id | number | 窗口 id |
window.title | string | 标题文本 |
window.summary | string | 概览描述 |
window.type | number | 原始窗口类型常量值 |
window.layer | number | 层级 |
window.active | boolean | 是否当前活动窗口 |
window.focused | boolean | 是否当前焦点窗口 |
window.packageName | string | null | 归属包名 |
window.bounds() | UiRect | null | 窗口屏幕区域 |
window.root() | UiObject | null | 这个窗口的根节点 |
window.typeName() | string | 类型名,如 TYPE_APPLICATION |
回调返回值怎样判定
底层不是只认原生布尔值。它会做一层布尔转换:
| 返回值 | 实际判定 |
|---|---|
true / 1 / "true" / "yes" / "on" | true |
false / 0 / "false" / "no" / "off" | false |
| 其他内容 | 按默认 false 处理 |
传函数后,哪些能力会受影响
selector.find*()的搜索根不再只看当前活动窗口。auto.root会变成“第一扇命中过滤器的窗口 root”。auto.windowRoots会变成“所有命中过滤器的窗口 root 数组”。currentPackage()会优先从命中的窗口里取包名。currentActivity()也会优先围绕命中的窗口去判断。- 快速搜索通道会被禁用,所以多窗口过滤通常更灵活,但也更慢。
传显示器 / 虚拟屏时会发生什么
这是这次新增的重点能力。
const screen = vdisplay.create({
width: 720,
height: 1280,
title: "自动化测试屏"
});
auto.setWindowFilter(screen);
这条路径下,它已经不再是“窗口回调过滤器”了,而是会:
- 清掉已有的函数过滤器
- 把
displayId记到windowFilterDisplayId - 同时把
displayId + sessionId记进自动化显示作用域 - 清掉自动化缓存
你可以把它直接理解成:
从这一刻起,后续很多自动化相关 API 在你不再显式传目标显示器时,会优先围绕这块屏来工作。
传显示器 / 虚拟屏后,哪些地方会跟着变
按当前源码,至少下面这些地方会受到影响:
| 能力 | 变化 |
|---|---|
selector.find*() / auto.root / auto.windowRoots | 优先围绕这块显示器上的窗口和 root |
currentPackage() | 会优先从这块屏命中的窗口里拿包名 |
currentActivity() | 会优先拿这块屏的页面;必要时还会参考启动记录做回退 |
auto.takeScreenshot() / automator.takeScreenshot() | 不传参数时可能直接截这块虚拟屏 |
images.captureScreen() | 不传 target 时也可能直接截当前自动化显示作用域里的虚拟屏 |
click(...) / longClick(...) / press(...) / swipe(...) | 省略最后一个显示器参数时,可能继承这块屏 |
back() / home() / keys.* 里支持显示器参数的接口 | 省略目标参数时,也可能沿用这个自动化显示作用域 |
什么时候更推荐传 session,而不是传裸数字
优先级建议很简单:
- 最推荐:直接传
screen - 次选:传
"vdisplay-1"这种 session id 字符串 - 再次:传
displayId数字
原因不是“数字不能用”,而是:
- session 更不容易写错
- session 同时带着
sessionId和displayId - 后面像
takeScreenshot()、images.captureScreen()这种 API 还能直接走虚拟屏专用分支
清除过滤器 / 清除自动化显示作用域
auto.setWindowFilter(null);
这一句会同时:
- 清掉函数过滤器
- 清掉自动化显示作用域
- 清掉缓存
示例:按窗口类型筛输入法窗口
auto.setWindowFilter(function (window) {
return window.typeName() == "TYPE_INPUT_METHOD";
});
const imeRoot = auto.root;
if (imeRoot) {
log("当前输入法窗口 root 已拿到");
}
示例:按包名筛窗口
auto.setWindowFilter(function (window) {
return window.packageName == currentPackage();
});
示例:直接把自动化显示作用域锁到虚拟屏
const screen = vdisplay.create({
width: 720,
height: 1280,
title: "登录页调试屏"
});
app.launch("com.android.settings", screen);
auto.setWindowFilter(screen);
const pkg = currentPackage();
const root = auto.root;
log(pkg);
log(root ? "root ready" : "root missing");
示例:按 displayId 锁定
auto.setWindowFilter(7);
这一组 getter 的共同规则
下面 5 个入口是一组同类能力:
auto.serviceauto.windowsauto.rootauto.rootInActiveWindowauto.windowRoots
它们都有 4 个共同点:
- 它们是属性 getter,不是函数,所以写法是
auto.root,不是auto.root()。 - 每次读取时,都会重新向当前自动化宿主取一次最新值。
- 返回对象在运行时里会按全局 scope 包装,所以你把结果先存到变量里、传给辅助函数、放到回调里继续用都可以。
- 但“先存到变量”不等于“后面会自动刷新”,如果页面已经变化,你还是要重新读一次 getter。
最容易看懂的写法就是下面这种:
auto.waitFor();
const firstRoot = auto.root;
sleep(500);
const secondRoot = auto.root;
log(firstRoot ? firstRoot.toString() : "no first root");
log(secondRoot ? secondRoot.toString() : "no second root");
如果你更喜欢先把 getter 结果交给辅助函数,也没问题:
function printRootInfo(root) {
if (!root) {
log("no root");
return;
}
const rect = root.bounds();
log(rect ? rect.toString() : "no bounds");
}
printRootInfo(auto.root);
auto.service
这是一个高级入口。
这是属性,不是函数
正确写法:
const service = auto.service;
不要写:
auto.service();
返回值
SelectToSpeakService | null
怎么理解
- 有服务时,返回当前无障碍服务对象。
- 没服务时,返回
null。 - 这是宿主内部对象,不是给“第一次写脚本的人”优先依赖的稳定抽象。
更建议的用法
大多数脚本优先用:
auto.waitFor()selector().findOne(...)UiObject.click()
示例
if (!auto.service) {
toast("无障碍服务还没连上");
}
const service = auto.service;
if (service) {
log(String(service));
}
auto.windows
auto.windows 返回的是JS 数组,数组里的每一项都是窗口信息对象。
这是属性,不是函数
const windows = auto.windows;
返回值
WindowInfo[]
能拿到什么
这一组最适合做:
- 多窗口调试
- 当前输入法窗口判断
- 只在某个包名窗口里查控件
- 看系统弹窗和应用主窗口是不是同时存在
示例
const windows = auto.windows;
for (let i = 0; i < windows.length; i++) {
const w = windows[i];
log(
`id=${w.id} type=${w.typeName()} active=${w.active} focused=${w.focused} pkg=${w.packageName}`
);
}
let ime = null;
for (let i = 0; i < auto.windows.length; i++) {
const candidate = auto.windows[i];
if (candidate.typeName() == "TYPE_INPUT_METHOD") {
ime = candidate;
break;
}
}
if (ime) {
log(`输入法窗口范围: ${ime.bounds()}`);
}
WindowInfo 常用方法
这是 auto.windows[i] 这类窗口对象身上最常用的 3 个方法。
bounds()
读取当前窗口的屏幕矩形。
bounds() 方法
- 返回
UiRect | null - 没有边界信息时返回
null
root()
拿这个窗口自己的根节点,后面可以继续按节点树做查找。
root() 方法
- 返回
UiObject | null - 可以直接从某个窗口对象跳到它自己的根节点
typeName()
把原始窗口类型数字转成更容易看懂的字符串。
typeName() 方法
把原始整型 type 转成人类可读名字。当前源码已经映射了这些值:
| 返回值 | 含义 |
|---|---|
TYPE_APPLICATION | 普通应用窗口 |
TYPE_INPUT_METHOD | 输入法窗口 |
TYPE_SYSTEM | 系统窗口 |
TYPE_ACCESSIBILITY_OVERLAY | 无障碍覆盖层 |
TYPE_SPLIT_SCREEN_DIVIDER | 分屏分隔条 |
| 其他值 | TYPE_数字 |
示例
let appWindow = null;
for (let i = 0; i < auto.windows.length; i++) {
const candidate = auto.windows[i];
if (candidate.typeName() == "TYPE_APPLICATION") {
appWindow = candidate;
break;
}
}
if (appWindow) {
const root = appWindow.root();
log(root ? root.toString() : "no root");
}
auto.root
这是属性,不是函数,所以直接写 auto.root。
返回值
UiObject | null
它指的到底是谁
- 没开
windowFilter时:当前自动化搜索范围里的第一个根节点,通常就是当前活动窗口根。 - 开了
windowFilter时:所有命中过滤器的窗口里,第一个窗口根节点。
它和 auto.rootInActiveWindow 的区别
| 属性 | 含义 |
|---|---|
auto.root | 当前“搜索范围”的第一个 root |
auto.rootInActiveWindow | 当前活动窗口 root,不理会过滤器 |
示例
const root = auto.root;
if (root) {
log(root.bounds().toString());
}
auto.rootInActiveWindow
这是属性,不是函数,所以直接写 auto.rootInActiveWindow。
返回值
UiObject | null
适合什么时候用
- 你临时开了
setWindowFilter(),但又想明确拿回“当前活动窗口”的根节点。 - 你在调试窗口过滤逻辑,想对比
auto.root和auto.rootInActiveWindow的差异。
示例
auto.setWindowFilter(function (window) {
return window.typeName() == "TYPE_INPUT_METHOD";
});
log(`filtered root = ${auto.root}`);
log(`active root = ${auto.rootInActiveWindow}`);
auto.windowRoots
这是属性,不是函数,所以直接写 auto.windowRoots。
返回值
UiObject[]
真实行为
- 没有窗口过滤器时,通常就是一个长度为
1的数组。 - 开了窗口过滤器时,会返回所有命中过滤器的窗口根节点数组。
示例
auto.setWindowFilter(function (window) {
return window.packageName == currentPackage();
});
const roots = auto.windowRoots;
for (let i = 0; i < roots.length; i++) {
log(`root[${i}] = ${roots[i]}`);
}
selector()
const s = selector();
返回值
UiSelector
怎么理解
它是一个“从空条件开始”的选择器构造器。后面每加一个条件,都会返回新的 UiSelector。
const base = selector();
const one = base.text("登录");
const two = one.clickable(true);
log(String(base)); // selector()
log(String(one)); // selector().text("登录")
log(String(two)); // selector().text("登录").clickable(true)
推荐心智模型
selector():先拿空白筛子.text(...)/.id(...):往筛子上继续加条件.findOne()/.find():真正开始查
选择器基础
这一组既有全局快捷函数,也有 UiSelector 方法。
text(value?)
按节点文本做精确匹配。
desc(value?)
按内容描述,也就是 contentDescription 做精确匹配。
id(value?)
按资源 id 匹配,同时会看完整 id 和短 id。
className(value?)
按节点类名精确匹配。
packageName(value?)
按节点所属包名精确匹配。
两种写法等价:
text("登录").findOne();
selector().text("登录").findOne();
共同规则
| API | 匹配字段 |
|---|---|
text(...) | 节点文本 |
desc(...) | 内容描述 / contentDescription |
id(...) | 资源 id,既会看完整 id,也会看最后一段短 id |
className(...) | 类名 |
packageName(...) | 包名 |
省略参数时会怎样
这点很容易忽略:
| 写法 | 实际含义 |
|---|---|
text() | 匹配“文本为空或没有文本”的节点 |
desc() | 匹配“描述为空或没有描述”的节点 |
id() | 匹配“没有可用 id 候选项”的节点 |
className() | 匹配类名为空的节点 |
packageName() | 匹配包名为空的节点 |
所以这组不传参数不是“匹配全部”,而是在做“空值精确匹配”。
id(...) 为什么和别的有点不一样
id(...) 不只比一种字符串。它会同时考虑:
- 完整 id,例如
com.demo:id/title - 短 id,例如
title
所以这两句都可能命中同一个节点:
id("title")
id("com.demo:id/title")
示例
const login = text("登录").findOne(1000);
if (login) {
login.click();
}
const tab = id("com.ss.android.ugc.aweme:id/home").findOnce()
|| id("home").findOnce();
包含匹配选择器
这一组是“包含匹配”。
textContains(value)
文本里只要包含指定片段就算命中。
descContains(value)
描述里只要包含指定片段就算命中。
idContains(value)
完整 id 或短 id 里只要包含指定片段就算命中。
classNameContains(value)
类名里只要包含指定片段就算命中。
packageNameContains(value)
包名里只要包含指定片段就算命中。
参数规则
- 参数会先转字符串。
- 如果你把参数漏掉了,很多 API 最终会拿
""去参与匹配。
最容易踩的坑
| 写法 | 风险 |
|---|---|
textContains() | 因为 "" 是任意字符串的子串,结果会非常宽 |
classNameContains() | 同理,几乎会匹配所有有类名的节点 |
packageNameContains() | 同理,范围会很大 |
idContains() | 会同时检查短 id 和完整 id |
所以这组最好都明确传值,不要省略。
示例
const tip = textContains("允许").findOnce();
const edit = classNameContains("EditText").findOne(800);
前缀匹配选择器
这一组是“前缀匹配”。
textStartsWith(value)
文本以前缀方式匹配。
descStartsWith(value)
描述以前缀方式匹配。
idStartsWith(value)
完整 id 和短 id 都会按前缀方式匹配。
classNameStartsWith(value)
类名前缀匹配。
packageNameStartsWith(value)
包名前缀匹配。
参数规则
- 支持任何能转成字符串的值。
idStartsWith(...)同样会同时检查短 id 和完整 id。- 省略参数时会退成空字符串前缀匹配,范围会很宽。
示例
const group = textStartsWith("群聊").findOnce();
const wxNode = packageNameStartsWith("com.tencent").findOne(1000);
后缀匹配选择器
这一组是“后缀匹配”。
textEndsWith(value)
文本以后缀方式匹配。
descEndsWith(value)
描述以后缀方式匹配。
idEndsWith(value)
完整 id 和短 id 都会按后缀方式匹配。
classNameEndsWith(value)
类名后缀匹配。
packageNameEndsWith(value)
包名后缀匹配。
参数规则
- 同样支持先转字符串。
idEndsWith("title")对短 id 和完整 id 都会比。- 省略参数时会退成空字符串后缀匹配,也会很宽。
示例
const settings = textEndsWith("设置").findOnce();
const icon = idEndsWith("avatar").findOne(1000);
正则匹配选择器
这是正则匹配组。
textMatches(pattern)
按正则匹配文本。
descMatches(pattern)
按正则匹配描述。
idMatches(pattern)
按正则匹配完整 id 或短 id。
classNameMatches(pattern)
按正则匹配类名。
packageNameMatches(pattern)
按正则匹配包名。
正则写法规则
底层当前是这样编译的:
- 如果你传的是普通字符串,比如
"登录|注册",它会直接Pattern.compile(...)。 - 如果你传的是
/.../flags这种形式,会把最外层/当作正则边界。 - 支持的 flags 只有:
ims
- 匹配时用的是
find(),不是matches(),也就是“只要命中一段就算成功”。
例子对比
textMatches("登录|注册")
textMatches("/登录|注册/")
textMatches("/login/i")
上面三种都会被当成正则,不是精确字符串比较。
空参数的风险
如果你写成:
textMatches()
那底层会编译一个空正则 "",几乎等于“到处都能匹配到”。
示例
const btn = textMatches("/^(登录|立即登录)$/").findOne(1200);
const anyWechat = packageNameMatches("/com\\.tencent\\..*/").findOnce();
布尔过滤器
这一组都是 UiSelector 的布尔过滤器。
clickable(value?)
只保留可点击节点。
longClickable(value?)
只保留可长按节点。
editable(value?)
只保留可编辑节点。
scrollable(value?)
只保留可滚动节点。
checkable(value?)
只保留可勾选节点。
checked(value?)
按“是否已勾选”筛节点。
selected(value?)
按“是否已选中”筛节点。
enabled(value?)
按“是否启用”筛节点。
focusable(value?)
按“是否可获得普通焦点”筛节点。
focused(value?)
按“当前是否已拿到普通焦点”筛节点。
visibleToUser(value?)
按“对用户是否可见”筛节点。
accessibilityFocused(value?)
按“是否已拿到无障碍焦点”筛节点。
contextClickable(value?)
按“是否支持上下文点击”筛节点。
dismissable(value?)
按“是否可关闭”筛节点。注意当前源码拼写就是 dismissable。
默认值规则
如果你省略参数,它们都会按 true 处理。
也就是说:
clickable()
clickable(true)
是一样的。
支持的布尔写法
底层会做安全布尔转换:
| 传入值 | 结果 |
|---|---|
true / false | 原样使用 |
1 / 0 | true / false |
"true" / "false" | true / false |
"yes" / "no" | true / false |
"on" / "off" | true / false |
| 其他无法识别的值 | 按默认值 true 处理 |
特别提醒
dismissable的拼写当前就是源码里的dismissable,不是dismissible。clickable("abc")不会报错,会退成clickable(true)。
示例
const send = text("发送")
.enabled()
.clickable()
.findOne(1000);
const unchecked = checkable(true).checked(false).find();
log(`count=${unchecked.size()}`);
数值过滤器
这是 UiSelector 的数值过滤器。
depth(value)
按节点深度筛选。这个值越大,通常说明节点在树里越靠下。
indexInParent(value)
按它在父节点里的索引筛选,第一项通常是 0。
drawingOrder(value)
按绘制顺序筛选。适合多个重叠兄弟节点时进一步缩范围。
row(value)
按节点所在行筛选,常见于表格、网格、宫格场景。
rowCount(value)
按总行数筛选,一般更适合筛容器节点。
rowSpan(value)
按跨行数筛选,用在表格类布局时更有意义。
column(value)
按节点所在列筛选。
columnCount(value)
按总列数筛选,一般更适合表格容器或网格容器。
columnSpan(value)
按跨列数筛选。
childCount(value)
按直接子节点数量筛选,常用于列表容器、卡片容器、面板节点。
参数规则
| 支持的值 | 说明 |
|---|---|
123 | 直接按数字用 |
"123" | 去空白后转整数 |
" 123 " | 仍然可用 |
"abc" | 抛异常 |
null / undefined | 抛异常 |
哪些字段缺省时常见什么值
| 字段 | 常见缺省值 |
|---|---|
row / column | -1 |
rowSpan / columnSpan | -1 |
rowCount / columnCount | 0 |
childCount | 0 |
所以这些条件很适合做“表格类 / 列表类节点”筛选。
示例
const firstChild = indexInParent(0).findOnce();
const gridItem = row(1).column(2).findOnce();
bounds(left, top, right, bottom)
这是边界完全相等匹配。
参数
4 个参数都必须能转成整数,否则直接抛异常:
bounds arguments must be numeric
适合场景
- 你已经知道目标节点的精确矩形
- 你在对比前后两次抓树,想锁定同一块区域
示例
const root = auto.rootInActiveWindow;
if (root) {
const rect = root.bounds();
const same = bounds(rect.left, rect.top, rect.right, rect.bottom).findOnce();
log(String(same));
}
boundsInside(left, top, right, bottom)
这是“节点边界必须完整落在给定区域内部”。
适合场景
- 只查屏幕上半区
- 只查某个弹窗内部
- 从一个大容器区域里继续筛子节点
示例
const topHalf = boundsInside(0, 0, device.width, Math.floor(device.height / 2)).find();
log(`topHalf=${topHalf.length}`);
boundsContains(x, y)
这是“节点矩形必须包含某个点”。
参数规则
x、y都要是数字或数字字符串- 否则抛异常
boundsContains(x, y) requires numeric coordinates
示例
const centerX = Math.floor(device.width / 2);
const centerY = Math.floor(device.height / 2);
const node = boundsContains(centerX, centerY).findOnce();
algorithm(value)
这是选择器遍历算法开关。
参数
| 值 | 含义 |
|---|---|
"DFS" | 深度优先 |
"BFS" | 广度优先 |
null / "" | 按 "DFS" |
大小写不敏感,所以 "dfs"、"bfs" 也能用。
为什么它不只是“学术参数”
它会影响两件事:
findOnce()/findOne()拿到的“第一个结果”是谁- 能不能走某些快速优化路径
当前源码里,BFS 会让部分优化失效,所以通常:
- 默认用
DFS - 只有你明确想按层级优先找第一个节点时,再切
BFS
示例
const node = selector()
.textContains("设置")
.algorithm("BFS")
.findOnce();
filter(fn)
这是自定义过滤器。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
fn | function | 回调参数是 UiObject,返回值会做布尔转换 |
非函数会直接抛异常:
filter(callback) requires a function
回调返回值规则
和 setWindowFilter() 一样,返回值会做布尔转换:
true/1/"yes"/"on"会当成真false/0/"no"/"off"会当成假- 其他值按默认
false
要注意什么
- 一旦用了
filter(fn),这个选择器就会被标记成“自定义过滤器选择器”。 - 这样通常就别指望它再走高阶快速搜索优化了。
- 所以建议先用普通字段条件把范围收窄,再加
filter(fn)。
示例
const matched = classNameContains("TextView")
.filter(function (node) {
const rect = node.bounds();
return node.text() && rect.width() > 200;
})
.find();
find()
这是 UiSelector 的“立即查全部”。
返回值
UiObjectCollection
行为
- 不等待
- 立刻返回当前找到的全部结果
- 一个都没有时,返回空集合,不是
null
示例
const items = textContains("消息").find();
log(items.length);
查找第一个结果
findOnce()
拿当前结果里的第一个节点,不等待。
findOnce(index)
拿当前结果里的第 index 个节点,不等待。
findOnce() 无参数
- 返回第一个匹配节点
- 找不到时返回
null
findOnce(index) 带索引
| 参数 | 类型 | 规则 |
|---|---|---|
index | number / 数字字符串 | 小于 0 或解析失败时返回 null |
示例
const first = textContains("评论").findOnce();
const third = textContains("评论").findOnce(2);
findOne(timeout?)
返回值
UiObject | null
超时规则
| 写法 | 行为 |
|---|---|
findOne() | 一直等,直到找到 |
findOne(1000) | 最多等 1000ms |
findOne("1000") | 也可以,会转成整数 |
findOne("abc") | 解析失败后按 0ms 处理,基本等于只查一次 |
当前实现每轮大约轮询一次后会 sleep 120ms。
示例
const login = text("登录").findOne(1500);
if (!login) {
toast("1.5 秒内没找到登录按钮");
}
untilFind(timeout?)
这是“等到至少有一个结果,再把整批结果拿回来”。
返回值
UiObjectCollection
行为
| 写法 | 行为 |
|---|---|
untilFind() | 一直等到结果非空 |
untilFind(1000) | 最多等 1000ms;超时后返回空集合 |
示例
const messages = textContains("消息").untilFind(2000);
if (messages.nonEmpty()) {
messages.each(function (item, index) {
log(index, item.text());
});
}
exists()
这是“立刻判断有没有”。
返回值
boolean
特点
- 不等待
- 不返回节点
- 就适合做分支判断
示例
if (text("允许").exists()) {
const allow = text("允许").findOnce();
if (allow) {
allow.click();
}
}
waitFor(timeout?)
当前实现里,它本质上和 findOne(timeout?) 一样,都是等待单个节点出现再返回。
返回值
UiObject | null
示例
const input = classNameContains("EditText").waitFor(1200);
if (input) {
input.setText("hello");
}
currentPackage()
这一组虽然也在 Global Functions 全局函数 里讲过,但自动化脚本里非常常用,这里再按自动化场景重讲一遍。
参数
无
返回值
string | null
依赖
依赖无障碍服务。服务没起来时会抛异常,而不是返回空串。
你真正拿到的是什么
它会尽量返回“当前前台应用包名”。
大多数时候你可以把它理解成“用户眼下正在看的 App 包名”。
但在下面这些瞬间,可能短暂拿到 null:
- 无障碍刚连上
- 页面正在快速切换
- 系统事件还没来得及更新缓存
如果你之前已经用:
auto.setWindowFilter(screen);
或者 auto.setWindowFilter(7) 这类写法把自动化显示作用域锁到某块显示器,它会优先从那块屏命中的窗口里拿包名,而不是只按主屏去理解。
示例
log(currentPackage()); // 例如 com.ss.android.ugc.aweme
if (currentPackage() != "com.tencent.mm") {
toast("请先切到微信");
}
currentActivity()
参数
无
返回值
string | null
依赖
同样依赖无障碍服务。
它返回的不是 Activity 对象
它只是一个类名字符串。
而且当前实现是把组件串里 / 后面的部分截出来,所以你可能看到两种形式:
com.demo.MainActivity.MainActivity
如果你想把相对类名补成完整类名,可以这样写:
function normalizeActivityName() {
const pkg = currentPackage();
const act = currentActivity();
if (!pkg || !act) return act;
return act.startsWith(".") ? pkg + act : act;
}
如果你已经把自动化显示作用域锁到了某块 vdisplay 虚拟屏,currentActivity() 也会优先围绕那块屏来判断;当系统临时拿不到对应组件时,当前源码还会参考“最近一次启动到这块屏的 Activity 记录”做一次回退,所以它在虚拟屏场景下会比单纯盯主屏更稳一点。
示例
log(currentActivity());
const act = normalizeActivityName();
if (act == "com.ss.android.ugc.aweme.im.sdk.chat.ChatRoomActivity") {
log("已经在聊天页");
}
scrollUp([index])
这是一个全局自动化快捷函数。
它不是 UiObject.scrollUp() 的简单重命名,而是“先帮你找一个合适的可滚动控件,再在那个控件上执行滚动动作”。
参数
| 参数 | 类型 | 可填值 | 默认行为 |
|---|---|---|---|
index | number / 数字字符串 | 0、1、2... | 省略时自动挑当前候选里面积最大的可滚动控件 |
返回值
boolean
它到底怎么找目标控件
源码当前分两步找:
- 先找
scrollable(true) + visibleToUser(true)的节点,并过滤掉没有面积的空矩形节点。 - 如果这一步完全找不到,再退到普通
scrollable(true),同样过滤空矩形。
也就是说,它优先考虑“当前用户能看到、而且真的占一块区域”的滚动控件。
不传索引时会选谁
不传 index 时,不是随便拿第一个。
当前实现会在候选里按下面顺序挑:
- 面积最大的
- 如果面积一样,挑更靠上的
- 还一样,再挑更靠左的
这也是为什么聊天列表、主内容列表这类“大块滚动区域”通常会被优先选中。
传索引时怎么解释
scrollUp(0):第一个候选scrollUp(1):第二个候选scrollUp("2"):第三个候选
如果传的是:
- 负数
- 越界索引
- 不能解析成整数的值
当前都会返回 false。
真正执行时不是只试一种动作
当前实现会按下面顺序尝试:
target.scrollUp()target.scrollLeft()target.scrollBackward()
只要其中一个成功,就返回 true。
为什么“上滑”还会试 scrollLeft()
因为这里不是死板按屏幕方向理解,而是兼顾横向容器。
对于某些横向列表、横向轮播、Tab 条,“往前滑”更接近左滑或 backward,而不是屏幕上的垂直上滑。
示例
if (!scrollUp()) {
toast("当前页面没有找到可以上滑的区域");
}
// 页面上有多个滚动区域时,试着滑第一个候选
scrollUp(0);
for (let i = 0; i < 3; i++) {
if (scrollUp(i)) {
log(`第 ${i + 1} 个滚动区域已尝试上滑`);
}
}
scrollDown([index])
这也是一个全局自动化快捷函数,找控件的逻辑和 scrollUp() 一样,区别只在执行动作的方向。
参数
| 参数 | 类型 | 可填值 | 默认行为 |
|---|---|---|---|
index | number / 数字字符串 | 0、1、2... | 省略时自动挑当前候选里面积最大的可滚动控件 |
返回值
boolean
真正执行的动作顺序
当前实现会依次尝试:
target.scrollDown()target.scrollRight()target.scrollForward()
所以它更接近“往后翻”或“朝内容末尾方向滑一下”。
示例
while (scrollDown()) {
if (text("更多设置").exists()) {
break;
}
}
const ok = scrollDown(1);
log(ok ? "第二个滚动控件已尝试下滑" : "没有第二个可滚动控件");
setText([index, ]text)
这是全局输入快捷函数。
它和 UiObject.setText(value) 的差别在于:UiObject.setText(...) 需要你先拿到具体节点,而这里会先帮你找输入框。
两种写法
setText("测试");
setText(0, "测试");
参数规则
| 写法 | 含义 |
|---|---|
setText(text) | 把所有命中的输入框内容都改成 text |
setText(index, text) | 只改第 index + 1 个命中的输入框 |
文本参数怎么处理
- 只有一个参数时,这个参数直接当文本。
- 两个及以上参数时,第一个参数按索引处理,第二个参数按文本处理。
text最终会走toString()。null/undefined最终会变成空字符串,所以它也能被用来“清空输入框”。
它怎么找输入框
当前源码优先顺序是:
editable(true) + enabled(true) + visibleToUser(true)classNameContains("EditText") + enabled(true) + visibleToUser(true)- 如果前两步都找不到,再退到不强求
visibleToUser(true)的同类规则
找到的结果还会做一层去重,避免同一个输入框因为快照或匹配路径问题被重复处理。
返回值
boolean
返回 false 的常见情况
- 根本没找到符合条件的输入框
- 传了索引,但对应序号不存在
- 索引解析失败
- 找到了输入框,但执行
ACTION_SET_TEXT失败 - 批量模式下,有任意一个输入框设置失败
这不是追加
setText("abc") 的含义始终是:
- 直接把目标输入框内容改成
"abc"
不是:
- 在原有文本后面继续补
"abc"
示例
setText("hello scriptx");
setText(0, "账号");
setText(1, "密码");
// 清空当前找到的所有输入框
setText(null);
input([index, ]text)
这也是全局输入快捷函数,但语义上是“追加”。
两种写法
input(" world");
input(0, " world");
参数规则
和 setText([index, ]text) 一样:
input(text):对所有命中的输入框追加input(index, text):只对第index + 1个命中的输入框追加
返回值
boolean
当前实现里“追加”是怎么做的
这一点要专门说清楚。
当前源码不是调用某个独立的“append 输入动作”,而是:
- 先读目标输入框当前
text() - 拼成
旧文本 + 新文本 - 再调用
setText(...)整段写回去
也就是说,它的语义是“追加”,但底层实现仍然是“重设整段文本”。
这意味着什么
- 如果宿主控件不支持
ACTION_SET_TEXT,它照样会失败。 - 它不会模拟系统输入法那种一个字一个字敲进去的过程。
- 如果你依赖“逐字符输入触发某些监听器”,那它未必符合你的预期。
一个容易忽略的小点
当输入框当前文本拿不到时,拼接结果取决于 text() 实际读到了什么。
所以如果你想最稳地控制结果,尤其是在复杂自定义输入框里,通常更建议:
- 先
setText(...) - 再决定要不要
input(...)
当前实现里还有一个更细的坑
源码现在直接拼的是:
旧文本 + 新文本
而不是:
(旧文本 || "") + 新文本
所以如果某个输入框当前 text() 读出来就是 null,最终写回去的结果有机会变成:
"null" + 新文本
也就是你本来想追加 "abc",结果实际可能写成 "nullabc"。
这不是文档猜测,而是当前源码写法本身带来的结果。
示例
setText(0, "hello");
input(0, " jsxhook");
input(" test");
if (!input(1, " appended")) {
toast("第二个输入框追加失败");
}
auto.takeScreenshot(target?) / automator.takeScreenshot(target?)
这是自动化模块里的截图入口,但它现在已经不是“只能截主屏”的旧状态了。
按当前源码,它实际有两条分支:
- 虚拟屏分支:如果参数或当前自动化显示作用域能解析到某个
vdisplaysession,就直接截那块虚拟屏 - 无障碍分支:如果没有解析到虚拟屏 session,就走无障碍服务截图
参数
| 写法 | 是否支持 | 说明 |
|---|---|---|
| 不传参数 | 支持 | 优先按当前自动化显示作用域判断;没有作用域时按默认显示器处理 |
screen | 支持 | 直接传 vdisplay.create() 返回的 session,最推荐 |
"vdisplay-1" | 支持 | 直接传虚拟屏 session id 字符串 |
7 | 支持 | 传 displayId 数字;如果这块屏正好对应某个虚拟屏 session,会优先走虚拟屏截图 |
"7" | 支持 | 纯数字字符串也会按 displayId 理解 |
{ target: screen } / { screen: screen } | 支持 | 会继续从 target / screen / display / vdisplay / session / displayId 里解析 |
如果你手里本来就是一个“session 风格对象”,顶层带着这些字段时,当前实现也能直接认:
__vdisplaySessionIdsessionIdiddisplayId
返回值
Image
当前源码里实际返回的是内部包装过的 ScriptImage 对象。你在脚本里就把它当成图片对象来用即可。
这两个名字的关系
const image1 = automator.takeScreenshot();
const image2 = auto.takeScreenshot();
这两个入口是同一个能力,只是挂在了两个对象上。
按当前源码,这种“先取方法再调”的写法也成立:
const shot = automator.takeScreenshot;
const image = shot();
image.recycle();
它现在到底怎么决定走哪条分支
源码实际顺序是这样的:
- 先看你这次有没有显式传
target - 再看这个
target能不能解析出某个vdisplaysession - 如果能,直接走虚拟屏截图分支
- 如果不能,再看当前自动化显示作用域里有没有绑定显示器 / session
- 还没有的话,才走无障碍截图分支
这意味着一个很重要的实际效果:
auto.setWindowFilter(screen);
const image = auto.takeScreenshot();
即使你这里没传参数,它也可能已经是在截 screen 这块虚拟屏,而不是主屏。
虚拟屏分支会怎样工作
当它成功解析到某个 vdisplay session 时:
- 不会去检查 Android 11
- 不会调用无障碍服务截图
- 不需要
requestScreenCapture() - 会直接调用虚拟屏管理器抓这块屏的位图
- 当前默认等待超时是 1500ms
所以这条路径更像是:你已经有一块虚拟屏,我现在直接把这块屏的最新画面拿出来。
无障碍分支会怎样工作
当它没有解析到虚拟屏 session 时,才会走无障碍截图:
- 系统必须是 Android 11 及以上
- 无障碍服务必须已经连上
- 内部会调用无障碍服务的
takeScreenshot(displayId, ...) - 默认最多等待 8000ms
- 成功后把结果转成软件位图,再包装成脚本里的
Image
无障碍分支的前提条件
如果你最终走的是无障碍截图分支,调用前至少要满足这 3 条:
- 系统版本是 Android 11 及以上
- 无障碍服务已经开启,并且 ScriptX 当前真的连上了这个服务
- 不要在 1 秒内连续疯狂调用,否则系统会直接拒绝这次截图
如果不满足第 1 条,会抛:
automator.takeScreenshot() requires Android 11 or above
如果无障碍服务没起来,会抛:
Accessibility service is not enabled
传参时最实用的理解
| 你传的内容 | 最常见结果 |
|---|---|
screen / "vdisplay-1" | 直接截这块虚拟屏 |
7,且 7 对应已有虚拟屏 session | 直接截这块虚拟屏 |
7,但它只是普通显示器 id | 走无障碍截图,目标显示器是 7 |
不传参数,但之前 auto.setWindowFilter(screen) | 很可能直接截当前自动化显示作用域里的虚拟屏 |
| 什么都不传,也没作用域 | 走默认显示器截图 |
这个 Image 对象能做什么
当前源码里,这个截图对象最常用的是下面这些方法:
| API | 返回值 | 说明 |
|---|---|---|
image.width() | number | 取图片宽度 |
image.height() | number | 取图片高度 |
image.getWidth() | number | width() 的别名 |
image.getHeight() | number | height() 的别名 |
image.pixel(x, y) | number | 读取指定坐标像素颜色 |
image.getPixel(x, y) | number | pixel(x, y) 的别名 |
image.recycle() | boolean | 回收图片,释放内存 |
image.isRecycled() | boolean | 判断图片是否已经回收 |
image.width() / image.height()
读取截图尺寸,返回像素值。
const image = automator.takeScreenshot();
log(`宽度: ${image.width()}`);
log(`高度: ${image.height()}`);
image.recycle();
image.pixel(x, y)
读取某个坐标点的颜色值。
| 参数 | 类型 | 说明 |
|---|---|---|
x | number | 横坐标 |
y | number | 纵坐标 |
const image = automator.takeScreenshot();
const x = Math.floor(image.width() / 2);
const y = Math.floor(image.height() / 2);
const color = image.pixel(x, y);
log(`中心像素颜色: ${color}`);
image.recycle();
image.recycle()
用完截图后,建议主动调用一次,及时释放内存。
- 第一次成功回收时返回
true - 如果这张图之前已经回收过了,再调一次会返回
false
const image = automator.takeScreenshot();
try {
log(image.width());
} finally {
image.recycle();
}
image.isRecycled()
const image = automator.takeScreenshot();
log(image.isRecycled()); // false
image.recycle();
log(image.isRecycled()); // true
截图频率限制
这个限制主要是 Android 无障碍截图能力本身带的限制。
当前源码已经把系统错误翻译成了更直白的提示:如果你调得太快,会抛:
automator.takeScreenshot() failed: called too frequently, wait about 1 second
所以最稳的写法是:
- 需要连续截图时,间隔至少留 1000ms
- 实战里更建议留 1050ms ~ 1200ms
失败时会出现哪些提示
下面这些都是当前源码里已经明确写死的错误提示,不是猜的:
| 场景 | 典型报错 |
|---|---|
| 传的是虚拟屏,但这块虚拟屏没截到画面 | automator.takeScreenshot() failed to capture virtual display vdisplay-1 |
| 系统版本低于 Android 11,且最终走到无障碍分支 | automator.takeScreenshot() requires Android 11 or above |
| 无障碍服务没启用或没连上 | Accessibility service is not enabled |
| 截图等待超时 | automator.takeScreenshot() timed out |
| 系统回调成功但没有给出位图 | automator.takeScreenshot() did not return a bitmap |
| 系统内部错误 | automator.takeScreenshot() failed: internal error |
| 当前无障碍服务没有截图访问能力 | automator.takeScreenshot() failed: service has no screenshot access |
| 调用频率过快 | automator.takeScreenshot() failed: called too frequently, wait about 1 second |
| 目标显示器无效 | automator.takeScreenshot() failed: invalid display |
| 其他系统错误码 | automator.takeScreenshot() failed: errorCode=... |
最基础的写法
auto.waitFor();
const image = automator.takeScreenshot();
try {
log("截图成功");
log(`size=${image.width()}x${image.height()}`);
} finally {
image.recycle();
}
用 auto.takeScreenshot() 写
auto.waitFor();
const image = auto.takeScreenshot();
try {
log("通过 auto.takeScreenshot() 截图成功");
log(image.getWidth());
log(image.getHeight());
} finally {
image.recycle();
}
显式截某块虚拟屏
const screen = vdisplay.create({
width: 720,
height: 1280,
title: "截图测试屏"
});
const image = automator.takeScreenshot(screen);
try {
log(`virtual shot = ${image.width()}x${image.height()}`);
} finally {
image.recycle();
}
用 session id 字符串去截
const screen = vdisplay.create();
const image = automator.takeScreenshot(screen.id);
try {
log(screen.id);
} finally {
image.recycle();
}
把自动化显示作用域锁到虚拟屏后直接截
const screen = vdisplay.create({
width: 720,
height: 1280
});
auto.setWindowFilter(screen);
const image = auto.takeScreenshot();
try {
log("这里通常拿到的已经是 screen 这块虚拟屏");
log(`${image.width()}x${image.height()}`);
} finally {
image.recycle();
}
显式按显示器 id 截图
const image = automator.takeScreenshot(0);
try {
log("尝试按 displayId=0 截图");
} finally {
image.recycle();
}
用目标对象写
const screen = vdisplay.create();
const image = automator.takeScreenshot({
screen: screen
});
try {
log("对象 target 也能解析");
} finally {
image.recycle();
}
失败时自己接住异常
auto.waitFor();
try {
const image = automator.takeScreenshot();
try {
log(`截图尺寸: ${image.width()} x ${image.height()}`);
} finally {
image.recycle();
}
} catch (err) {
toastLog(`截图失败: ${err}`);
}
连续截图时要主动留间隔
auto.waitFor();
try {
const first = automator.takeScreenshot();
try {
log(`first=${first.width()}x${first.height()}`);
} finally {
first.recycle();
}
sleep(1100);
const second = automator.takeScreenshot();
try {
log(`second=${second.width()}x${second.height()}`);
} finally {
second.recycle();
}
} catch (err) {
toastLog(`连续截图失败: ${err}`);
}
什么时候更适合用它
- 你已经在写无障碍自动化脚本,并且服务本来就要开着
- 你只想快速拿一张当前界面的图做颜色判断或调试
- 你已经接了
vdisplay,想直接截某块虚拟屏 - 你不想再走额外的截图授权流程
auto.saveScreenRoots(savePath, windowId?)
这是这次新增里很实用的一个“调试导出接口”。
它的用途不是单纯截图,而是把当前屏幕的无障碍节点树和一张对应截图一起打包保存下来。
你可以把它理解成:
- 先抓当前页面的 root 布局树
- 再抓一张对应截图
- 把布局 JSON、元信息、截图 PNG 一起打进一个 zip
- 最后把保存出来的绝对路径返回给脚本
如果你经常要:
- 让别人帮你看某个页面为什么选不中
- 保存当前页面结构,回头慢慢研究
- 对比两个页面的布局层级差异
- 留一份“当时现场”的控件树和截图
这个接口会非常顺手。
参数
| 参数 | 类型 | 可填值 | 默认值 | 说明 |
|---|---|---|---|---|
savePath | string | 任意非空路径 | 无 | 导出目标路径;如果你没写 .zip 后缀,源码会自动补上 |
windowId | number | null | undefined | auto.windows[i].id 这类窗口 id | 不传时按当前最合适的活动窗口 / 最近窗口自动选 | 指定要导出的窗口 |
返回值
string
返回最终写到磁盘上的绝对路径。
最终导出的文件长什么样
按当前源码,导出的其实是一个加密 zip 包,里面至少会有这些内容:
| 文件名 | 作用 |
|---|---|
layout.json | 当前抓到的无障碍节点树 |
meta.json | 导出时间、窗口 id、包名、显示尺寸等元信息 |
screenshot.png | 导出时对应的截图 |
所以它不是“只存一张图片”,而是一份可以继续分析的布局快照包。
savePath 可以怎么写
1. 直接写完整 zip 文件名
const path = auto.saveScreenRoots("/sdcard/Download/login-page.zip");
log(path);
这种情况下就按你写的名字保存。
2. 不写 .zip 后缀
const path = auto.saveScreenRoots("/sdcard/Download/login-page");
log(path);
当前源码会自动补成:
/sdcard/Download/login-page.zip
3. 写到还不存在的目录
如果上级目录不存在,当前实现会先自动创建父目录,再写文件。
第二个参数 windowId 到底怎么传
这个参数不是窗口对象本身,而是窗口 id 数字。
最常见写法是先从 auto.windows 里找到目标窗口,再把它的 id 传进去:
const windows = auto.windows;
for (let i = 0; i < windows.length; i++) {
log(`window id=${windows[i].id} title=${windows[i].title}`);
}
然后:
const targetWindowId = auto.windows[0].id;
const path = auto.saveScreenRoots("/sdcard/Download/first-window", targetWindowId);
log(path);
不传 windowId 时会发生什么
不传时,当前源码会让无障碍服务自己去选“当前最合适的一棵根节点”,也就是:
- 优先看当前活动窗口
- 再看最近事件、缓存窗口等内部来源
所以对大多数“我就想先把当前页面导出来看看”的场景,不传第二个参数就够用了。
传了 windowId 时会发生什么
传了以后,源码会:
- 在当前可访问窗口列表里查找这个 id 对应的窗口
- 拿这个窗口的 root 节点做布局抓取
- 用这个 root 节点导出布局树
- 再补一张对应截图,一起打包
如果这个 windowId 对应不到可用窗口,就会拿不到 root,最后抛错。
它会不会把悬浮日志窗也拍进去
当前实现专门处理了这个问题。
如果此时悬浮日志窗开着,导出前会先暂时把它切成“截图里隐藏”的状态,导出结束后再恢复。
所以一般情况下,它不会把这块调试悬浮窗硬塞进最终截图里。
这个行为和 console.setScreenshotVisible(visible?) 管的是同一类截图可见性逻辑。
成功时最常见的写法
auto.waitFor();
const zipPath = auto.saveScreenRoots("/sdcard/Download/current-screen");
toastLog(`布局包已导出到: ${zipPath}`);
示例:先列窗口,再导出指定窗口
auto.waitFor();
const windows = auto.windows;
for (let i = 0; i < windows.length; i++) {
const win = windows[i];
log(`#${i} id=${win.id} title=${win.title} active=${win.active}`);
}
if (windows.length > 0) {
const zipPath = auto.saveScreenRoots(
"/sdcard/Download/selected-window",
windows[0].id
);
log(`已导出: ${zipPath}`);
}
示例:专门导出输入法窗口
auto.waitFor();
const windows = auto.windows;
let imeWindow = null;
for (let i = 0; i < windows.length; i++) {
const win = windows[i];
if (win.typeToString && win.typeToString() == "TYPE_INPUT_METHOD") {
imeWindow = win;
break;
}
}
if (imeWindow) {
const zipPath = auto.saveScreenRoots(
"/sdcard/Download/ime-window",
imeWindow.id
);
toastLog(`输入法窗口布局已导出: ${zipPath}`);
} else {
toastLog("当前没找到输入法窗口");
}
示例:导出前先把日志悬浮窗设成截图隐藏
console.show();
console.setScreenshotVisible(false);
auto.waitFor();
const zipPath = auto.saveScreenRoots("/sdcard/Download/clean-layout");
log(zipPath);
示例:把导出的路径存起来,后面发给别人
auto.waitFor();
const zipPath = auto.saveScreenRoots("/sdcard/Download/problem-page");
files.write("/sdcard/Download/problem-page-path.txt", zipPath);
toastLog("路径已记下来,后面可以直接发包给别人分析");
会抛哪些典型错误
savePath 为空
如果你传的是空字符串、纯空白,或者根本没给第一个参数,会抛:
auto.saveScreenRoots(savePath) requires a non-empty path
无障碍服务没准备好
如果当前无障碍服务还没就绪,会抛:
Accessibility service is not ready
当前拿不到布局根节点
如果当前页面根本抓不到 root,导出器会抛:
当前无法获取屏幕布局节点
这通常意味着:
- 当前界面还没稳定下来
- 无障碍服务虽然开着,但这次没抓到可用 root
- 你传的
windowId对应不到当前真实窗口
第二个参数有哪些限制
- 当前只认
number - 不会自动从窗口对象里帮你取
id - 也不认字符串
"12" - 所以不要写:
auto.saveScreenRoots("/sdcard/Download/a", auto.windows[0]);
auto.saveScreenRoots("/sdcard/Download/a", "12");
最稳的写法始终是:
auto.saveScreenRoots("/sdcard/Download/a", auto.windows[0].id);
什么时候最适合用它
- 你想把“当前页面为什么点不到、搜不到”保存成一个可复盘的包
- 你想把布局树和截图一起发给别人帮忙排查
- 你在做选择器调试,想留样本做对比
- 你想固定保存某个弹窗、输入法窗口、系统窗口的完整结构
UiObjectCollection.length
这是集合上的只读长度属性。
返回值
number
额外说明
UiObjectCollection 不是普通 JS 数组,但它支持两种很顺手的访问方式:
const list = textContains("消息").find();
log(list.length);
log(list[0]);
集合状态
这是集合最常用的一组状态方法。
UiObjectCollection.size()
返回集合大小。
UiObjectCollection.empty()
判断集合是否为空。
UiObjectCollection.nonEmpty()
判断集合是否非空。
UiObjectCollection.isEmpty()
判断集合是否为空。语义上和 empty() 接近。
返回值
| API | 返回值 |
|---|---|
size() | 集合大小 |
empty() | 是否为空 |
nonEmpty() | 是否非空 |
isEmpty() | 是否为空 |
示例
const list = classNameContains("TextView").find();
if (list.nonEmpty()) {
log(`hit=${list.size()}`);
}
UiObjectCollection.get(index)
参数
| 参数 | 类型 | 规则 |
|---|---|---|
index | number / 数字字符串 | 小于 0 或越界时返回 null |
返回值
UiObject | null
示例
const list = textContains("消息").find();
const first = list.get(0);
const second = list.get("1");
集合内继续查找
这是“对集合里的每个元素都做一次子树检索”。
UiObjectCollection.find(selector)
把集合里每个节点的子树都扫一遍,并把所有命中结果收集回来。
UiObjectCollection.findOne(selector)
从集合里每个节点的子树里找第一个命中的节点。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
selector | UiSelector | 必传选择器;传 null 时会退成空选择器 |
一个很关键的细节
如果你传 null,底层会自动构造 selector(),也就是没有任何条件的空选择器。
这通常意味着:
collection.find(null):把每个元素整棵子树全扫出来collection.findOne(null):返回第一棵子树里遇到的第一个节点
所以实际脚本里,最好都显式传筛选条件。
返回值
| API | 返回值 |
|---|---|
find(selector) | UiObjectCollection |
findOne(selector) | UiObject | null |
示例
const cards = classNameContains("RecyclerView").find();
const avatar = cards.findOne(idEndsWith("avatar"));
UiObjectCollection.each(callback)
参数
| 参数 | 类型 | 说明 |
|---|---|---|
callback | function | 回调参数依次是 (item, index, collection) |
非函数会抛异常:
each(callback) requires a function
返回值
返回集合自身,方便链式调用。
示例
textContains("消息").find().each(function (item, index, collection) {
log(index, item.text(), `total=${collection.length}`);
});
UiObjectCollection.toArray()
返回值
普通 JS 数组 UiObject[]
什么时候有用
- 你想用原生数组方法
- 你想自己
map/filter - 你想传给只接受数组的辅助函数
示例
const array = textContains("消息").find().toArray();
array.forEach(function (item) {
log(item.text());
});
身份信息读取
这是最核心的一组“身份信息读取 API”。
UiObject.id()
读取短 id,例如 title。
UiObject.fullId()
读取完整 id,例如 com.demo:id/title。
UiObject.idInt()
读取整型资源 id,取不到时是 null。
UiObject.idHex()
把整型资源 id 以十六进制字符串形式返回。
UiObject.sourceNodeId()
读取无障碍节点自己的 sourceNodeId。
返回值说明
| API | 类型 | 说明 |
|---|---|---|
id() | string | null | 短 id,例如 title |
fullId() | string | null | 完整 id,例如 com.demo:id/title |
idInt() | number | null | 尝试解析出来的资源整型 id |
idHex() | string | null | idInt() 的十六进制形式,如 0x7f0a0123 |
sourceNodeId() | number | 无障碍 sourceNodeId,取不到时是 -1 |
怎么用最稳
const node = text("登录").findOnce();
if (node) {
log(JSON.stringify({
id: node.id(),
fullId: node.fullId(),
idInt: node.idInt(),
idHex: node.idHex(),
sourceNodeId: node.sourceNodeId()
}, null, 2));
}
文本与包名读取
UiObject.text()
读取节点文本。
UiObject.desc()
读取节点描述。
UiObject.className()
读取节点类名。
UiObject.packageName()
读取节点所属包名。
返回值
全部都是 string | null
典型用途
text():读按钮文案、列表项标题desc():读无文本图标按钮的描述className():做控件类型判断packageName():确认节点属于哪个 App / 哪个窗口
示例
const node = textContains("设置").findOnce();
if (node) {
log(node.text());
log(node.desc());
log(node.className());
log(node.packageName());
}
边界读取
这两个都会返回 UiRect。
UiObject.bounds()
读取屏幕坐标系下的矩形。
UiObject.boundsInParent()
读取相对父节点坐标系下的矩形。
差别
| API | 坐标系 |
|---|---|
bounds() | 屏幕坐标 |
boundsInParent() | 相对父节点坐标 |
示例
const node = text("登录").findOne(1000);
if (node) {
const screen = node.bounds();
const parent = node.boundsInParent();
log(`screen=${screen}`);
log(`parent=${parent}`);
}
UiRect 基础方法
这是 bounds() / boundsInParent() 返回的矩形对象可直接调用的方法。
UiRect.width()
读取矩形宽度。
UiRect.height()
读取矩形高度。
UiRect.centerX()
读取矩形中心点的 X。
UiRect.centerY()
读取矩形中心点的 Y。
UiRect.contains(x, y)
判断某个点是否落在矩形内部。
返回值
| API | 返回 |
|---|---|
width() | 宽度,最小不会小于 0 |
height() | 高度,最小不会小于 0 |
centerX() | 中心点 X |
centerY() | 中心点 Y |
contains(x, y) | 传入点是否落在矩形内;参数不合法时返回 false |
示例
const login = text("登录").findOne(1000);
if (login) {
const rect = login.bounds();
log(rect.width(), rect.height(), rect.centerX(), rect.centerY());
}
const root = auto.rootInActiveWindow;
if (root) {
const rect = root.bounds();
log(rect.contains(200, 300));
}
节点层级信息
UiObject.depth()
读取节点深度。
UiObject.indexInParent()
读取它在父节点里的位置索引。
UiObject.drawingOrder()
读取绘制顺序。
返回值
全部都是 number
怎么理解
| API | 说明 |
|---|---|
depth() | 节点深度 |
indexInParent() | 它在父节点里的索引 |
drawingOrder() | 绘制顺序 |
示例
const node = text("登录").findOne(1000);
log(node.depth(), node.indexInParent(), node.drawingOrder());
网格信息
这组特别适合表格、宫格、列表容器。
UiObject.row()
读取节点所在行。
UiObject.rowCount()
读取总行数。
UiObject.rowSpan()
读取跨行数。
UiObject.column()
读取节点所在列。
UiObject.columnCount()
读取总列数。
UiObject.columnSpan()
读取跨列数。
UiObject.childCount()
读取直接子节点数量。
返回值常见规则
| API | 没有相关信息时常见返回 |
|---|---|
row() / column() | -1 |
rowSpan() / columnSpan() | -1 |
rowCount() / columnCount() | 0 |
childCount() | 0 |
示例
const node = selector().classNameContains("RecyclerView").findOnce();
if (node) {
log(`childCount=${node.childCount()}`);
}
const cell = row(0).column(1).findOnce();
if (cell) {
log(cell.text());
}
节点状态读取
这是节点自身的布尔状态读取。
UiObject.clickable()
节点本身是不是可点击。
UiObject.longClickable()
节点本身是不是可长按。
UiObject.editable()
节点本身是不是可编辑。
UiObject.scrollable()
节点本身是不是可滚动。
UiObject.checkable()
节点本身是不是可勾选。
UiObject.checked()
节点当前是否已勾选。
UiObject.selected()
节点当前是否已选中。
UiObject.enabled()
节点当前是否已启用。
UiObject.password()
节点是不是密码输入框。
UiObject.focusable()
节点是否可获得普通焦点。
UiObject.focused()
节点当前是否已拿到普通焦点。
UiObject.visibleToUser()
节点当前是否对用户可见。
UiObject.accessibilityFocused()
节点当前是否已拿到无障碍焦点。
UiObject.contextClickable()
节点当前是否支持上下文点击。
UiObject.dismissable()
节点当前是否可关闭。注意这里仍然是源码拼写 dismissable。
返回值
全部都是 boolean
适合怎么用
先读状态,再决定动作,比盲点按钮稳很多:
const node = text("发送").findOnce();
if (node && node.enabled() && node.clickable()) {
node.click();
}
特别容易忽略的 4 个
| API | 含义 |
|---|---|
password() | 当前节点是不是密码输入框 |
visibleToUser() | 是否对用户可见 |
accessibilityFocused() | 是否拿到了无障碍焦点 |
contextClickable() | 是否支持上下文点击 |
树结构导航
这是树结构导航。
UiObject.parent()
拿父节点。
UiObject.child(index)
按索引拿某一个直接子节点。
UiObject.children()
拿全部直接子节点集合。
返回值
| API | 返回值 |
|---|---|
parent() | UiObject | null |
child(index) | UiObject | null |
children() | UiObjectCollection |
参数规则
child(index):
- 支持数字和数字字符串
- 小于
0、越界、解析失败时返回null
示例
const list = classNameContains("RecyclerView").findOnce();
if (list) {
const first = list.child(0);
const children = list.children();
log(children.length);
log(first ? first.text() : "no first child");
}
节点内查找
这是“只在当前节点子树里继续查”。
UiObject.find(selector)
在当前节点子树里查全部命中结果。
UiObject.findOne(selector)
在当前节点子树里查第一个命中的结果。
参数
| 参数 | 类型 | 说明 |
|---|---|---|
selector | UiSelector | 传 null 时会退成空选择器 |
返回值
| API | 返回值 |
|---|---|
find(selector) | UiObjectCollection |
findOne(selector) | UiObject | null |
一个重要提醒
如果你写:
node.find(null)
那不是报错,而是“把当前节点整棵子树里所有节点都找出来”。
示例
const card = textContains("群聊").findOnce();
if (card) {
const avatar = card.findOne(idEndsWith("avatar"));
const labels = card.find(classNameContains("TextView"));
log(labels.length);
}
点击动作
这是节点动作里最常用的一组。
UiObject.click()
优先走节点点击动作,失败后再回退到中心点点击。
UiObject.clickCenter()
直接按屏幕坐标点击控件中心。
UiObject.longClick()
优先走节点长按动作,失败后再回退到中心长按。
返回值
全部都是 boolean
三者差别
| API | 真实行为 |
|---|---|
click() | 先尝试节点 ACTION_CLICK,失败后回退到“点控件中心” |
clickCenter() | 直接按屏幕坐标点控件中心 |
longClick() | 先尝试节点 ACTION_LONG_CLICK,失败后回退到中心长按 |
什么时候选哪个
- 普通按钮优先
click() - 目标节点明明能拿到,但节点动作老失败,可试
clickCenter() - 需要呼出上下文菜单、拖动前长按等场景用
longClick()
示例
const send = text("发送").findOne(1000);
if (send && !send.click()) {
send.clickCenter();
}
文本输入与选区
UiObject.setText(value)
给节点设置文本。
UiObject.setSelection(start, end)
设置输入框选区。
setText(value) 行为
| 规则 | 说明 |
|---|---|
| 任何值都会先转字符串 | 123 会变成 "123" |
null 会变成空字符串 | 等于尝试清空文本 |
| 返回值 | boolean |
setSelection(start, end) 行为
| 规则 | 说明 |
|---|---|
| 参数支持数字和数字字符串 | 会转整数 |
非法值会退成 -1 | 然后再尝试执行节点动作 |
| 返回值 | boolean |
示例
const input = classNameContains("EditText").findOne(1200);
if (input) {
input.setText("hello jsxhook");
input.setSelection(0, 5);
}
const input = classNameContains("EditText").findOne(1200);
if (input) {
input.setText(null); // 尝试清空
}
滚动动作
这是滚动动作组。
UiObject.scrollForward()
向前滚动,最常见于列表继续往下翻。
UiObject.scrollBackward()
向后滚动。
UiObject.scrollUp()
向上滚动。
UiObject.scrollDown()
向下滚动。
UiObject.scrollLeft()
向左滚动。
UiObject.scrollRight()
向右滚动。
返回值
全部都是 boolean
要注意什么
- 它们不会先替你判断
scrollable()。 - 节点不支持对应动作时,通常就是
false。 scrollUp/Down/Left/Right依赖对应 action id 是否可用。
示例
const list = selector().scrollable(true).findOnce();
if (list) {
list.scrollForward();
}
const panel = selector().scrollable(true).findOnce();
if (panel && !panel.scrollDown()) {
panel.scrollForward();
}
焦点动作
这是两套“焦点”动作:
UiObject.focus()
请求普通输入/控件焦点。
UiObject.clearFocus()
清除普通输入/控件焦点。
UiObject.accessibilityFocus()
请求无障碍焦点。
UiObject.clearAccessibilityFocus()
清除无障碍焦点。
| API | 焦点类型 |
|---|---|
focus() / clearFocus() | 普通输入 / 控件焦点 |
accessibilityFocus() / clearAccessibilityFocus() | 无障碍焦点 |
返回值
全部都是 boolean
示例
const input = classNameContains("EditText").findOne(1000);
if (input) {
input.focus();
input.accessibilityFocus();
}
选择与剪贴板
这是选择与剪贴板动作组。
UiObject.select()
执行选中动作。
UiObject.copy()
执行复制动作。
UiObject.cut()
执行剪切动作。
UiObject.paste()
执行粘贴动作。
返回值
全部都是 boolean
更适合的场景
- 文本可选控件
- 输入框
- 带原生编辑动作的宿主控件
示例
const input = classNameContains("EditText").findOne(1000);
if (input) {
input.setSelection(0, input.text() ? input.text().length : 0);
input.copy();
}
setClip("你好,世界");
const input = classNameContains("EditText").findOne(1000);
if (input) {
input.paste();
}
其他动作
这是剩下几组比较场景化的节点动作。
UiObject.contextClick()
执行上下文点击。
UiObject.show()
尽量把节点滚动到可见区域。
UiObject.dismiss()
执行关闭动作。
UiObject.expand()
执行展开动作。
UiObject.collapse()
执行折叠动作。
返回值
全部都是 boolean
大致适合的控件
| API | 更常见场景 |
|---|---|
contextClick() | 支持上下文点击的控件 |
show() | 让节点滚动到可见区域 |
dismiss() | 可关闭提示、弹层 |
expand() | 可展开条目、折叠面板 |
collapse() | 可折叠条目、展开面板的反向动作 |
示例
const node = descContains("更多").findOnce();
if (node) {
node.contextClick();
}
const item = textContains("高级设置").findOnce();
if (item) {
item.show();
}
实战建议
最后给你 6 条写自动化脚本时非常实用的经验:
先
auto.waitFor(),再查节点。
很多“为什么找不到”的问题,其实是服务还没真正连上。优先写稳定选择器,再考虑坐标。
文案、id、类名、包名、布尔状态先组合一遍,只有实在没有节点动作时,再下沉到坐标。搜索模式别乱设。
不确定时先用auto.setSearchMode("smart"),它比一上来就fast稳。界面刚变化后,怀疑结果发旧就
auto.clearCache()。筛选条件先宽后窄。
例如先classNameContains("TextView"),再textContains("设置"),最后必要时加filter(fn)。拿到
UiObject后先读状态。enabled()、clickable()、visibleToUser()往往比一上来直接click()更省排错时间。
