ADB与UI节点定位实战:打造稳定可控的Android自动化脚本
移动端自动化这几年其实一直不算新话题但真正能把“自动操作手机”这件事做干净的项目并不多。我去年折腾了一个叫 ClawPhone 的个人项目名字有点怪核心思路很简单用一套可控的脚本代替人手让 Android 手机自动执行指定的点击、滑动、输入、读取结果。整个项目没有做成 App也没有依赖特殊内核或者开源框架的完整能力而是靠 ADB 把手机和电脑连起来再通过抓取和分析屏幕上的 UI 节点来完成定位和操作。这篇文章就以 ClawPhone 为线索把移动端自动化里最容易踩坑的两个环节讲透一是 ADB 的深入用法二是 UI 节点定位的实战思路。适合刚接触自动化测试、想自己搭一套手机群控脚本、或者准备做 App 自动化回归测试的人参考。我会把项目中真实踩过的坑、验证过的方式以及为什么这样做而不是那样做尽量讲清楚。1. 项目背景与核心思路1.1 ClawPhone 要解决什么问题先说说为什么我要做 ClawPhone。当时手头有一个非常单调的需求每天需要在某台 Android 手机上重复完成一组固定操作比如打开某个应用、点击某几个入口、记录屏幕上出现的数字、再清理后台。这种操作重复性极高人工执行几天就会开始烦躁而且手抖一下就可能点错。市面上确实有现成的自动化框架但要么太重要么对手机型号和系统版本有各种限定要么只能跑在自己的封闭环境里想从服务器远程触发就非常别扭。ClawPhone 的定位就很明确做一个“能远程控制 Android 设备执行可见操作并获取结果”的轻量级自动化系统。你可以在任何一台电脑上通过命令行或 API 向手机下发指令然后手机自己在屏幕上完成一系列动作最终把需要的信息回传给你。它不做复杂的应用内逻辑也不碰安全和隐私这块敏感区域只处理“模拟用户操作 读取界面状态”这个自动化核心问题。这里的核心技术点其实就是两个第一怎么稳定地控制手机执行操作第二怎么知道屏幕上哪个元素可以点、应该点。前者交给 ADB 解决后者则需要结合 UI 层级信息来处理。ClawPhone 从一开始就没有考虑用坐标硬编码去解决所有问题因为坐标方案在手机屏幕尺寸变化、键盘弹出、页面内容动态加载时都会崩得很惨。1.2 整体架构和关键选型项目架构不复杂按数据流可以分成三层控制层电脑端或服务器端发出指令点击 x y / 滑动 x1 y1 x2 y2 / 读取当前页面结构。通信层通过 ADB 通道向手机发送 shell 指令完成截图、事件注入、UI 信息抓取。执行层手机端原生响应这些指令执行触摸事件或返回配置结果。为什么选 ADB 而不是直接写一个无障碍服务跑在手机上无障碍服务确实可以监听到界面变化并模拟点击但它有两个麻烦一是需要用户手动开启权限很多手机厂商在后台还会反复提示风险二是无障碍服务的开发成本和权限审查都比较重对于非长期驻留的自动化任务来说性能开销也过剩。ADB 则完全绕开了这些限制只要电脑和手机建立了调试连接就可以用标准命令执行几乎一切操作调试连接建立一次就够了中途不需要在手机上做额外授权。可能有人会担心ADB 连接方式是不是只能用在开发调试场景实际情况是对于个人自动化脚本、内部测试工具、甚至小型群控系统ADB 都非常可靠。只要把 USB 调试授权或无线调试连接确定下来它的稳定性要远远高于基于纯截图的图像识别方案。2. ADB 实操细节不只是 adb tap 和 adb shell2.1 常用命令集从基础到冷门很多人对 ADB 的理解仅限于安装应用、查看日志但真正做自动化的时候它远不止这么简单。ClawPhone 中最常用也最值得深入理解的命令大概有下面这几类功能命令示例说明触摸点击adb shell input tap 500 1200向屏幕注入点击事件滑动操作adb shell input swipe 500 1200 500 300模拟从下到上的滑动长按操作adb shell input swipe 500 1200 500 1200 1000通过时长参数模拟长按文本输入adb shell input text hello向当前聚焦输入框发送文本按键操作adb shell input keyevent 4模拟返回键当前页面结构adb shell uiautomator dump输出当前界面的 XML 节点信息截图adb exec-out screencap -p screen.png无损截取当前屏幕画面系统信息adb shell getprop ro.product.model获取设备型号等属性其中最容易被人忽略的是 input keyevent。它看起来只能模拟几个物理按键但实际上 Android 定义了一整套 keycode不只是返回键和 Home 键还能模拟音量的加减、相机快门、甚至编辑框里的删除键 keyevent 67。ClawPhone 中很多看似复杂的手势操作最终都被拆成了“点击 少量按键 滑动”的组合操作越简单越稳定。2.2 事件注入背后发生了什么当你执行 adb shell input tap 500 1200 的时候系统到底做了什么简单来说它在应用层向当前前台窗口注入了一个触摸事件。这意味着它和你用手指操作产生的效果基本一致应用会收到完整的 onTouchEvent、点击事件、焦点变化等回调。既然走的是系统输入管道它就能绕过很多基于 root 方案的技术限制。不需要 root不需要关闭 SELinux也不需要注入特殊驱动。但也因为如此它不能用来模拟“多指触控”这种高级手势。input 命令每次只能注入一个简单事件流。如果你需要更精细的操作还得掌握 input motionevent 这类更底层的命令变体。比如想要模拟一次比较自然的上滑动画可以设置较长的滑动时间并在中间插入多个中间点adb shell input motionevent DOWN 500 1200 adb shell input motionevent MOVE 500 1190 adb shell input motionevent MOVE 500 1150 adb shell input motionevent MOVE 500 1000 adb shell input motionevent UP 500 1000这种写法在需要精确控制滑动轨迹时很有用。单条 swipe 命令虽然也能完成绝大多数滑动场景但遇到那种需要“先慢后快”或者识别手势轨迹的 App多步事件注入就比单条命令靠谱多了。2.3 ADB 连接的稳定性处理自动化脚本跑着跑着最常见的故障不是脚本逻辑崩了而是 ADB 连接忽然断开导致所有后续操作全部失败。ClawPhone 在这块吃过不少亏后来稳定下来的做法是优先使用 TCP/IP 无线连接但必须在脚本里加入自动重连逻辑。重启设备或者电脑休眠过久ADB 连接很可能丢失这时需要重新执行 adb connect。避免过多设备同时连接同一台主机。如果 USB 调试不稳定可以考虑用多台独立设备各自建立 adb server。核心脚本执行前先跑一句 adb get-state 检测设备状态如果返回 device 再继续执行否则直接抛出“设备离线”的提示。有一次在生产环境跑自动巡检手机在凌晨自动重启了结果第二天一早检查任务列表发现一整晚的脚本全部卡在连接超时上。后来我在任务入口加了一个重连包装层每次执行前判断 adb 状态非 device 状态就自动执行 adb connect重试三次再失败才会告警。这个改动直接让整套系统从“偶尔挂掉”变成了“基本不需要人工管”。2.4 shell 权限和输入法坑位通过 ADB 注入文本时有一个很隐蔽的坑input text 默认只支持 ASCII 字符。你输入中文或特殊符号的时候命令往往会失效或者只输入了空格。这是因为 shell 层的输入通道对字符集的支持有限。ClawPhone 目前的方案是优先让应用处理字符串尽量避免直接输入中文如果必须输入中文则先把内容复制到剪贴板再用 keyevent 模拟粘贴操作。这个思路虽然绕了一些但在真实项目里稳定得多。另外某些输入法会拦截 input 注入的键盘事件导致文本输入不完整。遇到这种情况建议在自动化阶段先把系统输入法切换成封闭环境下的一个简洁输入法或者直接使用 ADB 自带的 LatinIME 测试输入法。它没有多余的功能和弹窗事件注入路径最短也最可控。3. UI 定位实战从“看坐标”到“看结构”3.1 坐标定位为什么容易翻车很多自动化项目一开始都会选择“先截图再找到按钮的坐标直接点击”。这种做法在固定设备上、固定分辨率下、固定页面版本里确实可以跑通但只要是稍微复杂一点的场景就很容易翻车。我遇到过的典型问题包括应用在不同手机上分辨率不同按钮位置漂移。键盘弹出后整个页面被顶上去点击坐标偏离目标。页面内容是动态加载的同一个按钮在不同时间可能出现在不同层级位置。厂商系统字体大小不同导致文本占位变化。这些问题的根源在于你拿到的只是一个二维像素坐标但应用实际关心的是布局和视图树。所以 ClawPhone 从一开始就把“UI 层级解析”放在坐标之上只有在没有任何节点信息可用时才回退到截图定位。3.2 uiautomator dump 与 View Hierarchy 解析ADB 自带的 uiautomator dump 是获取 Android 当前界面 View Hierarchy 最直接的入口。执行这条命令后系统会把当前所有可见控件的信息序列化成一份 XML 文件里面包含每个节点的 bounds边界、text、resource-id、class、clickable、content-desc 等属性。这份 XML 就是自动化系统的“眼睛”。它不是截图而是一张结构化的地图。你可以像查 DOM 一样去查找某个节点是否存在、位置在哪里、是否可点击然后根据节点信息决定后续的操作。ClawPhone 的定位模块核心逻辑大概是这样import subprocess import xml.etree.ElementTree as ET def dump_ui_xml(device_idNone, out_file/sdcard/window_dump.xml): cmd [adb, shell, uiautomator, dump, out_file] subprocess.run(cmd, checkTrue) # 从设备拉到本地 pull_cmd [adb, pull, out_file, ./window_dump.xml] subprocess.run(pull_cmd, checkTrue) tree ET.parse(./window_dump.xml) return tree拿到 XML 之后有几个属性需要重点关注resource-id最稳定的节点标识相当于代码里的控件 ID但有些自定义控件没有设置。text节点的可见文本可以直接用来定位按钮比如“立即支付”。content-desc无障碍描述文本常用来表示那些没有文字但有用意的图标。bounds节点在屏幕上的坐标范围最终点击还是要用它换算成中心点。3.3 动态定位策略优先属性回退边界真正落实到 ClawPhone 里的定位策略遵循的是一个优先级原则先找唯一属性再找文本最后才是坐标。比如要点击一个“提交订单”按钮定位逻辑是在 XML 中查找所有 text 等于“提交订单”的节点。如果找到且只有一个节点直接取该节点 bounds 的中心点作为点击坐标。如果找到多个节点再看哪个节点 satisfy 了“可点击”属性且面积在合理范围内。如果找不到文本节点就尝试 resource-id 是否包含 submit 或 confirm。如果以上都失败回退到保存好的固定坐标。这里的核心技巧是“不把所有鸡蛋放在一个篮子里”。属性定位和坐标定位互为备份优先用逻辑强的最后用逻辑弱的。实际运行下来整个自动化流程的稳定性比纯粹用坐标提高了非常多原本需要频繁维护的定位脚本一个月才用调整一两次。3.4 处理动态加载和异步刷新动态内容是最考验 UI 定位能力的场景。页面刚打开时按钮还未加载XML 里根本找不到目标节点滑动列表后新条目才出现或者是弹窗比预期晚出现。如果没有等待策略大概率会在第一次执行时直接失败。ClawPhone 的解决方式是写一个简单的“轮询等待器”在超时时间内每隔固定间隔重新执行 uiautomator dump然后解析目标节点是否存在。如果目标出现就立即继续如果超时未出现则记录页面截图和 XML方便事后排查。import time def wait_for_text(target_text, timeout10, interval1): deadline time.time() timeout while time.time() deadline: tree dump_ui_xml() nodes tree.findall( f.//node[text{target_text}] ) if nodes: return True time.sleep(interval) return False注意这里的 XML 属性查找需要做转义因为 Android 的 XML 文件里文本可能包含单引号、双引号和特殊符号。项目早期在这里踩过坑直接拼接字符串去查文本节点遇到带空格的文本就会查不到。后来统一改成使用 XML 解析器而不是简单的字符串查找问题就消失了。4. 核心流程实现从日志到回执的一次完整实战4.1 一个完整的自动化任务定义ClawPhone 的一个任务通常是一个 JSON 描述的操作序列比如“打开应用 → 等待首页加载 → 点击今日任务 → 截图 → 返回”。这份任务定义的好处是它把“该做什么”和“怎么做”分开了。控制层只需要解析 JSON按照步骤依次调用执行层的能力。典型的任务定义{ name: daily_check, steps: [ {action: launch_app, package: com.example.app}, {action: wait, duration: 3}, {action: click_text, target: 今日任务, timeout: 10}, {action: screenshot, name: daily_task_page}, {action: keyevent, code: 4}, {action: home} ] }这套任务定义虽然简单但扩展性很好。后续新增一个“滑动列表并寻找目标”的步骤不需要改动整体框架只要在 action 分发器里多加一个分支就可以了。4.2 关键实现截图、XML、点击的组合拳整个执行过程的核心是把“看”和“做”拆开来考虑。看用截图和 XML做用 input tap 和 input swipe。下面是一段简化但能直接跑通的 Python 示例展示了 ClawPhone 最核心的执行循环import subprocess import xml.etree.ElementTree as ET ADB_HOST 127.0.0.1:5555 def adb(args): full_cmd [adb, -s, ADB_HOST] args return subprocess.run(full_cmd, capture_outputTrue, textTrue) def get_current_xml(): # 先执行 dump 到设备本地文件 dump_result adb([shell, uiautomator, dump, /sdcard/ui.xml]) if dump_result.returncode ! 0: raise RuntimeError(UI dump failed) # 拉回本地 adb([pull, /sdcard/ui.xml, ./ui.xml]) tree ET.parse(./ui.xml) return tree def get_bounds_center(bounds_text): # bounds 格式类似 [x1,y1][x2,y2] left_top, right_bottom bounds_text.split(][) x1, y1 map(int, left_top.strip([]).split(,)) x2, y2 map(int, right_bottom.strip([]).split(,)) center_x (x1 x2) // 2 center_y (y1 y2) // 2 return center_x, center_y def click_text(target): tree get_current_xml() for node in tree.iter(node): if node.get(text) target: x, y get_bounds_center(node.get(bounds)) adb([shell, input, tap, str(x), str(y)]) return True return False def run_task(): # 第一步打开应用 adb([shell, monkey, -p, com.example.app, -c, android.intent.category.LAUNCHER, 1]) # 等待启动 import time time.sleep(3) # 点击首页上的“今日任务” success click_text(今日任务) if success: # 截图保存现场 adb([exec-out, screencap, -p, , today.png]) else: # 兜底截一份当前页面 XML 供排错 adb([exec-out, screencap, -p, , debug.png])这个代码在实际项目中能直接用但有几个细节必须提示exec-out screencap 从设备读回图片时最好在 Python 里用二进制读取不要走文本模式否则图片数据会被转码破坏。dump 到 /sdcard 目录有时候会因为外部存储权限问题失败更稳妥的做法是 dump 到 /data/local/tmp/ 目录下再拉回本地。monkey 启动应用的命令虽然方便但它本质上是一个随机事件发生器只拿来拉起应用包名还行不要作为正式 long-term 启动方案。更严肃的做法是先用 am force-stop 清状态再通过 am start 指定组件名。4.3 执行过程中的自检机制自动化跑久了环节多了以后任何一个环节失败了都可能导致后续步骤雪崩。ClawPhone 里我加了一个“自检闸门”每一轮关键操作之后都会主动判断结果是否符合预期而不是盲目执行下一步。判断方法很简单操作后重新 dump 一次当前页面确认目标已经消失或新页面出现。对比操作前后的截图差异如果界面完全没变说明这次点击很可能没有生效。记录每一步的操作时间戳如果某一步耗时远超过正常值就触发超时处理。有一次某个页面弹出了系统权限请求弹窗脚本按原路径去找“下一步”按钮结果怎么都找不到。如果没有自检系统会卡死在那一步直到超时有了自检之后它会自动识别当前页面是否有系统弹窗并模拟点击“允许”按钮然后再回到原来的流程。5. 常见问题与排查技巧实录5.1 高频问题速查表移动端自动化过程中我积累了不少问题和对应解法整理成一个速查表不一定覆盖所有设备和 App但绝大多数常见问题都能在里面找到方向现象可能原因排查思路/解决方式UI dump 返回空文件页面处于动画中或仍在加载加等待或重试 dump避免在动画期间抓取XML 中文文本乱码dump 文件的编码格式问题用 Python 以 UTF-8 方式读取并做转义处理点击后无反应节点 bounds 与实际可点击区域不一致检查父容器是否绑定 clickable坐标点偏了屏幕分辨率变化或界面缩放用 wm size 确认当前分辨率优先节点定位文本输入变成空白输入法拦截或 shell 字符集限制切换到系统基础输入法或改用剪贴板方案adb 连接不稳定USB 休眠/线材问题用无线调试并加入自动重连逻辑截图黑屏设备屏幕关闭或保护膜遮挡先执行 adb shell input keyevent 26 唤醒屏幕手机越用越卡反复截图和 dump 积累内存周期执行 am kill-all 清理后台5.2 一个真实的定位失败案例项目中期碰到过一个特别头疼的问题某个银行类 App 的“确认付款”按钮用 uiautomator dump 出来的 text 是空的resource-id 也只有一个很长的随机后缀class 是 android.widget.Button看起来就像是一个完全没有唯一标识的节点。几台设备用固定坐标倒是都能点对地方但换一台分辨率不同的设备就完全偏了。后来通过反复检查 XML 发现那个按钮的 content-desc 属性里放了一段带空格和特殊符号的无障碍描述。因为之前代码里用的是单纯的字符串匹配特殊符号没有正确处理导致一直匹配不上。改成用 XML 标准解析并做属性值精确比较之后问题迎刃而解。这个案例给我的教训是没有真正“不可定位”的节点只有你暂时还没用对属性。多看一眼 XML把 text、content-desc、resource-id、class 都打印出来再奇葩的控件也能找到突破口。5.3 如何提高重试机制的可靠性自动化脚本总会遇到偶发失败这时候重试很有用但盲目重试往往会放大问题。ClawPhone 里设计了一套分阶段重试策略网络错误类连续重试三次每次间隔递增避免无脑请求把手机打崩溃。UI 未找到类先做一次页面等待再尝试一次滚动或刷新最后才判定失败。点击无效果类不直接重试同一个坐标而是重新读取一次最新 XML再次定位。这套策略启动之后任务成功率从最初的 85% 左右提升到 97% 以上。剩下的 3% 大多是需要人工处置的环境性异常比如账号被风控、网络断开、系统更新重启。这些情况再怎么重试也没意义尽早失败并通知人才是正确选择。5.4 关于安全与合规的提醒自动化操作手机的边界需要自己把握好。ClawPhone 的应用场景限定在个人测试、内部回归、设备巡检这一类合法需求上不涉及任何绕过安全策略、篡改系统数据或者干扰其他线上服务的行为。做移动自动化的人更要对合规保持敬畏尤其是涉及登录态、支付流程和账号数据时一定要遵守相应平台的使用协议和法律法规不使用这套技术去做任何违反公序良俗的事情。6. 从项目到进阶稳定性和扩展性的再思考6.1 稳定运行的关键日志与状态机ClawPhone 能做到基本无人值守靠的不是某个“完美脚本”而是整个项目把稳定性当作一等公民来设计。每个任务运行结束后都会生成一份完整的状态报告包括执行步骤、耗时、失败原因、截图、XML 快照。这样一旦出问题排查不需要重新复现现场直接查看报告就能知道卡在哪一步以及当时屏幕长什么样。状态机思维也很重要。每个任务在任意时刻只处于“初始化”“等待中”“正在执行”“已完成”“已失败”几个状态之一。脚本不会在失败后继续执行后续操作而是立即中断并进入错误恢复流程。这个设计看似简单但它避免了最可怕的情况失败之后连着点错好几个界面最后把数据搞乱了才发现。6.2 性能优化减少 dump 和截图的开销uiautomator dump 虽然好用但它不是免费的。实测下来单次 dump 在大多数设备上要花 0.5 到 1.5 秒中途如果页面复杂甚至可能耗时更长。频繁 dump 会导致整个自动化执行得非常慢。优化手段无外乎几条只在关键节点处 dump比如点击前后、等待结果时而不是每步都 dump。可以结合截图变化来判断是否需要解析 XML。截图没有明显变化时页面结构大概率也没有变化。策略性使用缓存如果刚解析完 XML 并且页面没有变化就直接复用上一次解析结果省去再次 dump。这些优化合起来一个原本需要 40 秒跑完的任务可以压缩到 20 秒以内特别是在连续执行多步骤时会明显感觉到进度条不再卡顿。6.3 后续可以扩展的方向ClawPhone 目前还远称不上完善但它已经为后续扩展留好了接口。比如可以考虑加入基于 OCR 的文本识别作为兜底方案解决那些既没有 text 也没有 content-desc 的图形化按钮或者接入版本管理工具让任务定义不只存在于一个 JSON 文件里而是可以通过一个简单管理后台配置下发。多设备群控也是自然延伸ADB 天然支持多设备同时连接只需要在命令中加上 -s 指定序列号即可。再往前走一步还可以把整个执行过程抽象成更通用的“设备动作 API”不绑定特定的 App而是像浏览器自动化那样用一套语法描述设备和页面的交互逻辑。这个方向做好了就不再只是一个个人工具而是可以集成到更大的自动化测试平台里去。把一段踩坑经验送给准备入坑的人做 ClawPhone 大半年我的直观感受是移动端自动化的门槛不在工具而在对设备行为、页面结构、异常场景的理解深度。ADB 命令本身几天就能熟悉uiautomator dump 的 XML 结构也不复杂真正的复杂度全部藏在真实世界的各种意外里——弹窗、网络延迟、设备卡顿、应用崩溃、权限请求、系统版本差异。ClawPhone 能稳定跑下来不是因为代码写得有多高级而是因为每一个坑都踩过、记录过、修复过最后形成了一个带有完整排障能力的闭环。如果你也准备做一个类似的自动化项目我的建议是不要一上来就追求全自动、全智能先把“点击一个按钮”这件事在十条不同的设备上跑稳再逐步扩展滑动、输入、等待和异常处理。自动化的核心从来不是“能自动”而是“自动了之后不容易坏”。从一次可靠的点击开始后面的一切自然会长出来。