坐标和控件这两个东西,是 Auto.Js 绕不开的两条腿,也是很多人从"能跑"到"跑得稳"之间那道最深的坎。我见过太多脚本,第一版跑得漂漂亮亮,换台手机就全乱了:明明点的是"确认"按钮,结果点到了旁边的空白;明明找得到控件,第二天 App 一升级全变成 null。说到底就是没搞明白坐标是怎么算出来的、控件是怎么被找到的、以及为什么"模拟真人操作"这件事在工程上远不止加几个随机数那么简单。这篇就按我自己的实操顺序捋一遍:先把坐标体系和控件树这两块地基打牢,再讲怎么把点击、滑动、节奏做得像真人手在动,最后把我这两年踩过的坑整理成一张速查表。内容偏实战,代码片段都能直接抄改,适合已经能写出"能跑脚本"、但被稳定性和适配问题折磨过的人;如果你是纯新手,也能看懂,因为我会把 dp、px、bounds 这些基础概念用生活化的方式先讲清楚。
1. 先想清楚:为什么坐标和控件要一起用
1.1 两种定位方式的本质差异
坐标定位和控件定位,本质上解决的是同一个问题——"我要点哪里",但它们看世界的方式完全不同。坐标定位是"盲操作",它只知道屏幕上一个数学点,(540, 960) 就是 (540, 960),屏幕上是按钮还是广告图它不关心,点下去就完事。控件定位是"带眼睛的操作",它先让无障碍服务把当前界面的控件树读出来,每个控件带着 id、文本、类名、位置信息,然后按条件筛选出目标,再对它执行 click。
这个差异决定了它们各自的死穴。坐标定位最大的问题是它绑死在分辨率上,1080×1920 上调好的点,拿到 1440×3200 上就偏得没边;而且它对界面变化零感知,弹窗一冒出来,你的点击就落在了错误的东西上。控件定位最大的问题是它绑死在控件树上,App 一旦把原生控件换成自绘的 Canvas 或者 WebView,无障碍服务读到的可能只剩一个大壳子,里面啥也没有,这时候你写text("立即购买").findOne()会一直等到超时。
所以正确的做法不是二选一,而是分层:能用控件就用控件,控件拿不到再退回坐标,坐标还要做分辨率适配。这个思路听着简单,但真正把它写成代码结构的人不多,大部分人是一开始用坐标写顺手了,后面就再也懒得换。
1.2 方案选型的判断标准
我一般用下面这张表来决定某个交互点该用哪种方式,你可以直接拿去对照:
| 场景特征 | 推荐方式 | 理由 |
|---|---|---|
| 原生 App,按钮有明确 text/id | 控件定位 | 换机型、换分辨率都不受影响 |
| 列表项,同类控件多个 | 控件 + index/bounds 筛选 | 需要额外区分条件,纯坐标无法泛化 |
| 游戏画面、自绘 Canvas | 坐标 + 图像识别 | 控件树里读不到有效节点 |
| H5 页面 | 控件优先,fallback 坐标 | WebView 部分节点可读,但层级浅 |
| 悬浮窗、系统弹窗 | 控件 + 等待重试 | 出现时机不确定,需要轮询 |
| 固定布局的工具类页面 | 坐标 + 比例换算 | 页面几乎不变,坐标更省事 |
判断的核心就一句话:这个元素有没有稳定的、可被无障碍服务读到的特征?有,就用控件;没有,才考虑坐标。很多人反过来,图省事直接用坐标,结果脚本的生命周期只有几天。
1.3 一个真实的踩坑:纯坐标脚本为什么三天就废了
我最早写的一个自动化脚本,全是硬编码坐标,在一个固定机型上跑得很顺。第三天开始出问题:先是弹了个"更新提示",遮挡了原本的按钮位置,脚本还在往原坐标点,点到了"稍后再说"上;接着 App 做了一次 UI 微调,按钮往下挪了 12 个像素,肉眼几乎看不出来,但脚本的点就落在按钮边缘外了。那两天我一直在加坐标偏移和条件判断,代码越堆越乱,最后干脆推倒重写。
重写之后的结构是这样的:每一处交互封装成一个函数,函数内部先尝试控件定位,超时后降级到坐标,坐标全部走比例换算。这么改完,同一个脚本在三种不同分辨率的设备上都能跑,而且 App 小改版基本不用动代码。这个经历给我的教训是:坐标不是不能用,但不能作为第一选择,它应该是兜底方案,而不是主方案。
2. 坐标系与坐标转换:把屏幕上的点算准
2.1 px、dp 与屏幕坐标系的关系
安卓的坐标体系里,最容易被搞混的就是 px 和 dp。px 是物理像素,是屏幕上真实存在的点;dp 是密度无关像素,是一个逻辑单位,它跟屏幕密度挂钩,换算关系是px = dp × density。举个例子,一台 1080×2400、density 为 3 的手机,1 dp 等于 3 px,一个宽度 100 dp 的按钮,实际占 300 px 宽。
那 Auto.Js 里的click(x, y)吃的到底是哪个?是px,而且是以屏幕左上角为原点、向右为 x 正方向、向下为 y 正方向的绝对坐标。所以你在手机上用开发者选项里的"指针位置"读到的数值,和 click 里写的数值,是同一个体系,这一点可以放心。
但很多人不知道的是,同一个 App 在不同手机上,同一个按钮的 px 坐标是不一样的,dp 位置却基本一致。这就是为什么适配的第一原则是"以比例或 dp 换算,而不是硬编码 px"。Auto.Js 提供了device.width、device.height、device.density三个属性,前两个是当前设备的实际像素尺寸,第三个是密度值。有了这三个数,你就能把任意坐标在设备间做等比映射。
顺带说一个常见误区:device.width拿到的不一定是完整屏幕宽度。在有虚拟导航栏、刘海屏或者横屏状态下,这个值可能跟你预期的不一样。所以脚本启动时最好先打印一次,确认实际值,别凭经验猜。
2.2 分辨率适配的三种落地思路
我在项目里用过的适配方案有三种,按复杂度递增,你可以按需要挑:
第一种是setScreenMetrics。这是 Auto.Js 内置的适配方法,你在脚本开头调用setScreenMetrics(1080, 1920),之后所有的 click、swipe、gesture 坐标都会按"当前分辨率 / 设定分辨率"的比例自动缩放。写起来最省事,缺点是它是全局生效的,如果你同时要用控件 bounds 返回的绝对坐标去换算,就容易出现两套坐标系打架的情况。
第二种是手动比例换算。自己封装一个函数,把设计稿坐标转成当前设备坐标:
const DESIGN_W = 1080; const DESIGN_H = 1920; const SCALE_X = device.width / DESIGN_W; const SCALE_Y = device.height / DESIGN_H; function toReal(x, y) { return { x: Math.round(x * SCALE_X), y: Math.round(y * SCALE_Y) }; }这种写法的好处是可控,你能明确知道每一步在做什么,也能针对不同区域用不同的缩放系数(比如上下安全区不参与缩放)。我现在的脚本基本都用这一种。
第三种是相对位置法。干脆不记绝对坐标,只记"这个点在整个屏幕的百分之多少位置"。比如屏幕右上角的关闭按钮,记为 (0.93, 0.06),用的时候乘以device.width和device.height。这种方式对异形屏最友好,但表达力不如前两种,适合少数几个固定位置。
2.3 比例换算里的两个细节坑
第一个坑是长宽比不一致。手机屏幕的比例从 16:9 到 20:9 甚至更夸张的都有,如果你用同一个比例去缩放横纵坐标,在长屏手机上纵向就会被拉长,点位整体下移。稳妥的做法是横纵分开算,甚至纵向用"相对于可用高度的比例",把状态栏和导航栏的高度先减掉。
第二个坑是安全区偏移。有些机型顶部状态栏特别高,有些底部手势条会占掉几十像素。如果你的脚本要在屏幕最顶或最底操作,一定要拿device.height减去底部安全区,或者直接用控件 bounds 拿实际值。我吃过一次亏:脚本在底部点击"提交",在某一台上永远点不中,排查半天发现是手势条拦截了触摸事件,往上挪 60 像素就正常了。
提示:比例换算只解决"位置对不对",解决不了"顺序对不对"。界面加载慢的时候,位置再准也没用,该等还得等。
3. 控件定位:从选择器到控件树遍历
3.1 selector 常用属性速查
Auto.Js 的控件定位入口是selector(),也可以用它的快捷函数text()、id()、desc()、className()。这些函数返回的是一个选择器对象,只有调用findOne()、find()、exists()之后才真正去查控件树。
我把最常用的属性整理成表,写脚本时可以直接对照:
| 属性 | 含义 | 使用建议 |
|---|---|---|
| text / textContains | 文本 / 文本包含 | text 精确匹配最稳,textContains 慎用 |
| id | 资源 id | 最稳定,优先用,但要注意 App 改版会变 |
| desc | 内容描述 | 图片按钮常用,比 text 更可靠 |
| className | 控件类名 | 一般作为辅助条件,单独用太宽泛 |
| clickable | 是否可点击 | 组合条件时非常有用 |
| bounds | 位置区域 | 用于筛选和拿中心点 |
| depth | 层级深度 | 用来区分同 id 的多个控件 |
组合条件的写法是链式的,比如text("搜索").className("android.widget.Button").findOne()。链式条件越多,匹配越精确,但同时也越脆弱——任何一个条件发生变化都会导致匹配失败。我的经验是两个条件足够,最多三个,再多就是在给自己埋雷。
另外提醒一点:id()匹配的是完整资源 id,形如com.xxx.app:id/btn_confirm,很多教程里只写id("btn_confirm")是不严谨的写法,某些版本能匹配上是因为做了模糊处理,换个版本可能就失效。稳妥起见写全。
3.2 控件树遍历与 bounds 取值
findOne()拿到的控件对象,本身带了一堆方法。最有用的几个是bounds()、clickable()、click()、parent()、children()。
bounds()返回一个对象{left, top, right, bottom},注意这里是相对屏幕的绝对像素坐标,跟 click 用的是同一套,所以可以无缝衔接。要拿中心点:
let b = widget.bounds(); let cx = (b.left + b.right) / 2; let cy = (b.top + b.bottom) / 2;还有一个boundsInParent(),返回的是相对父容器的坐标,这个容易混淆,一般用不上,除非你要做相对偏移。
遍历控件树的典型场景是"同一个 id 有多个控件"。比如列表里的每个条目 id 都一样,你得先find()拿到数组,再按索引或按文本内容筛选。再复杂一点,你要找某个文本下方的那个按钮,那就得先定位文本控件,拿它的 bounds,再在它下方一定范围内的控件里找目标:
let label = text("收货地址").findOne(3000); if (label) { let lb = label.bounds(); let btn = selector().clickable(true) .boundsContains(lb.left, lb.bottom, lb.right, lb.bottom + 200) .findOne(2000); }这种"锚点 + 区域"的写法,比硬编码坐标稳得多,也更抗改版——只要"收货地址"这四个字还在,下面的按钮移到哪都能找到。
3.3 控件找不到时的阶梯式降级
控件的最大问题不是找不到,而是你不知道它为什么找不到。可能是界面还没加载完,可能是弹窗挡着,可能是控件不可见,也可能是 App 换成了自绘。我现在的做法是写一个统一的查找函数,按阶梯逐级降级,每一级都记录日志:
function smartFind(opt) { // 第一级:精确条件 let w = selector().text(opt.text).findOne(1500); if (w) return { type: 'widget', target: w }; // 第二级:模糊条件 w = selector().textContains(opt.keyword).findOne(1500); if (w) return { type: 'widget', target: w }; // 第三级:坐标兜底 if (opt.fallbackXY) { let p = toReal(opt.fallbackXY[0], opt.fallbackXY[1]); return { type: 'coord', target: p }; } return null; }这个结构的好处是降级路径是显式的。脚本跑出问题时,日志里能直接看到是走了哪一级,排查效率提升非常明显。如果每次都只写一句findOne(),失败了你只能靠猜。
注意:降级到坐标之后,点击前的校验千万不能省。我一般会在坐标点击前后各截一次屏,比对目标区域是否发生了变化,没变化就说明点空了。
4. 模拟真人操作:手势、轨迹与节奏
4.1 点击不是"点一下"那么简单
先说一个容易被忽略的事实:真人的点击从来不在控件正中心。人手点按钮,落点会在按钮范围内随机分布,偏上偏下偏左偏右都有,而且大部分人有个"向右下偏"的习惯。如果你的脚本每次都精准点在中心像素上,从操作特征上看是非常不自然的。
所以我现在的点击函数是这么写的:拿到控件 bounds 之后,在控件范围内取一个带权重的随机点,同时避开边缘——因为边缘可能是圆角或者不可点击区域:
function humanClick(widget) { let b = widget.bounds(); let w = b.right - b.left; let h = b.bottom - b.top; // 在中间 60% 区域内随机,避开边缘圆角和不可点区域 let x = b.left + w * (0.2 + Math.random() * 0.6); let y = b.top + h * (0.2 + Math.random() * 0.6); press(Math.round(x), Math.round(y), random(60, 140)); }注意这里用的是press而不是click。原因是press带按压时长,而click在很多实现里按压时间是固定的、极短的。真人的手指接触屏幕,从按下到抬起通常有 60 到 150 毫秒,用press更接近真实手感。按压时长本身也做随机,固定值反而成了特征。
还有一种情况是控件本身不可点击,但它的父容器可点击。这时候直接widget.click()是没反应的,你得往上找:
let target = widget; while (target && !target.clickable()) { target = target.parent(); } if (target) target.click();这个循环我建议加上层数限制,否则遇到诡异的控件树可能一路往上爬到根节点。
4.2 滑动轨迹:为什么直线滑动看起来假
滑动的核心不是起点和终点,而是中间路径。真人手指滑动是一条微微弯曲的弧线,速度是先快后慢再快,最后停下来;而swipe(x1,y1,x2,y2,300)是一条完美的直线匀速运动。这个差别在短距离滑动上无所谓,但在长距离滑动、连续滑动场景里,直线轨迹是很明显的特征。
解决办法是用gesture()手动构造轨迹点。思路是先算一条二次贝塞尔曲线,然后在曲线上采样若干个点,再传给 gesture:
function bezierPoint(p0, p1, p2, t) { let mt = 1 - t; return { x: mt * mt * p0.x + 2 * mt * t * p1.x + t * t * p2.x, y: mt * mt * p0.y + 2 * mt * t * p1.y + t * t * p2.y }; } function humanSwipe(sx, sy, ex, ey, duration) { // 控制点随机偏移,制造弧度 let mid = { x: (sx + ex) / 2 + random(-60, 60), y: (sy + ey) / 2 + random(-40, 40) }; let points = []; let steps = 24; for (let i = 0; i <= steps; i++) { let t = i / steps; let p = bezierPoint({x: sx, y: sy}, mid, {x: ex, y: ey}, t); points.push([Math.round(p.x), Math.round(p.y)]); } gesture(duration, points); }控制点的随机偏移量决定了弧度大小,我一般控制在起终点距离的 5% 到 10% 之间,太小看不出效果,太大轨迹会飘。采样点数 20 到 30 个够用,太多反而增加执行开销。
这里有个版本兼容的坑要提醒:gesture在不同分支的签名不一样,有的版本是gesture(duration, [x1,y1], [x2,y2], ...),有的版本是gesture(duration, [[x1,y1],[x2,y2], ...])。你写之前先查一下手上版本的文档,或者两种情况都写一个 try-catch 包起来。我之前换版本的时候就是因为这个白排查了半天。
4.3 节奏:延迟的分布比延迟的大小更重要
很多人做"模拟真人",第一反应是把所有sleep改成sleep(random(500, 1000))。这个方向对,但不够。真人的操作节奏有几个特征:
第一,不是均匀分布。人做重复动作的间隔时间分布是右偏的,大部分操作很快,偶尔会有一次明显的停顿(走神、看消息、被别的东西吸引)。用均匀分布模拟不出这个特征。我一般用"基础延迟 + 概率性长延迟"的组合:
function humanDelay() { sleep(random(180, 420)); // 12% 概率出现一次明显停顿 if (Math.random() < 0.12) { sleep(random(1200, 3000)); } }第二,操作之间有阅读时间。点进一个页面立刻点下一个按钮,这不符合人的习惯。有人会先扫一眼页面,有人会滑动一下再看看。所以在进入新页面后加一段"浏览延迟",比单纯加随机数值有效得多。
第三,同一批次内的节奏要一致。这是个反直觉的点:如果你的脚本每次执行的操作间隔都是完全独立的随机数,整体看反而很"白噪声"。真人的节奏是有惯性的——今天手快,整段都偏快;今天手慢,整段都偏慢。所以我会在脚本启动时抽一个"当日速度系数",比如 0.85 到 1.2,所有延迟都乘上这个系数。这样同一段脚本的节奏更连贯。
提示:所有涉及随机的地方都要用统一的随机源和明确的取值范围,别随手写
Math.random() * 1000,这样参数无法统一调整。
5. 常见问题与排查实录
5.1 问题速查表
下面这张表是我这两年在实际项目里遇到频率最高的十几个问题,按"现象—可能原因—排查动作"整理,可以直接当工具用:
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 点击无反应 | 坐标超出安全区 / 控件不可点击 | 打印 bounds,往上找 clickable 父节点 |
| 一直 findOne 超时 | 界面未加载完 / 弹窗遮挡 | 加 waitForActivity,截图看当前界面 |
| 换机型后点位偏移 | 硬编码 px 坐标 | 改用比例换算,横纵分开算 |
| bounds 全是 0 | App 使用自绘或 WebView | 改用图像识别 + 坐标点击 |
| 点击到了错误的元素 | 未加唯一性条件 | 增加 className 或 depth 过滤 |
| 滑动距离对但没生效 | 滑动时长过短被识别为 fling | 把 duration 提到 400ms 以上 |
| 脚本跑一半卡死 | 某个 findOne 没有超时参数 | 所有查找必须带超时,禁止无参调用 |
| 控件 id 突然失效 | App 版本更新 | 检查 id 变化,或改用 text/desc |
| 文字匹配不到 | 文本带空格或换行 | 用 textContains,或先 trim 再比 |
| 随机延迟不生效 | sleep 单位是毫秒,写成了秒 | 检查数值量级 |
这张表里我要特别强调两条。第一条是所有 findOne 必须带超时参数,无参调用会一直阻塞下去,脚本就卡死在那里了,日志都不会输出。这是新手最常见的坑,没有之一。第二条是滑动时长不要低于 400 毫秒,太短的滑动在手势识别层面会被当成快速滑动处理,滚动惯性会失控,页面直接飞出去好几个屏。
5.2 独家避坑技巧
分享几个文档里不会写、但实际用起来很省事的小技巧。
技巧一:用截图做可视化调试。每次脚本失败的时候,先captureScreen()存一张图,文件名带上时间戳和当前步骤名。事后翻图比看日志直观一百倍,一眼就能看出当时是哪个弹窗挡住了。我现在每个关键步骤都存图,占不了多少空间,排查效率翻倍。
技巧二:给每个操作步骤加"前置断言"。执行点击之前,先确认当前页面的关键特征存在,比如waitForActivity("com.xxx.MainActivity", 5000)或者text("首页").exists()。断言失败就停止当前流程,而不是继续往下瞎点。这个习惯能避免脚本在错误状态下做出一连串错误操作,把局面搞得更乱。
技巧三:坐标和控件混用时,统一坐标系。如果你用了setScreenMetrics,那所有手动换算的坐标就别再自己乘比例了,否则会被缩放两次。我的做法是全局只用一套:要么全用 setScreenMetrics 加设计稿坐标,要么全用手动换算。混着用迟早出问题。
技巧四:把随机数种子集中管理。我在脚本里定义了RANDOM_RANGE = { clickOffset: 0.6, delayBase: [180, 420], delayLong: [1200, 3000] }这样的配置对象,所有随机都从这里取值。调参的时候改这一处就行,不用满脚本找random(。
技巧五:图片识别当坐标兜底时要限定区域。如果在整屏范围内做图像查找,耗时可能到几百毫秒甚至更久,而且容易匹配到相似图案。做法是先根据页面结构圈定一个大致的区域,用findImage(img, {region: [x, y, w, h]})缩小搜索范围,命中率和速度都会明显改善。
技巧六:注意控件文本的动态性。很多 App 的按钮文本会变,比如"立即购买"和"马上抢"、"去支付"和"确认支付"。硬匹配一个文本,遇到另一版就失效。这时候用textMatches(/去支付|确认支付/)这类正则匹配,容错率高很多。但要克制,正则写得太宽泛反而会误匹配。
最后说一点我个人对这个方向的看法。做自动化脚本,稳定性永远比功能多重要。我见过太多人花一周堆了二十个功能,然后花两个月修 bug,最后放弃。真正省时间的路径是反过来的:先花一天把坐标系、控件查找、点击手势这三个基础模块封装扎实,后面加功能就是几行代码的事。坐标会漂、控件会变、界面会改,这些都不会停止发生,但一套分层降级的架构能让你在它们变化的时候只需要改一个地方,而不是从头再来一遍。另外,任何自动化工具的使用都应当遵守目标应用的服务条款和相关规定,这一点在动手写第一行代码之前就该想清楚。