- 测试
- 移动开发
- GUI 自动化
【免费下载链接】uiautomator2
Android Uiautomator2 Python Wrapper
本文基于 uiautomator2 仓库根目录的中文文档 XPATH_CN.md 展开,系统讲解其 XPath 扩展的工作原理、自定义简写定位语法、XPathSelector/XMLElement/PageSource三层 API 的完整用法与scroll_to滑动查找机制,并结合 uiautomator2/xpath.py 源码与 tests/test_xpath.py、mobile_tests/test_xpath.py 测试用例,帮助你在 Android UI 自动化中写出更简洁、更可靠的元素定位脚本,并理解每个 API 背后的真实实现。
工作原理:dump_hierarchy + lxml
XPath 扩展的工作链路只有两步:
- 通过 uiautomator2 库的
dump_hierarchy接口获取当前界面 XML(包含界面上所有元素的 resource-id、text、content-desc、bounds 等属性的<hierarchy>文档); - 使用
lxml库解析该 XML,执行 XPath 查询找到匹配元素,再根据元素bounds计算出坐标,最终通过click指令完成操作。
从源码看,这条链路落在 uiautomator2/xpath.py 的PageSource类上:get_page_source()对d.dump_hierarchy()的返回值做PageSource.parse包装;首次访问root属性时才真正用etree.fromstring解析,并把每个<node>标签改写为class属性的值(即控件类名,如android.widget.TextView),这样后续才能写出//android.widget.TextView这样的查询:
# uiautomator2/xpath.py (PageSource.root) @functools.cached_property def root(self): _root = etree.fromstring(str2bytes(self._xml_content)) for node in _root.xpath("//node"): node.tag = safe_xmlstr(node.attrib.pop("class", "")) or "node" return _root需要注意的限制:lxml只支持 XPath 1.0 语法,文档原作者也明确说明尚未研究 XPath 2.0 的接入方式。另外,查询时通过namespaces={"re": "http://exslt.org/regular-expressions"}注册了 EXSLT 正则命名空间(见 xpath.py),这正是下文^正则简写语法能生效的原因。
弹窗监控原理
hierarchy 中包含了界面上所有元素信息(包括弹窗按钮)。假设界面上存在跳过、知道了两个弹窗按钮,而真正要点的按钮是播放,监控流程为:
- 获取当前界面 XML(通过
dump_hierarchy); - 检查
跳过、知道了是否存在,存在就点击,然后回到第 1 步重新抓取界面; - 检查
播放是否存在,存在就点击并结束;未找到则回到第 1 步,循环执行直到查找次数超标。
需要注意:当前源码中d.xpath.when(...)、d.xpath.run_watchers()、d.xpath.watch_background()、d.xpath.watch_stop()、d.xpath.sleep_watch(seconds)等方法均已标记为@deprecated,官方建议改用独立的d.watcherAPI(见 xpath.py 中各方法上的deprecated装饰器,原因均为 "use d.watcher.xxx instead")。真机测试 mobile_tests/test_xpath.py 演示了新版写法:
dev.watcher.when("App").click() dev.watcher.start(interval=1.0) # 触发回调后 watcher 每 interval 秒检查一次安装
pip3 install -U uiautomator2XPath 扩展是 uiautomator2 的核心内置模块,随主包安装即可使用,无需额外插件。
简单用法:最小示例
下面这个完整可运行的例子演示了启动应用、按 text 点击、resource-id 定位、多条件组合、父元素与子元素定位:
import uiautomator2 as u2 def main(): d = u2.connect() d.app_start("com.netease.cloudmusic", stop=True) d.xpath('//*[@text="私人FM"]').click() # # 高级用法(元素定位) # # @ 开头:等价于 d.xpath('//*[@resource-id="personal-fm"]') d.xpath('@personal-fm') # 多个条件定位,类似于 AND(当前推荐用 & 运算符,见下文) d.xpath('//android.widget.Button').xpath('//*[@text="私人FM"]') d.xpath('//*[@text="私人FM"]').parent() # 定位到父元素 d.xpath('//*[@text="私人FM"]').parent("@android:list") # 定位到符合条件的父元素 # 包含 child 的时候,不建议再使用多条件 xpath,容易搞混 d.xpath('@android:id/list').child('/android.widget.TextView').click() # 等价于 # d.xpath('//*[@resource-id="android:id/list"]/android.widget.TextView').click()后续示例为方便均省略import与main,默认存在设备对象d。
简写 XPath 语法规则
为了脚本写得更快,uiautomator2 自定义了一套简写规则。这些规则并非"魔术",而是d.xpath(...)内部统一调用 strict_xpath() 把简写转换回标准 XPath 字符串(转换结果还会经etree.XPath语法校验,非法表达式抛出XPathError)。
| 简写 | 含义 | 等价标准 XPath |
|---|---|---|
//...开头 | 原生 XPath,原样透传 | 不转换 |
@smartisanos:id/right_container | 按 resource-id 定位 | //*[@resource-id="smartisanos:id/right_container"] |
^.*道了 | 正则匹配(灵感:Python re) | //*[re:match(text(), '^.*道了')] |
知道% | 前缀匹配(灵感:SQL LIKE) | //*[starts-with(text(), '知道')] |
%知道 | 后缀匹配 | //*[ends-with(text(), '知道')] |
%知道% | 包含匹配 | //*[contains(text(), '知道')] |
搜索(无特殊前缀) | 同时匹配 text 与 description 字段 | //*[@text="搜索" or @content-desc="搜索" or @resource-id="搜索"] |
结合 strict_xpath() 源码 有两个值得注意的实现细节:
^正则实际同时作用于三个字段。源码生成的是re:match(@text, ...) or re:match(@content-desc, ...) or re:match(@resource-id, ...),比文档中给出的"只匹配 text"范围更广;同理%x%、x%、%x也同时匹配@text与@content-desc两个字段。- 后缀匹配(
%x)不是用ends-with()。因为 XPath 1.0 没有ends-with函数,源码用substring(@text, string-length(@text) - n + 1)截取末尾 n 个字符再做等值比较实现。
单元测试 tests/test_xpath.py 验证了转换结果:
def test_strict_xpath(): for (input, expect) in [ ("@n1", "//*[@resource-id='n1']"), ("//TextView", "//TextView"), ("//TextView[@text='n1']", "//TextView[@text='n1']"), ("(//TextView)[2]", "(//TextView)[2]"), ("//TextView/", "//TextView"), # 末尾 / 会被 rstrip 掉 ]: assert strict_xpath(input) == expectXPath 类型与路径拼接
简写字符串首先会被包装成 XPath 类(继承自str,构造时即完成strict_xpath转换)。它的joinpath()方法用于把子路径拼接在父路径之后:
xp = XPath("//TextView") xp.joinpath("/n1") # -> "//TextView/n1"XPathSelector.child()正是基于joinpath实现的(见 xpath.py),且对已设置了&/|运算符的选择器会抛出XPathError——这就是文档提醒"使用 child 时不要再套多条件 xpath"的原因。
XPathSelector:等待、点击与遍历
d.xpath("...")返回XPathSelector对象(XPathEntry.call),它是整个扩展的使用入口:
sl = d.xpath("@com.example:id/home_searchedit") # sl 为 XPathSelector 对象 # 点击 sl.click() sl.click(timeout=10) # 指定超时,超时未找到抛出 XPathElementNotFoundError sl.click_exists() # 存在即点击,返回是否点击成功 (bool) sl.click_exists(timeout=10) # 等待最多 10 秒 sl.match() # 不匹配返回 None,否则返回 XMLElement # 等待元素出现,返回 XMLElement(未找到返回 None) el = sl.wait() el = sl.wait(timeout=15) # 等待 15 秒 # 等待元素消失 sl.wait_gone() sl.wait_gone(timeout=15) # 与 wait 类似,区别是未找到直接抛出 XPathElementNotFoundError 异常 el = sl.get() el = sl.get(timeout=15) # 修改全局默认等待时间 d.xpath.global_set("timeout", 15) d.xpath.implicitly_wait(15) # 与上一行等价(源码中已标记 deprecated) print(sl.exists) # 返回是否存在 (bool) sl.get_last_match() # 获取上次匹配的 XMLElement sl.get_text() # 获取组件文本(内部先 get() 再取 .text) sl.set_text("") # 清空输入框 sl.set_text("hello world") # 输入 hello world几个行为细节由源码直接给出:
- 轮询间隔 0.2 秒。wait() 在一个
while循环中每 0.2s 重新dump_hierarchy查询一次,直到命中或超过 deadline;wait_gone()逻辑相反,命中"不存在"即返回True。 - 默认超时不是 10 秒。文档注释写"默认等待时间是 10s",但当前仓库中该默认值来自
settings:wait_timeout: 20.0,且 XPathSelector._global_timeout 的回退值也是 20.0 秒。因此按当前版本实测,不传timeout时默认最多等待 20 秒;global_set("timeout", 15)本质上就是改写d.wait_timeout。 set_text的实现是"先点击聚焦,再 send_keys"(见 xpath.py),所以输入框必须先可点击。get()找不到时抛异常,异常类型是 XPathElementNotFoundError(继承自NormalError→RPCError)。
单测 tests/test_xpath.py 用 mock 的 hierarchy 验证了这些行为,例如click会精确点击元素中心点(bounds [0,0][1080,100]的元素点击坐标为(540, 50)),click_exists在元素不存在时不产生任何点击调用,get超时抛XPathElementNotFoundError:
def test_xpath_click(): x("n1").click() assert mock.click.call_args[0] == (540, 50) assert x("n1").click_exists() == True assert x("n3").click_exists(timeout=.1) == False # 不存在则不点击 def test_xpath_wait_and_wait_gone(): assert x("n1").wait() is True assert x("n3").wait(timeout=.1) is False assert x("n3").wait_gone(timeout=.1) is True遍历所有匹配元素
for el in d.xpath('//android.widget.EditText').all(): print("rect:", el.rect) # tuple: (left_x, top_y, width, height) print("center:", el.center()) el.click() print(el.elem) # lxml 解析出来的 Node print(el.text)all()(xpath.py)每次调用都会重新抓取一次最新界面(除非显式传入PageSource),返回XMLElement列表,并把每个元素的_parent回填为XPathEntry,保证后续el.click()等动作能拿到设备对象。
高级查找语法:&与|
Added in version 3.1
选择器支持用 Python 运算符组合条件,内部实现为对两组匹配结果做集合交并运算(Operator.AND→set &,Operator.OR→set |):
# 查找 text=NFC AND id=android:id/item (d.xpath("NFC") & d.xpath("@android:id/item")).get() # 查找 text=NFC OR id=android:id/item (d.xpath("NFC") | d.xpath("App") | d.xpath("Content")).get() # 复杂组合也支持 ((d.xpath("NFC") | d.xpath("@android:id/item")) & d.xpath("//android.widget.TextView")).get()链式方法sl.xpath("...")同样等价于&(源码标注 "Deprecated, using a & b instead",见 xpath.py),传 list 时会用functools.reduce依次&合并。
XMLElement:几何属性与手势操作
XPathSelector.get()返回的对象称为XMLElement,封装了单个 UI 节点:
el = d.xpath("@com.example:id/home_searchedit").get() lx, ly, width, height = el.rect # 左上角坐标 + 宽高 lx, ly, rx, ry = el.bounds # 左上角与右下角坐标 x, y = el.center() # 元素中心点 x, y = el.offset(0.5, 0.5) # 等价于 center(),可取任意相对位置 el.click() # 发送点击(实际是 d.click(x, y)) print(el.text) # 文本内容 print(el.attrib) # 节点全部属性, dict # 控件截图(原理:整张截图后按 bounds crop) el.screenshot() # 控件内滑动 el.swipe("right") # left / right / up / down el.swipe("right", scale=0.9) # scale 表示滑动距离占控件宽度(水平)/高度(垂直)的比例几何属性的实现细节:
bounds是从节点bounds="[0,0][1080,100]"属性用正则提取四个整数得到(cached_property,同一元素只解析一次);rect由bounds换算为(x, y, w, h)(xpath.py)。offset(px, py)按宽高百分比从左上角偏移取点(xpath.py),center()即offset(0.5, 0.5)。screenshot()调用d.screenshot()整屏截图后im.crop(self.bounds)(xpath.py)。el.info会把 XML 属性整理成更接近 dump 输出的 dict:布尔属性转成bool、属性名转驼峰(convert_to_camel_case)、并补充childCount、className、bounds、packageName、contentDescription、resourceName等字段(info 实现),输出示例:
{'index': 0, 'text': '', 'resourceId': 'com.example:id/home_searchedit', 'checkable': True, 'checked': True, 'clickable': True, 'enabled': True, 'focusable': False, 'focused': False, 'scrollable': False, 'longClickable': False, 'password': False, 'selected': False, 'visibleToUser': True, 'childCount': 0, 'className': 'android.widget.Switch', 'bounds': {'left': 882, 'top': 279, 'right': 1026, 'bottom': 423}, 'packageName': 'com.android.settings', 'contentDescription': '', 'resourceName': 'android:id/switch_widget'}单测 tests/test_xpath.py 验证了坐标换算、截图裁剪尺寸、click/long_click落到中心点、get_xpath(strip_index=True)生成/hierarchy/FrameLayout/TextView等路径。
swipe 的 scale 语义(注意与旧文档注释的差异)
el.swipe(direction, scale)委托给 swipe_in_bounds():以控件中心为轴心,从距离对边width * scale处滑动到另一侧,即滑动距离 = scale × 控件宽度(水平方向)或高度(垂直方向),滑动中心点与控件中心点一致。
这里要特别指出一个源码与旧文档注释的差异:当前源码中swipe的scale参数默认值是 0.6(XMLElement.swipe 与 utils.swipe_in_bounds 一致),且断言0 < scale <= 1.0;文档早期注释里写的"scale 默认 0.9"是过时说明,以源码为准。
parent:向上定位
d.xpath('//*[@text="私人FM"]').parent() # 直接父节点 d.xpath('//*[@text="私人FM"]').parent("@android:list") # 向上找第一个符合条件的祖先实现见 XMLElement.parent:不带参数时直接取 lxml 父节点;带 XPath 条件时,把从当前节点到根的整条祖先链收集起来,再在全树中执行条件查询,取"祖先链 ∩ 匹配集"中离自己最近的那个节点。真机测试 mobile_tests/test_xpath.py 验证了d.xpath("App").parent("@android:id/list").info["resourceId"] == "android:id/list"。
滑动到指定位置:scroll_to
scroll_to属于较新的功能,文档原文提醒"可能不这么完善(比如不能检测是否滑动到底部了)"——源码中同样留有FIXME: 还差一个检测是否到底的功能的注释(xpath.py)。
先看方向枚举 Direction:
class Direction(str, enum.Enum): LEFT, RIGHT, UP, DOWN = "left", "right", "up", "down" FORWARD = "up" # 垂直向前 = 内容向上滚(手指向上滑) BACKWARD = "down" HORIZ_FORWARD = "left" # 水平向前 HORIZ_BACKWARD = "right"设备级与元素级两种用法:
from uiautomator2 import connect_usb, Direction d = connect_usb() d.scroll_to("下单") # 默认向下(FORWARD)查找 d.scroll_to("下单", Direction.FORWARD) d.scroll_to("下单", Direction.HORIZ_FORWARD, max_swipes=5) # 在指定元素内部滑动 d.xpath('@com.taobao.taobao:id/dx_root').scroll(Direction.HORIZ_FORWARD) d.xpath('@com.taobao.taobao:id/dx_root').scroll_to("下单", Direction.HORIZ_FORWARD)两层实现对照:
- 元素级XMLElement.scroll_to:循环最多
max_swipes(默认 10)次,先查目标是否存在,不存在则调用self.scroll(direction)滑动;一旦scroll()返回False(没有新元素出现,可推断已经到底)则提前break;找到目标返回XMLElement,否则返回None。 - 设备级XPathEntry.scroll_to:把
FORWARD/BACKWARD/HORIZ_*映射为UP/DOWN/LEFT/RIGHT手势方向,在max_swipes次循环内每次先target.exists检查,找不到就swipe_ext(direction, 0.5)滑半屏;找到后还会再滑0.1比例的小位移,防止目标元素停留在屏幕边缘,然后返回get_last_match();未找到时源码实际返回False(返回类型注解为XMLElement | None,以运行时行为为准)。
scroll()与swipe()的关键区别在于返回值语义:scroll(direction)返回bool,表示滑动后是否还有新子元素出现(XMLElement.scroll 的做法是滑动前后各查一次//*,用集合差集再配合get_xpath()前缀限定到当前元素子树内)。
真机测试 mobile_tests/test_xpath.py 验证了元素级scroll_to:
def test_xpath_scroll_to(dev: u2.Device): d = dev d.xpath("Graphics").click() d.xpath("@android:id/list").scroll_to("Pictures") assert d.xpath("Pictures").exists综合示例
import uiautomator2 as u2 from uiautomator2 import Direction def main(): d = u2.connect() d.app_start("com.netease.cloudmusic", stop=True) # steps d.xpath("//*[@text='私人FM']/../android.widget.ImageView").click() d.xpath("下一首").click() # 监控弹窗(注意:以下 watch 系列方法在当前源码中已标记 deprecated, # 新代码建议使用 d.watcher 系列 API,参见上文"弹窗监控原理") d.xpath.sleep_watch(2) d.xpath("转到上一层级").click() d.xpath("转到上一层级").click(watch=False) # 点击且不触发 watch d.xpath("转到上一层级").click(timeout=5.0) # 最多等待 5s d.xpath.watch_background() # 开启后台监控,默认每 4s 检查一次 d.xpath.watch_background(interval=2.0) # 每 2s 检查一次 d.xpath.watch_stop() # 停止监控 for el in d.xpath('//android.widget.EditText').all(): print("rect:", el.rect) # tuple: (left_x, top_y, width, height) print("bounds:", el.bounds) # tuple: (left, top, right, bottom) print("center:", el.center()) el.click() print(el.elem) # lxml Node # 控件滑动 el = d.xpath('@com.taobao.taobao:id/fl_banner_container').get() el.swipe(Direction.HORIZ_FORWARD) # 从右滑到左 el.swipe(Direction.LEFT) # 同上 el.swipe(Direction.FORWARD) # 从下滑到上 el.swipe(Direction.UP) el.swipe("right", scale=0.9) # 滑动距离为控件宽度的 90% el.swipe("up", scale=0.5) # 滑动距离为控件高度的 50% # scroll 返回 bool:是否还有新元素出现 el.scroll(Direction.FORWARD) el.scroll(Direction.BACKWARD) el.scroll(Direction.HORIZ_FORWARD) el.scroll(Direction.HORIZ_BACKWARD) if el.scroll("forward"): print("还可以继续滚动")PageSource 对象
Added in version 3.1
这是 XPath 扩展最底层的对象——"高级用法",但"最初级":几乎所有函数最终都依赖它。PageSource由d.dump_hierarchy()的返回字符串初始化,核心方法是find_elements。
source = d.xpath.get_page_source() # find_elements 是核心方法 elements = source.find_elements('//android.widget.TextView') # List[XMLElement] for el in elements: print(el.text) # 获取坐标后点击 x, y = elements[0].center() d.click(x, y) # 多种条件的查询写法 es1 = source.find_elements('//android.widget.TextView') es2 = source.find_elements(XPath('@android:id/content').joinpath("//*")) # 寻找是 TextView 但不属于 id=android:id/content 下的节点 els = set(es1) - set(es2) # 寻找是 TextView 且属于 id=android:id/content 下的节点 els = set(es1) & set(es2)从源码看(PageSource),find_elements直接在 lxml 树上执行带re命名空间的 XPath 查询,并把每个命中节点包成XMLElement;XMLElement实现了__hash__/__eq__(xpath.py),所以set集合运算可以直接比较两个查询结果。d.xpath("...", source)还支持传入固定的 XML 字符串/PageSource做离线查询(XPathEntry.call),适合调试时复用一份 dump 结果。
特殊说明与高级 XPath 用法
- 类名中的非法字符:有时
className中包含$@#&等 XML 非法字符,uiautomator2 会统一替换成.,见 safe_xmlstr()(用re.sub('[$@#&]', '.', s)并把连续点号折叠)。因此查询自定义 View 时若类名含这些字符,要记得写替换后的类名。 - lxml 仅支持 XPath 1.0,写表达式时不要用
ends-with()、subsequence()等 XPath 2.0 函数。
常用高级写法清单:
# 所有元素 //* # resource-id 包含 login 字符 //*[contains(@resource-id, 'login')] # 按钮包含账号或帐号 //android.widget.Button[contains(@text, '账号') or contains(@text, '帐号')] # 所有 ImageView 中的第二个 (//android.widget.ImageView)[2] # 所有 ImageView 中的最后一个 (//android.widget.ImageView)[last()] # className 包含 ImageView //*[contains(name(), "ImageView")]简写语法也支持这些组合,例如 mobile_tests/test_xpath.py 中%前缀/后缀简写的真机验证:
assert dev.xpath("Accessibility").wait() assert dev.xpath("%ccessibility").wait() # 后缀匹配 assert dev.xpath("Accessibilit%").wait() # 前缀匹配此外,社区有大量成熟的 XPath 学习资料与在线测试工具(W3School XPath 教程、XPath Quicksheet、各类 xpath-tester 网站)可供检索使用,用来验证自己写的表达式;仓库中配套的示例脚本可参考 examples/com.netease.cloudmusic/main.py(与上文云音乐示例同一场景)。
小结:文档与当前版本的差异速查
| 事项 | 文档描述 | 当前仓库源码事实 |
|---|---|---|
| 默认等待超时 | "默认等待时间是 10s" | settings默认wait_timeout = 20.0(settings.py),_global_timeout回退值亦为 20.0 |
swipe默认 scale | 注释写 0.9 | 源码默认scale=0.6(xpath.py) |
^正则简写匹配字段 | 仅 text | 实际匹配text、content-desc、resource-id三字段(xpath.py) |
when/watch_background/sleep_watch | 文档推荐用法 | 均标记@deprecated,改用d.watcherAPI(xpath.py) |
sl.xpath(...)链式 AND | 文档示例 | 功能保留但标注 Deprecated,推荐a & b(xpath.py) |
掌握以上内容后,你可以:用一行简写完成绝大多数日常定位(@id、%text%、^regex);用wait/get/click_exists组合出健壮的等待-操作逻辑;用&/|组合精确条件;用scroll_to在长列表中找到目标元素;并在遇到行为疑点时,直接对照 uiautomator2/xpath.py 的源码与 tests/test_xpath.py 的单测确认预期行为。
- 测试
- 移动开发
- GUI 自动化
【免费下载链接】uiautomator2
Android Uiautomator2 Python Wrapper
相关推荐
uiautomator2 XPath 扩展实战:元素定位、等待点击、滚动与弹窗监控
uiautomator2 XPath 扩展实战:元素定位、等待点击、滚动与弹窗监控 uiautomator2 的 XPath 扩展允许你用一行 d.xpath
测试移动开发GUI 自动化深入理解openatx/uiautomator2中的XPath扩展功能
深入理解openatx/uiautomator2中的XPath扩展功能 前言 在移动应用自动化测试领域,元素定位是最基础也是最重要的环节之一。openatx/u
测试移动开发GUI 自动化E5-small-v2-openmind实战案例:如何将文本嵌入集成到你的Python项目中
E5 small v2 openmind实战案例:如何将文本嵌入集成到你的Python项目中 E5 small v2 openmind是一款高效的文本嵌入模型,
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考