Android自动化技能库:从uiautomator2封装到实战巡检机器人构建
1. 项目概述:一个面向Android的自动化技能库
最近在搞Android自动化测试和逆向分析的朋友,估计都遇到过类似的痛点:想写个脚本自动点击、滑动、截图,或者想分析某个App的UI结构,结果发现网上代码要么太零散,要么依赖复杂,要么就是兼容性差,换个设备或者Android版本就跑不起来了。我自己在折腾这些事的时候,也踩了不少坑,后来索性把常用的、稳定的、经过实战检验的代码片段都整理到了一起,这就是“Aotocom/android-agent-skills”这个项目的由来。
简单来说,这是一个专注于Android平台自动化操作的代码技能库。它的核心目标不是提供一个完整的、大而全的自动化框架,而是充当一个“工具箱”或“代码片段集”。你可以把它想象成一个乐高积木箱,里面装满了各种形状、功能明确的积木块(技能)。当你需要构建一个自动化任务时,比如自动登录、批量截图、数据采集或者UI遍历,你可以从这个箱子里快速找到合适的“积木”进行组合,而无需从零开始造轮子,或者去网上大海捞针般地搜索那些质量参差不齐的代码。
这个项目特别适合以下几类人:一是Android应用测试工程师,尤其是做UI自动化、兼容性测试和Monkey测试的同行,可以快速搭建测试脚本;二是移动安全研究员或逆向工程师,在进行动态分析、行为触发或数据抓取时,需要可靠的底层操作支持;三是任何对Android自动化有兴趣的开发者,想学习如何通过代码控制设备、与App交互。项目里的代码大多基于uiautomator2、adb等成熟工具封装,强调实用性和可复现性,很多功能都是我在实际项目中反复打磨过的。
2. 核心技能模块深度解析
2.1 设备控制与基础交互层
任何Android自动化的基石,都是对设备的可靠控制。这一层封装了最基础、也是最关键的设备操作技能,目标是提供稳定、兼容性好的底层接口。
设备连接与状态管理:这是所有操作的起点。项目里不会简单地调用adb devices就了事,而是实现了一套健壮的连接管理逻辑。比如,它会自动检测USB连接和网络连接(adb connect)的优先级,处理设备离线重连,甚至能应对部分设备需要多次adb kill-server/start-server才能识别的情况。代码里会包含一个DeviceManager类,其核心方法ensure_connection()会尝试多种连接策略,并返回一个可用的设备句柄。这里有个关键细节:对于无线调试,项目会建议你先通过USB完成一次adb pair和adb connect,然后将IP和端口信息持久化到配置中,后续脚本就可以直接使用网络连接,摆脱USB线的束缚,这在多设备并行测试时非常有用。
屏幕操作模拟:点击、滑动、长按、拖拽,这些是模拟用户操作的核心。项目基于uiautomator2的d()对象进行了二次封装。为什么不用原生的adb shell input命令?因为uiautomator2提供了更精确的坐标处理(基于百分比或具体控件)和更好的性能。封装后的click_element()或swipe()方法,内部会加入重试机制和异常处理。例如,点击操作如果因为动画未完成或页面未稳定而失败,会自动等待一小段时间后重试最多3次。滑动操作则会计算更平滑的轨迹,模拟真人操作,避免被一些应用的反爬机制识别为机器行为。
注意:不同Android版本和厂商ROM对触摸事件的响应可能有细微差别。在封装滑动函数时,我通常会引入一个小的随机偏移量和速度变化,让每次滑动的轨迹和时长不完全一致,这能有效提高在复杂场景下的成功率。
按键模拟与系统导航:除了触摸,物理按键和系统导航键(Home, Back, Recent)的模拟也必不可少。这部分通过adb shell input keyevent实现。项目里会定义一个KeyCode枚举类,将常用的键值(如KEYCODE_HOME、KEYCODE_BACK、KEYCODE_ENTER)映射为易懂的常量。更重要的是,会封装一些组合操作,比如force_stop_app(package_name),其内部实现是先用am force-stop尝试强制停止,如果不行,再模拟按下Back键直到退出应用,最后再按Home键返回桌面,确保应用环境干净。
2.2 UI元素定位与智能等待策略
自动化脚本的稳定性,大半取决于元素定位是否准确、可靠。这一模块是项目的精华所在,解决“如何找到并操作屏幕上那个正确的按钮或文本框”的问题。
多策略元素定位器:直接使用d(text=“登录”).click()很简洁,但很脆弱。一旦文本改变、元素不唯一或者元素加载慢,脚本就挂了。因此,项目实现了ElementFinder工具类,它支持多种定位策略的优先级组合和回退。基本策略包括:
- 资源ID定位:最精确,首选。
d(resourceId=“com.example:id/btn_login”)。 - 文本定位:最常用,但需注意全匹配和部分匹配。
d(text=“登录”)或d(textContains=“登”)。 - 描述定位:对于无障碍功能友好的App,
d(description=“提交按钮”)有时很稳定。 - XPath定位:功能强大但速度稍慢,用于处理复杂层级结构。
d.xpath(“//android.widget.Button[@text=‘登录’]”)。
ElementFinder的find()方法会按上述优先级尝试定位,并可以设置超时时间。更高级的功能是支持“相对定位”,比如“找到文本为‘用户名’的TextView,然后找到它右边的EditText”。这在实际表单填写中非常实用。
动态等待与条件轮询:这是避免使用sleep()这种“魔法数字”的关键。项目提供了wait_for()系列函数,例如:
wait_for_element(selector, timeout=10): 等待某个元素出现。wait_for_element_gone(selector, timeout=5): 等待某个元素消失(如下载完成提示)。wait_for_activity(activity_name, timeout=10): 等待特定Activity出现。
这些函数的内部实现是基于条件轮询,每隔一小段时间(如0.5秒)检查一次条件是否满足,而不是盲目等待固定时间。这能显著缩短脚本执行时间,并提高对网络波动、设备卡顿的容错性。
处理弹窗与权限请求:这是UI自动化中最令人头疼的干扰项。项目里会有一个PopupHandler模块,它维护了一个常见的弹窗列表(如权限申请、系统更新提示、第三方广告弹窗),并在每次关键操作前或定期执行检查。一旦检测到已知弹窗,就自动点击“允许”或“取消”。对于未知弹窗,可以配置一个安全策略,比如截图保存后,尝试点击弹窗上的“关闭”按钮或按Back键。这个模块需要根据你测试的具体应用进行定制和扩充。
2.3 数据捕获与状态监控技能
自动化不仅仅是操作,还需要“观察”和“记录”。这个模块负责从设备和应用中提取信息,用于断言、记录或后续分析。
屏幕截图与图像识别:d.screenshot()很简单,但项目会封装更实用的功能。比如take_screenshot_and_save(prefix=“step1”),会自动以时间戳和前缀命名文件,并保存到指定的日志目录。更进阶的是轻量级的图像识别,例如,使用opencv模板匹配,来判断某个图标是否出现在屏幕上(即使它的UI控件属性无法定位),或者验证截图是否与预期基准图一致。这对于测试UI渲染结果或者处理游戏界面等非标准控件很有帮助。
布局层次结构分析与Dump:获取当前屏幕的完整UI布局(XML Dump)是进行复杂UI分析和控件属性探查的基础。项目会封装d.dump_hierarchy()函数,并将其返回的XML字符串解析成更易操作的对象树。你可以写一个函数来遍历这棵树,找到所有clickable=true的元素并打印它们的属性,这对于探索一个陌生App的UI结构非常有用。此外,还可以监控布局的变化,例如,监听某个特定布局的出现,作为流程进入下一阶段的信号。
日志与性能数据抓取:通过adb logcat抓取应用日志是排查问题的必备技能。项目会提供start_logcat_capture(package_name, log_level=“I”)和stop_logcat_capture()函数,将日志实时写入文件,并可以过滤特定标签(TAG)或级别的日志。对于性能测试,可以集成简单的adb shell dumpsys gfxinfo和adb shell dumpsys meminfo命令来周期性采集应用的帧率和内存使用情况,虽然不如专业工具全面,但用于快速检查性能回归已经足够。
3. 实战:构建一个App自动化巡检机器人
理论说得再多,不如看一个实际例子。假设我们需要为一个新闻类App构建一个每日自动化巡检脚本,核心任务是:启动App,跳过开屏广告,遍历底部几个主要Tab(首页、视频、我的),在首页随机点击一篇文章进入详情页并滑动阅读,最后退出。这个场景涵盖了设备控制、UI定位、弹窗处理、数据捕获等多个技能。
3.1 环境准备与脚本骨架
首先,你需要准备好Python环境,安装uiautomator2库:pip install uiautomator2。然后通过USB连接一台Android设备,并运行python -m uiautomator2 init来初始化设备端服务。
接下来,我们创建脚本骨架,并导入项目中的技能模块(假设技能库已打包为android_skills包):
import time from android_skills.device_manager import DeviceManager from android_skills.element_finder import ElementFinder from android_skills.popup_handler import PopupHandler from android_skills.screen_capturer import ScreenCapturer # 1. 初始化设备连接 device_manager = DeviceManager() d = device_manager.get_device() # 获取uiautomator2设备对象 # 2. 初始化工具类 finder = ElementFinder(d) popup_handler = PopupHandler(d) capturer = ScreenCapturer(d, save_dir="./巡检截图") # 3. 定义目标App app_package = "com.example.newsapp" app_main_activity = ".MainActivity" def daily_inspection(): """每日自动化巡检主函数""" try: # 后续步骤将填充在这里 pass except Exception as e: capturer.take_screenshot(prefix="error") print(f"巡检过程发生异常: {e}") raise if __name__ == "__main__": daily_inspection()3.2 核心流程步骤分解与实现
现在,我们来一步步实现daily_inspection函数。
步骤1:启动应用并处理开屏广告
# 停止应用,确保从干净状态开始 d.app_stop(app_package) time.sleep(1) # 启动应用 d.app_start(app_package, app_main_activity) print("应用启动成功。") # 等待应用主界面加载,这里我们等待一个只有主界面才有的元素出现,比如底部导航栏的“首页”Tab home_tab_selector = {"text": "首页", "className": "android.widget.TextView"} if not finder.wait_for_element(home_tab_selector, timeout=15): # 如果没等到,可能是开屏广告或权限弹窗 print("未直接进入首页,尝试处理弹窗...") # 调用弹窗处理器,尝试清除当前可能出现的弹窗 popup_handler.handle_common_popups(timeout=5) # 再次等待首页Tab finder.wait_for_element(home_tab_selector, timeout=10) # 此时应该已进入主界面,截图记录 capturer.take_screenshot(prefix="step1_main_launched")这里的关键是wait_for_element和弹窗处理的组合。开屏广告通常是一个全屏的ImageView或FrameLayout,PopupHandler里可以预置一条规则:如果检测到包含“跳过”或“跳过广告”文本的按钮,就点击它;如果没有,则在广告显示3-5秒后,尝试点击屏幕右侧或底部的一个特定坐标区域(许多广告的跳过按钮位置相对固定)。
步骤2:遍历底部Tab
# 定义需要遍历的Tab文本 tabs_to_visit = ["首页", "视频", "我的"] current_screen = "首页" for tab_text in tabs_to_visi: if tab_text == current_screen: continue # 当前已在首页,跳过 tab_selector = {"text": tab_text, "className": "android.widget.TextView"} tab_element = finder.find(tab_selector) if tab_element: tab_element.click() print(f"切换到Tab: {tab_text}") # 等待新Tab内容加载,这里可以等待一个该Tab下的特征元素出现 finder.wait_for_element({"resourceId": f"com.example.newsapp:id/{tab_text}_content"}, timeout=5) time.sleep(2) # 给予内容渲染一点时间 capturer.take_screenshot(prefix=f"step2_tab_{tab_text}") current_screen = tab_text else: print(f"警告:未找到Tab: {tab_text}")遍历操作本身简单,但重点是等待内容加载完成。仅仅点击后立即截图可能截到的是空白或加载中转圈。因此,我们通过等待一个特定资源ID的元素出现(假设每个Tab下的主要内容区域都有唯一的ID)来作为加载完成的标志。如果没有这样的ID,也可以等待某个列表的第一个元素出现。
步骤3:在首页随机点击一篇文章并阅读
# 确保回到首页 if current_screen != "首页": finder.find({"text": "首页"}).click() finder.wait_for_element(home_tab_selector, timeout=5) time.sleep(1) # 定位文章列表。假设文章项有一个共同的类名,如`android.widget.LinearLayout`,并且是可点击的。 # 更稳健的方式是通过resourceId定位列表容器,再获取其子元素。 article_list_selector = {"resourceId": "com.example.newsapp:id/news_list"} if finder.wait_for_element(article_list_selector, timeout=5): # 获取列表内所有可点击的子项(这里简化处理,实际可能需要更精确的定位) # 注意:直接使用`child`方法可能不稳定,更好的做法是dump布局后分析结构。 # 这里我们采用一种更通用的方法:通过XPath查找列表下的特定类型项目。 article_items = d.xpath('//*[@resource-id="com.example.newsapp:id/news_list"]/android.widget.LinearLayout').all() if article_items: import random random_article = random.choice(article_items) random_article.click() print("随机点击一篇文章。") # 等待文章详情页加载,通常会有标题、作者等元素 finder.wait_for_element({"resourceId": "com.example.newsapp:id/article_title"}, timeout=8) capturer.take_screenshot(prefix="step3_article_detail") # 模拟阅读:缓慢滑动几次 for i in range(3): # 从屏幕中部向上滑动,模拟阅读翻页 d.swipe(0.5, 0.7, 0.5, 0.3, duration=0.8) # 参数为屏幕比例 (start_x, start_y, end_x, end_y) time.sleep(1.5) # 滑动后停顿,模拟阅读时间 capturer.take_screenshot(prefix="step3_after_scroll") else: print("警告:首页未找到文章列表项。") else: print("错误:首页文章列表未加载。")这个步骤的难点在于动态列表项的定位与随机选择。直接通过child索引或instance索引在列表内容变化时非常脆弱。这里展示的XPath方法相对稳定,但前提是列表容器的resource-id固定。在实际项目中,你可能需要根据目标App的具体布局结构来调整XPath。随机选择增加了测试的覆盖度。滑动阅读时,duration参数控制滑动速度,较慢的速度更接近真人操作。
步骤4:退出应用与清理
# 返回首页 d.press("back") # 从文章详情页返回 time.sleep(1) # 如果有多层返回,可以循环直到检测到首页特征元素 while not finder.exists(home_tab_selector): d.press("back") time.sleep(0.5) # 回到桌面 d.press("home") print("巡检完成,已返回桌面。") time.sleep(1) # 可选:停止应用,释放资源 d.app_stop(app_package)3.3 增强健壮性与日志记录
一个可用的脚本和一个健壮的脚本之间,差的就是错误处理和日志。我们在骨架中已经有了try-except,但还可以做得更多。
添加详细日志:使用Python的logging模块,替代print,可以输出不同级别(INFO, WARNING, ERROR)的日志到文件和控制台,方便事后排查。
import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[logging.FileHandler('./inspection.log'), logging.StreamHandler()])然后在每个关键步骤使用logging.info(f“切换到Tab: {tab_text}”)。
关键节点截图:我们已经在每个步骤后截图。但在异常捕获时,除了截图,还可以保存当前的UI布局(XML Dump),这对于分析元素定位失败的原因至关重要。
except ElementNotFoundError as e: capturer.take_screenshot(prefix="error_element_not_found") current_hierarchy = d.dump_hierarchy() with open("./error_hierarchy.xml", "w", encoding="utf-8") as f: f.write(current_hierarchy) logging.error(f"元素未找到: {e.selector}。当前布局已保存。") raise配置化:将App包名、关键元素的定位器(selector)、等待超时时间等提取到配置文件(如config.yaml)中,这样无需修改代码就能适配不同的App或环境。
app: package: "com.example.newsapp" main_activity: ".MainActivity" elements: home_tab: {"text": "首页", "className": "android.widget.TextView"} news_list: {"resourceId": "com.example.newsapp:id/news_list"} timeouts: element_wait: 10 activity_wait: 84. 进阶技能与性能优化
当基础自动化跑通后,你会面临更复杂的场景和更高的效率要求。这时就需要用到项目里的一些进阶技能。
4.1 跨应用协作与数据传递
有些流程需要多个应用配合。例如,从新闻App分享一篇文章到微信。这涉及到:
- 在新闻App内触发分享,选择“微信”。
- 系统会启动或切换到微信,并进入分享界面。
- 在微信界面选择分享对象并发送。
项目里可以封装一个CrossAppOperator类来处理这种场景。核心是利用adb shell am start命令的-a(Action)和-d(Data)参数来传递数据,并监控Activity的切换。
def share_to_weixin(article_url): # 在新闻App内,分享操作通常会调用一个Intent # 我们可以模拟这个Intent,直接启动微信的分享界面 share_intent_cmd = f'am start -a android.intent.action.SEND -t text/plain -d "{article_url}" --es android.intent.extra.TEXT "{article_url}"' d.shell(share_intent_cmd) # 等待微信的分享Activity出现 wait_for_activity("com.tencent.mm.ui.tools.ShareUI", timeout=5) # 接下来定位微信分享界面的“发送”按钮并点击... # 注意:微信的UI结构复杂且频繁更新,此处的定位器需要经常维护。这种方式比纯UI操作更底层、更快速,但需要对Android的Intent机制和目标应用的行为有较深了解。
4.2 并行测试与设备池管理
如果你有多个设备需要同时执行相同的测试套件,串行执行效率太低。项目可以引入基于threading或multiprocessing的简单并行机制,以及一个DevicePool来管理设备连接。
DevicePool会维护一个可用设备列表(通过adb devices获取),每个测试任务从池中申请一个设备,执行完毕后释放。这需要小心处理设备资源的竞争和状态隔离(例如,每个任务使用独立的临时目录存放截图和日志)。
4.3 脚本稳定性与反检测对抗
随着自动化脚本的频繁运行,你可能会遇到应用本身的“反自动化”机制,比如:
- 验证码:这是自动化难以逾越的障碍。对于测试环境,可以联系开发提供万能验证码或关闭验证码功能。对于线上巡检,可能需要集成第三方打码平台(成本高,不稳定)。
- 行为指纹检测:应用可能检测触摸事件的规律性(如过于精准的坐标、恒定的间隔)。应对方法是在操作中引入随机性:点击坐标加入微小随机偏移,操作间隔时间在一定范围内随机。
- 根检测或模拟器检测:部分应用在Root设备或模拟器上会限制功能。对于测试,可以使用已Root的真机或能绕过检测的定制模拟器(如Android Studio官方模拟器通常没问题)。
在技能库中,可以提供一个HumanImitator类,为点击、滑动等操作封装“人性化”的随机参数。
5. 常见问题排查与调试技巧
即使使用了完善的技能库,在实际运行中还是会遇到各种问题。这里记录一些典型的“坑”和排查思路。
5.1 元素定位失败
这是最常见的问题。排查步骤如下表:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
脚本报错UiObjectNotFoundError | 1. 元素尚未加载出来。 2. 定位器(selector)写错了。 3. 页面结构已更新。 4. 元素在屏幕外(如需要滑动)。 | 1. 增加wait_for超时时间,或检查前置操作是否完成。2. 使用 d.debug = True输出更多信息,或手动运行d.dump_hierarchy()将当前UI布局保存为XML文件,用工具(如UI Automator Viewer)查看正确属性。3. 确认App版本,更新定位器。 4. 在操作前先滑动到该元素可能出现的区域。 |
| 定位到多个元素,操作了错误的那个 | 定位条件不够精确,匹配到多个UI元素。 | 使用更独特的属性组合定位,如resourceId+text,或使用instance索引(谨慎,易变)。最好的方法是使用XPath描述精确的层级路径。 |
| 间歇性定位失败 | 设备性能波动、网络加载慢、动画干扰。 | 在定位操作前加入稳定的等待条件(如等待某个特征元素出现),而非固定sleep。启用重试机制。 |
调试技巧:在脚本中临时插入以下代码,可以实时查看UI结构,对于编写和调试定位器非常有用:
# 打印当前页面所有可点击元素的文本和资源ID for elem in d(clickable=True): print(f"Text: {elem.info['text']}, ResId: {elem.info['resourceName']}")5.2 脚本执行速度慢
自动化脚本追求稳定,但也不能太慢。优化点包括:
- 减少不必要的等待:用
wait_for_element替代固定的sleep。 - 优化操作路径:合并可以连续执行的操作,减少中间不必要的页面跳转。
- 关闭动画:在开发者选项中关闭“窗口动画缩放”、“过渡动画缩放”、“动画程序时长缩放”,可以显著提升UI响应速度。可以通过
adb shell settings命令在脚本中设置。 - 使用更快的定位策略:
resourceId定位最快,XPath和textContains相对较慢。在循环中定位元素时尤其要注意。
5.3 设备断开或无响应
长时间运行的脚本可能会遇到设备断开连接、adb无响应或应用卡死。
- 心跳检测:在脚本主循环中,定期执行一个简单的
adb shell echo test命令来检查设备连接。 - 超时与重启:对任何
uiautomator2的调用设置合理的超时时间。如果检测到设备无响应,可以尝试d.app_stop(app_package)再d.app_start(...)来重启应用,或者更彻底地重启adb服务。 - 异常恢复:在
try-except块中捕获广泛的异常,并设计恢复逻辑。例如,捕获AdbError后,尝试重新初始化设备连接。
5.4 兼容性问题
不同Android版本、厂商ROM、屏幕分辨率都可能带来差异。
- 分辨率适配:所有基于坐标的操作(如
swipe)尽量使用屏幕比例(0.5, 0.5)而非绝对像素。uiautomator2的API默认支持比例坐标。 - 系统对话框:不同厂商的权限申请对话框、安装确认对话框样式各异。
PopupHandler需要根据测试覆盖的设备范围,不断扩充其弹窗规则库。 - API差异:极少数情况下,不同Android版本对
uiautomator的API支持有细微差别。如果遇到,可能需要根据d.info['sdkInt'](SDK版本号)来写条件分支代码。
最后,维护这样一个技能库和基于它构建的自动化脚本,本身就是一个持续迭代的过程。每次遇到新的问题、新的App、新的交互模式,都是丰富这个“工具箱”的机会。我的经验是,建立一个共享的“问题-解决方案”知识库,把每次排查和解决新问题的过程记录下来,沉淀成新的“技能”或“处理规则”,这样整个团队的自动化能力就会像滚雪球一样越来越强。自动化不是一劳永逸的,它需要像对待产品一样,持续地投入和维护。
