news 2026/9/25 2:58:27

uiautomator2 XPath 扩展深入实战:简写定位语法、选择器 API 与源码级实现细节

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
uiautomator2 XPath 扩展深入实战:简写定位语法、选择器 API 与源码级实现细节
  • 测试
  • 移动开发
  • GUI 自动化

【免费下载链接】uiautomator2

Android Uiautomator2 Python Wrapper

项目地址:https://gitcode.com/gh_mirrors/ui/uiautomator2
点击查看免费下载

本文基于 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 扩展的工作链路只有两步:

  1. 通过 uiautomator2 库的dump_hierarchy接口获取当前界面 XML(包含界面上所有元素的 resource-id、text、content-desc、bounds 等属性的<hierarchy>文档);
  2. 使用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 中包含了界面上所有元素信息(包括弹窗按钮)。假设界面上存在跳过、知道了两个弹窗按钮,而真正要点的按钮是播放,监控流程为:

  1. 获取当前界面 XML(通过dump_hierarchy);
  2. 检查跳过、知道了是否存在,存在就点击,然后回到第 1 步重新抓取界面;
  3. 检查播放是否存在,存在就点击并结束;未找到则回到第 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 uiautomator2

XPath 扩展是 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) == expect

XPath 类型与路径拼接

简写字符串首先会被包装成 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

项目地址:https://gitcode.com/gh_mirrors/ui/uiautomator2
点击查看免费下载
上一篇:为什么越来越多的企业选择ERPNext?开源ERP系统从零到一完整入门指南
下一篇:Video2X:用AI魔法让老旧视频重获新生

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/25 2:57:45

Spring Boot构建智能物业管理系统开发实践

1. 项目背景与核心价值小区物业管理系统是当前社区数字化建设的重要组成部分。随着城市化进程加快&#xff0c;传统纸质化、人工化的物业管理模式已经难以满足现代社区高效运营的需求。我们团队基于Spring Boot框架开发的这套系统&#xff0c;正是为了解决以下几个核心痛点&…

作者头像 李华
网站建设 2026/9/25 2:55:13

CTF逆向Hidden Key实战:AI辅助破解控制流平坦化与信号处理

上周末打了一场线上CTF&#xff0c;有一道100分的逆向题叫Hidden Key&#xff0c;我一开始真没当回事。名字看着像是让你去字符串里翻一个藏起来的key&#xff0c;结果附件是个strip过的64位ELF&#xff0c;运行之后只有一句Usage: ./hidden_key <key>&#xff0c;输错就…

作者头像 李华