无人值守的自动化脚本,最难的不是“写出来”,而是“跑得住”。本文不聊具体业务,只聊一件事:脚本为什么跑着跑着就挂?以及工程上怎么让它不挂。全文以 EasyClick 为贯穿的实现示例——安卓手机自动化(免 root)平台,脚本语言 JavaScript,官方文档;文中出现的函数名都来自它的公开文档,可以随时核对。
一、脚本挂掉的第一现场:四类典型故障
先还原“第一现场”。无人值守脚本的挂法看起来千奇百怪——日志停在某一行、界面停留在某个页面、悬浮窗消失、设备黑屏……但归纳起来,几乎全部可以归进四类故障:
| 故障类型 | 现场表现 | 一句话根因 |
|---|---|---|
| 空点击 | 日志显示“点了”,界面却没反应,任务卡在下一步 | 元素还没出现/还没可点,脚本就先动手了 |
| 定位失败 | 选择器反复匹配不到节点,同一个地方反复超时 | 界面升级、布局调整、文案变化,旧定位条件失效 |
| 服务被回收 | 无障碍服务掉线,节点、点击、输入全部失灵 | 内存紧张或厂商省电策略,回收了后台服务 |
| 环境变化 | 同一个脚本 A 机型正常、B 机型乱点 | 分辨率/DPI/显示缩放差异,写死的坐标失效 |
关键结论先给出来:绝大多数“跑着跑着就挂”都不是玄学,而是脚本把“界面稳定、服务在线、坐标不变”当成了默认前提——而真实手机上,这四个前提随时会被打破。所以稳定性工程只有一件核心的事:为每一个可能被打破的前提,准备“检测 + 恢复”的路径。
下面逐个拆解,每个故障给出原理与工程化解法。
二、故障一:空点击——元素还没出现就操作
原理:脚本的“快”是双刃剑。人看到按钮才点,脚本却不知道页面加载到哪一步。最天真的写法是 sleep(3000) 后直接点——网络慢一点、广告位多一个、动画多播半秒,元素没出来,点击就落在空白处。“录出来的脚本能跑但不稳定”,头号来源就是它。
解法:把“等一个固定秒数”换成“等元素出现”。EasyClick 的选择器提供了 waitExistNode(毫秒):最多等 N 毫秒,元素一旦出现立即返回,超时才返回空(选择器与节点文档):
function main() {
// 等待"立即领取"出现,最多等 5 秒;出现即返回,不用干等
let node = text("立即领取").waitExistNode(5000);
if (node) {
node.click(); // 节点区域随机点击
} else {
loge("等待超时:5 秒内未出现目标节点");
}
}
main();
如果不想用现成的等待函数,手写“轮询”也是一样的原理——每 500ms 查一次节点,直到出现或达到次数上限。理解这个循环,就理解了所有等待类 API 的本质:
function main() {
let node = null;
// 轮询代替固定 sleep:每 500ms 查一次,最多查 10 次
for (let i = 0; i < 10; i++) {
node = text("立即领取").getOneNodeInfo(0); // 0 表示不等待,立即查一次
if (node) {
break;
}
sleep(500);
}
if (node) {
node.click();
} else {
loge("轮询 10 次仍未找到节点");
}
}
main();
两种方式对比:
| 写法 | 行为 | 问题 |
|---|---|---|
sleep(3000) 后直接操作 |
固定等 3 秒 | 等不够(元素没出)或白等(浪费时间) |
waitExistNode(5000) |
等到出现为止,最多 5 秒 | 需要节点体系可用(无障碍/代理模式全功能) |
轮询 getOneNodeInfo(0) + sleep(500) |
每 500ms 查一次,最多 N 次 | 适合自己控制节奏,原理与 waitExistNode 相同 |
两个补充细节:一是“判断有没有”用 has(),“等待出现”用 waitExistNode(),各有分工;二是拿到的节点在页面重绘后可能失效,可以用节点的 isValid() 判断有效性,失效就重新取。把“等元素出现再操作”养成习惯,脚本稳定性的第一道保障就到位了。
三、故障二:元素定位失败——界面升级、布局变化
原理:选择器匹配的是“属性”,而属性会变。App 发版改了资源 id、改了按钮文案、把控件从 A 容器挪进 B 容器——旧的选择器就失配了。只赌单一属性的脚本,本质上是在赌“这个 App 永远不改版”。
解法:节点优先,且多属性组合。定位策略按稳定性排优先级:
| 优先级 | 策略 | 说明 |
|---|---|---|
| 1 | id(资源 ID) | 最稳定,但混淆或重构后可能变化 |
| 2 | 文本 text / 描述 desc | 语义稳定;文案一变就失效,用 textMatch/descMatch 正则兜底 |
| 3 | 多属性组合 + 级联 | 同时匹配文本、类名、选中态等;或先选父级再选子级 |
| 4 | 坐标兜底 | 只在节点体系拿不到时用,配合节点区域随机点击 |
多属性组合的例子——同时约束“文本 + 类名 + 选中状态”,比单一属性鲁棒得多:
function main() {
// 多属性组合:文本(正则) + 选中态 + 类名,三者同时满足
let selector = textMatch(".*选择器.*")
.checked(true)
.clz("android.widget.CheckBox");
let node = selector.getOneNodeInfo(3000);
if (node) {
node.click();
} else {
loge("主策略定位失败,进入备选策略");
}
}
main();
再配合两个机制:级联选择——无法直接选中时,先选父级再通过 .child() 选子级;正则匹配——textMatch、idMatch、clzMatch 等容忍文案和 ID 的微小变化。发布前在开发工具的节点面板里逐页核对一遍定位条件,比上线后对着日志猜快得多。
关键结论:能用节点定位就不用坐标;能组合多个属性就不要只赌一个属性。 界面升级是常态,把“升级后定位失效”当成一定会发生的事来设计,脚本才扛得住版本迭代。
四、故障三:无障碍服务被系统回收——内存紧张时厂商系统会杀服务
原理:安卓系统在内存紧张时会回收后台服务,国产厂商还有更激进的“省电管理”“神隐模式”——官方常见问题里就有一条:脚本运行约 20 分钟后自动停止、悬浮窗消失,正是神隐模式/省电模式导致(常见问题文档)。无障碍服务一旦被回收,节点、点击、输入全部失灵,而脚本可能还在“自嗨”地往下跑——表现为“脚本没退出,但什么都不做了”。
解法:检测 + 自愈。核心是三个全局函数(全局模块文档):
| 函数 | 作用 |
|---|---|
isServiceOk() |
自动化服务是否正常,返回 true/false |
startEnv() |
启动自动化服务环境 |
daemonEnv(true) |
守护自动化环境,尽量保证自动服务不掉线(EC 6.7.0+) |
官方文档(AI 辅助编程文档)里就有一个“检测 → 启动 → 再检测”的 autoServiceStart 模板,直接作为脚本入口的自愈循环:
// 官方 autoServiceStart 模板:循环检测,服务不在线就启动,最多 time 次
function autoServiceStart(time) {
for (let i = 0; i < time; i++) {
if (isServiceOk()) {
return true;
}
startEnv();
sleep(1000);
}
return isServiceOk();
}
function main() {
// 入口先确保服务就绪,3 次机会
if (!autoServiceStart(3)) {
loge("服务启动失败");
return;
}
// 业务逻辑……
}
main();
进阶做法是监听服务被回收的事件,自动拉起。全局模块支持监听“无障碍服务被销毁 / 被中断”事件(acc-service-destroy / acc-service-interrupt),收到事件就执行一次自愈:
function main() {
autoServiceStart(3);
// 服务被系统销毁/中断时,自动尝试恢复
observeEvent("acc-service-destroy", function (key, data) {
loge("无障碍服务被销毁,开始自愈: " + data);
autoServiceStart(3);
});
observeEvent("acc-service-interrupt", function (key, data) {
loge("无障碍服务被中断,开始自愈: " + data);
autoServiceStart(3);
});
// 业务逻辑……
}
main();
设备侧也要配合:把 EC 加入厂商的后台白名单、关闭省电/神隐限制;EC 系统设置里支持配置“开机自启动”(setECSystemConfig 的 auto_start_service 参数),设备重启后脚本能自动恢复运行。服务保活是“脚本侧自愈 + 设备侧放行”两条腿走路,缺一条都跑不长久。
五、故障四:分辨率与机型差异
原理:屏幕物理分辨率、DPI、刘海/挖孔、显示缩放各不相同,同一个 (x, y) 在不同机型上就是不同的物理位置。EasyClick 支持安卓 5.0 到最新系统,覆盖面越广,机型差异这个坑就越值得认真对待。
解法:按节点,不按坐标。节点定位天然自带“坐标自适应”——节点的 bounds 是系统按当前屏幕实际计算出来的边界,换机型、改分辨率,脚本一行都不用改。更妙的是节点的 click() 本身就是节点区域随机点击(文档原话):点击落在节点区域内、位置不完全一致,既免去了手算坐标,也让操作不那么机械。
必须用坐标的场景(滑动、手势等),用相对坐标——按屏幕宽高比例换算,而不是写死像素:
function main() {
let w = device.getScreenWidth();
let h = device.getScreenHeight();
// 从屏幕 80% 高度滑到 20% 高度,换机型也能用
swipeToPoint(w / 2, h * 0.8, w / 2, h * 0.2, 500);
}
main();
坐标 vs 节点,一个表说清:
| 维度 | 写死坐标 | 节点定位 |
|---|---|---|
| 分辨率变化 | 全废 | 自适应(bounds 按当前屏幕计算) |
| 机型差异 | 每台机器一套坐标 | 一套脚本通吃 |
| 布局微调 | 偏一点就点错 | 节点还在就点得准 |
| 操作特征 | 每次都同一个像素点 | 节点区域内随机,位置不完全一致 |
关键结论:坐标是“最后的兜底”,不是“默认方案”。 把“写坐标”当成一种需要额外理由的操作,脚本的跨机型稳定性会立刻上一个台阶。
六、兜底设计:重试、超时、日志、截图留证
前面的四类故障,讲的是“怎么防”。但工程上还要接受一个现实:任何防线都可能被击穿。所以最后一道设计是兜底——把“失败”当成正常分支来写,而不是当成意外。
- 重试:给关键操作包一层重试,失败就按间隔再试,达到次数上限再走失败分支:
// 通用重试:fn 返回真值即成功,最多重试 times 次,每次间隔 1 秒
function retry(times, fn) {
for (let i = 1; i <= times; i++) {
try {
let result = fn();
if (result) {
return result;
}
} catch (e) {
loge("第 " + i + " 次执行异常: " + e);
}
sleep(1000);
}
return null;
}
function main() {
let node = retry(3, function () {
return text("立即领取").waitExistNode(3000);
});
if (node) {
node.click();
} else {
loge("重试 3 次仍失败,进入失败处理流程");
}
}
main();
- 超时:所有等待和重试都要有上限。
waitExistNode(5000)的超时参数、重试次数上限,本质都是同一个原则——不允许脚本无限期地等下去。 - 日志:用
setSaveLogEx(true, "/sdcard/aaa/", 1024 * 1024, "testlog")把日志落盘,失败分支用loge记录现场;再注册setExceptionCallback监听脚本异常停止。日志是事后复盘的第一手材料。 - 截图留证:失败分支里把屏幕截图存到本地,与日志对照,几分钟就能定位到挂掉的那一步。
- 告警:无人值守场景,用
sendDingDingMsg(url, secret, msg, atMobile, atAll)把失败消息推到钉钉群,第一时间通知人。 - 兜底重启:异常难以就地恢复时,可用
restartScript(null, true, 3)重启脚本(文档特别提醒:该方法威力巨大,是否自动重启要自行控制好)。
关键结论:稳定性 = 防得住(前四章)+ 兜得住(本章)。 把每个可能失败的点都写一个“失败分支”,脚本就从“碰运气”变成了“可预期”。
七、稳定性检查清单(发布前逐项核对)
发布前把下面这张表过一遍,比上线后救火高效得多:
| 检查项 | 要求 | 对应章节 |
|---|---|---|
| 每个操作前是否等待了节点 | 用 waitExistNode / getOneNodeInfo(超时),不用裸 sleep | 二 |
| 是否有固定 sleep 依赖 | 仅用于模拟人为节奏 | 二 |
| 定位是否单一属性 | 至少双属性组合,必要时正则/级联 | 三 |
| 是否处理了节点为空的返回值 | 每个 node 判空,空则走降级或失败分支 | 三 |
| 入口是否检测服务状态 | autoServiceStart 循环启动 + 校验 | 四 |
| 是否配置服务自愈 | observeEvent 监听 acc-service-destroy / acc-service-interrupt | 四 |
| 设备侧是否放行后台 | 关闭省电/神隐限制,加入后台白名单,配置开机自启 | 四 |
| 是否有写死坐标 | 全部换成节点定位或相对坐标 | 五 |
| 是否有多机型验证 | 主流分辨率与系统版本各过一遍 | 五 |
| 所有等待与重试是否有上限 | 超时参数、次数上限齐全 | 六 |
| 是否留日志与截图 | setSaveLogEx + 失败分支截图 | 六 |
| 是否配置异常回调与告警 | setExceptionCallback + sendDingDingMsg | 六 |
八、FAQ
Q1:脚本跑十几分钟就自己停了,悬浮窗也没了,是什么原因?
A:最常见是厂商的省电管理/神隐模式在后台杀进程(vivo 手机管家的省电管理、小米的神隐模式都干过这事),其次是内存紧张时系统回收了无障碍服务。对策分两层:设备侧把 EC 加入后台白名单、关闭省电限制;脚本侧用 isServiceOk() 检测 + startEnv() 自动重启做自愈。
Q2:waitExistNode 和固定 sleep 有什么区别?
A:sleep(3000) 是“不管元素出没出来,都等 3 秒”;waitExistNode(5000) 是“等到元素出现为止,最多等 5 秒”,元素一到立即继续。固定等待要么等不够、要么白等,等待类操作应优先用 waitExistNode。
Q3:界面升级后脚本找不到控件了,怎么办? A:先做“抗升级”定位:多属性组合(id + 文本 + 类名)而不是单一属性;文案可能变的地方用 textMatch 正则;父级结构变化用级联选择(先选父再选子)。最后保留坐标兜底并记日志,升级后对照日志快速定位是哪一级定位失效。
Q4:无障碍服务被系统回收后,脚本能自动恢复吗?
A:默认不能,需要脚本自愈:isServiceOk() 检测 + startEnv() 重启,官方 autoServiceStart 模板就是“检测-启动-再检测”的循环;还可以监听 acc-service-destroy / acc-service-interrupt 事件,被回收时自动拉起。
Q5:同一个脚本在不同手机上表现不一样,怎么处理? A:优先节点定位,天然适配分辨率差异;必须用坐标时按屏幕宽高比例换算,不写死像素;发布前在主流分辨率与机型上过一遍稳定性清单。
Q6:脚本挂掉之后,怎么复盘是哪一步出的问题? A:靠“留证”:setSaveLogEx 落盘日志、setExceptionCallback 记录异常停止、失败分支截图存本地、必要时 sendDingDingMsg 发告警。日志 + 截图对照,很快就能定位到挂掉的那一步。
Q7:固定 sleep 是不是完全不能用了? A:不是。等待元素出现优先 waitExistNode;sleep 适合“人为节奏”场景,比如两次点击之间模拟人工操作间隔。原则是:能用“条件等待”就不用“时间等待”。
Q8:脚本卡在某个等待上不动了怎么办? A:所有等待都要有上限:waitExistNode 传超时毫秒、重试循环设次数上限;配合 setExceptionCallback 捕获异常,必要时 restartScript 自动重启脚本。无人值守建议加运行监控,超时未完成自动拉起。
Q9:为什么点击要在节点区域内随机? A:一是坐标自适应——节点区域由系统按当前屏幕计算,换机型不用改脚本;二是降低操作特征——每次点击位置不完全一致,避免“每次都点同一个像素点”的机械行为。
Q10:稳定性检查和功能测试有什么不同? A:功能测试验证“能不能跑通一条流程”;稳定性检查验证“长时间无人值守跑会不会挂”,重点覆盖等待、超时、服务回收、异常分支、日志留证这些工程化项,是发布前的最后一道关。
关于 EasyClick:手机自动化 AI 智能体平台,覆盖安卓免 root、iOS 免越狱、鸿蒙 Next 三大生态,提供脚本开发、苹果群控、本地中控投屏与云控系统。→ 了解全部产品
想要真实跑起来?
本文介绍的方案均可在 EasyClick 手机自动化平台落地。官网提供完整文档、开发工具与群控云控产品,免费体验。