安卓自动化原理工程实践

安卓脚本总挂怎么办?稳定性排查与修复指南(等待节点/服务自愈)

安卓脚本跑着跑着就挂,多半是这四类:空点击、元素定位失败、无障碍服务被系统回收、分辨率机型差异。对应的工程化解法,等待节点、重试机制、服务自愈、节点优先定位,一次讲清。

约 17 分钟

安卓自动化就是用程序替掉重复的手工操作。主流走免 root 路线,靠无障碍服务和 ADB 两条通道。

四类典型故障

无人值守脚本写出来之后,难的是让它一直跑。这篇不讲具体业务,讲脚本为什么跑着跑着就停,以及怎么处理。例子用 EasyClick,它是安卓免 root 自动化平台,脚本语言是 JavaScript(官方文档)。文里的函数名都在公开文档里,可以对照。

EasyClick 中控设备状态监控面板展示脚本运行日志与异常告警

脚本停掉的样子很多:日志停在某一行、界面停在某一页、悬浮窗消失、设备黑屏。大体是后面四类:

故障类型 现场表现 一句话根因
空点击 日志显示“点了”,界面却没反应,任务卡在下一步 元素还没出现/还没可点,脚本就先动手了
定位失败 选择器反复匹配不到节点,同一个地方反复超时 界面升级、布局调整、文案变化,旧定位条件失效
服务被回收 无障碍服务掉线,节点、点击、输入全部失灵 内存紧张或厂商省电策略,回收了后台服务
环境变化 同一个脚本 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 查一次节点,直到出现,或者达到次数上限:

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 容器,旧的选择器就对不上。只匹配一个属性,一改版就失配。优先用节点,并且把多个属性叠在一起。按稳定性,可以这样排:

优先级 策略 说明
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();

写死坐标和节点定位的差别:

维度 写死坐标 节点定位
分辨率变化 全废 自适应(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:手机自动化脚本开发平台,覆盖安卓免 ROOT、iOS 免越狱、鸿蒙三大手机生态,还提供苹果脚本编写、苹果群控、安卓群控、远程云控、AI 群控手机等。→ 了解全部产品

想要真实跑起来?

本文介绍的方案均可基于 EasyClick 能力在 iEasyClick 落地。官网提供完整文档、开发工具与自动化产品,免费体验。

访问 iEasyClick 官网 →