Appium universal-xml-plugin:把 iOS 与 Android 页面源码统一为一套通用 XML 语法
【免费下载链接】appiumCross-platform automation framework for all kinds of apps, built on top of the W3C WebDriver protocol项目地址: https://gitcode.com/GitHub_Trending/ap/appium
本文以 Appium 仓库中的@appium/universal-xml-plugin插件文档(README)为主体,结合 lib/plugin.ts、lib/source.ts、lib/xpath.ts 等源码实现,完整讲清这个插件的工作机制:如何安装启用、如何把两个平台的 Page Source 统一为通用节点/属性命名、XPath 查询如何被自动翻译回平台原生 XML,以及映射表、过滤属性的完整清单。读完你将掌握跨平台 UI 测试中“一份源码、一套 XPath”的完整用法与实现原理。
1. 解决什么问题
Appium 中 iOS 与 Android 驱动返回的页面源码(Page Source)命名体系完全不同:iOS 使用XCUIElementTypeButton这类 XCUITest 元素类型和name/label/visible属性,Android 使用android.widget.Button这类控件全名和content-desc/text/displayed属性。这意味着同一个“登录按钮”在两个平台需要写两套 XPath,跨平台测试无法复用。
@appium/universal-xml-plugin的目标(README 中的 Motivation)正是:让 iOS 与 Android 的 XML 源码具备互操作性,从而简化跨平台测试的编写。插件把 Get Page Source 返回的 XML 中的节点名、属性名统一改写为一套对两个平台都适用的通用术语(如Button、text、visible),未收录在映射表中的名称原样保留。
从 package.json 可以看到该插件的元信息:npm 包名@appium/universal-xml-plugin,appium.pluginName为universal-xml,主类为UniversalXMLPlugin,peerDependencies要求appium ^3.0.0-beta.0,engines要求 Node^20.19.0 || ^22.12.0 || >=24.0.0。
2. 安装与启用
README 给出的两步操作即为完整用法:
# 安装插件 appium plugin install universal-xml# 启动 Appium 服务器时显式激活插件 appium --use-plugins=universal-xml与所有 Appium 插件一样,插件不会自动生效,必须在启动服务器时通过--use-plugins显式激活。插件激活后,会拦截两类命令并做转换:
- Get Page Source命令返回的应用源码;
- Find Element / Find Elements系列命令中携带的节点名与属性名(即 XPath 选择器)。
3. 拦截机制:插件到底 hook 了哪些命令
UniversalXMLPlugin 继承自 Appium 的BasePlugin,只实现了三个方法:getPageSource、findElement、findElements。
3.1 拦截 Get Page Source
getPageSource(plugin.ts)的流程是:
- 先调用
next()拿到驱动返回的原始 XML(或兜底直接调driver.getPageSource()); - 从会话能力
caps.platformName中取出平台名(getPlatformName); - 若平台为 Android,额外从驱动选项
driver.opts.appPackage取包名放入转换元数据——这个包名稍后会用于剥离resource-id的前缀; - 调用 transformSourceXml 完成真正的 XML 转换,返回统一命名后的字符串。
此外还有一个值得注意的“质量反馈”机制:转换过程中所有未被映射表收录的节点名与属性名会被收集起来,插件会在日志中打 warn,提示“这些未知名称应该被报告,以改进插件质量”(plugin.ts)。也就是说源码里保留了完整的 unknown 统计通道,只是默认行为是“原样保留 + 告警”。
3.2 拦截 Find Element 系列命令
findElement/findElements共用内部方法_find(plugin.ts),其前置条件非常明确,源码中只有同时满足以下三点才会走翻译逻辑,否则直接放行给驱动(next()):
- 定位策略
strategy必须是xpath(不区分大小写); - 驱动实现了
getCurrentContext; - 当前上下文必须是
NATIVE_APP。
满足条件后的执行链是:
- 先以
addIndexPath: true模式重新生成一份带索引路径的转换后 XML(见第 5 节); - 用 transformQuery 把用户写的“通用 XPath”翻译成针对原始平台 XML的等价路径表达式;
- 若翻译结果为
null(意味着没有匹配节点或翻译失败),插件记录 warn,然后findElement抛NoSuchElementError、findElements返回空数组[]; - 翻译成功则记录
Selector was translated to: ...的 info 日志,并直接调用driver.findElement/findElements(strategy, newSelector)——注意这里是直接调驱动而不是next(),因为翻译后的选择器必须作用于未经插件改写的原生 XML。
4. 源码转换核心:解析、递归改写与命名映射
transformSourceXml 基于fast-xml-parser的单例XMLParser/XMLBuilder实现,属性前缀常量ATTR_PREFIX为@_(source.ts),输出前会补上<?xml version="1.0" encoding="UTF-8"?>声明。解析器配置中isArray恒返回 true(对非属性节点),保证重复节点名合并时能正确形成数组(source.ts)。
4.1 节点名映射(多对一)
映射数据结构定义在 types.ts:键是通用节点名,值按平台给出字符串或字符串数组,即支持多个平台名映射到同一个通用名(many-to-one)。查找函数 getUniversalName 遍历整张表做包含判断,未命中返回null。
改名时 transformChildNodes 先递归处理子树,再替换节点名;当两个不同的原名映射到同一通用名时,它会把原有值包成数组并合并(source.ts),避免覆盖丢失。
README 中给出的三行示例(Button/Alert/SwitchInput)在 node-map.ts 中有完整映射表,这里给出全文(iOS/Android 均为数组时以顿号分隔,—表示该平台无对应来源名):
| 通用节点名 | iOS 来源 | Android 来源 |
|---|---|---|
| Alert | XCUIElementTypeAlert | android.widget.Toast |
| App | XCUIElementTypeApplication | — |
| Button | XCUIElementTypeButton、DecrementArrow、IncrementArrow、DisclosureTriangle、Handle、Key、Link、MenuButton、PageIndicator、PopUpButton、ToolbarButton、RadioButton、Tab | android.widget.Button、ImageButton、RadioButton、QuickContactBadge |
| Cell | XCUIElementTypeCell | — |
| CheckBox | XCUIElementTypeCheckBox | android.widget.CheckBox |
| Column | XCUIElementTypeTableColumn | — |
| DateInput | XCUIElementTypeDatePicker | android.widget.DatePicker |
| Element | XCUIElementTypeOther、Any、Matte、MenuBarItem、MenuItem、Ruler、RulerMarker、Splitter、StatusItem、Timeline | android.widget.Space、TwoLineListItem |
| Grid | XCUIElementTypeGrid | android.widget.GridLayout、GridView |
| Icon | XCUIElementTypeIcon、DockItem | — |
| Image | XCUIElementTypeImage | android.widget.ImageView |
| Indicator | XCUIElementTypeLevelIndicator、ProgressIndicator、RatingIndicator、RelevanceIndicator、ValueIndicator | android.widget.RatingBar、ProgressBar |
| Input | XCUIElementTypeColorWell | — |
| List | XCUIElementTypeCollectionView | android.widget.ListView、ExpandableListView、Gallery |
| Map | XCUIElementTypeMap | — |
| Menu | XCUIElementTypeMenu、MenuBar | android.widget.ActionMenuView、PopupMenu |
| Modal | XCUIElementTypeDrawer、Dialog、Popover | android.widget.ListPopupWindow、PopupWindow、SlidingDrawer、Magnifier |
| Nav | XCUIElementTypeNavigationBar | — |
| PickerInput | XCUIElementTypePickerWheel | android.widget.NumberPicker、TimePicker、CalendarView |
| RadioInput | XCUIElementTypeRadioGroup | android.widget.RadioGroup |
| Row | XCUIElementTypeTableRow、OutlineRow、SegmentedControl、TouchBar | android.widget.TableRow |
| Scrollable | XCUIElementTypeScrollView | android.widget.ScrollView、HorizontalScrollView |
| SearchInput | XCUIElementTypeSearchField | android.widget.SearchView |
| SliderInput | XCUIElementTypeSlider、Stepper、ScrollBar | android.widget.SeekBar |
| Spinner | XCUIElementTypeActivityIndicator | android.widget.Spinner |
| SwitchInput | XCUIElementTypeSwitch | android.widget.Switch |
| Table | XCUIElementTypeTable | android.widget.TableLayout |
| Text | XCUIElementTypeStaticText、TextView、HelpTag | android.widget.TextView、Chronometer、TextClock |
| TextInput | XCUIElementTypeTextField、SecureTextField、ComboBox | android.widget.EditText、AutoCompleteTextView、MultiAutoCompleteTextView |
| ToggleInput | XCUIElementTypeToggle | android.widget.CheckedTextView、ToggleButton |
| Toolbar | XCUIElementTypeToolbar | android.widget.Toolbar |
| UI | AppiumAUT | hierarchy |
| Video | — | android.widget.VideoView |
| View | XCUIElementTypeBrowser、Group、Keyboard、LayoutArea、LayoutItem、Outline、Picker、Sheet、SplitGroup、StatusBar、TabBar、TabGroup | android.widget.FrameLayout、LinearLayout、RelativeLayout、android.view.View、ViewGroup、MediaController、StackView |
| WebView | XCUIElementTypeWebView | — |
| Window | XCUIElementTypeWindow | — |
(上表中 iOS 来源为完整XCUIElementTypeXxx名的缩写展示,以 node-map.ts 中的全量定义为准。)
未收录的名称不做任何改写,仅计入 unknown 统计并触发第 3.1 节的告警日志。
4.2 属性名映射与移除清单
属性映射表 ATTR_MAP 与 README 中三行示例对应的完整内容是:
| 通用属性 | iOS 来源 | Android 来源 |
|---|---|---|
axId | name | content-desc |
text | label | text |
visible | visible | displayed |
x/y/width/height | 同名(由 bounds 换算,见 4.3) | 同名(由 bounds 换算,见 4.3) |
id | — | resource-id |
enabled | enabled | 见下方说明 |
value | value | — |
README 同时强调“插件还会从转换后的 XML 中删除若干属性”,即 REMOVE_ATTRS:
index, type, package, class, checkable, checked, clickable, enabled, focusable, focused, long-clickable, password, scrollable, selected, bounds, rotation从源码看,transformAttrs 中对每个属性先判断是否命中 REMOVE_ATTRS、命中即删除,再做映射查找。因此上表中enabled一行存在一个平台差异:iOS 的enabled会映射为通用enabled保留;Android 的enabled因先命中移除清单而被直接丢弃。同理 Android 的bounds虽在移除清单中,但它的坐标信息会先被平台转换器换算为x/y/width/height(见下节),信息并未丢失。
4.3 平台预处理转换器:Android 的 bounds 与 resource-id
除了改名,transformNode在每个节点上还会调用平台转换器(source.ts):
- iOS 转换器是空操作(transformers.ts),因为 XCUITest 源码本身已带有
x/y/width/height与短name属性; - Android 转换器(transformers.ts)做两件事:
- 把
resource-id形如com.example:id/title的值剥离${appPackage}:id/前缀(包名即第 3.1 节从driver.opts.appPackage取的元数据),最终呈现为短id属性; - 把
bounds="[x,y][x2,y2]"拆分解析,换算出x、y、width、height四个通用属性。
- 把
以测试夹具 test/fixtures/android.xml 中的一行为例,其中android.widget.EditText节点带有content-desc="username"、text="alice"、bounds="[150,504][930,616]"、displayed="true"。按上述规则,它会被改写为TextInput节点,并携带axId="username"、text="alice"、visible="true"、x="150"、y="504"、width="780"、height="112";其余index/class/clickable等命中移除清单的属性则被丢弃。转换前后对照可参考夹具中的 android-transformed.xml 与 ios-transformed.xml。
4.4 indexPath:为 XPath 反查埋下的索引
transformNode 在addIndexPath开启时要求每个节点必须带index属性(缺失则直接抛错),并把父路径拼接成parent/index形式写入@indexPath属性。源码注释里特别说明了一个不对称处理(source.ts):iOS 的<AppiumAUT>根节点被 XCUITest 驱动排除在查询层级之外,因此故意不给它写 indexPath;Android 的<hierarchy>根则参与查询层级,保留其索引。
5. XPath 选择器的翻译原理
transformQuery 是整个插件最巧妙的部分,思路是“在转换后的 XML 上先跑一遍用户选择器,再把命中的节点按 indexPath 反写成原生 XML 中的位置表达式”:
- 用
@xmldom/xmldom+xpath库在转换后、带 indexPath 的 XML上执行用户的通用 XPath(runQuery); - 过滤掉没有
indexPath的节点(即第 4.4 节中的 iOSAppiumAUT根); - 把每个命中节点的
indexPath(形如/0/0/1/1/0/1/0/2)逐段 +1(XPath 索引从 1 开始),映射为*[n]位置轴表达式并重新拼接,例如/0/0/1变为/*[1]/*[1]/*[2]——由于插件改名是保序的,位置轴路径在未改名的原生 XML 上同样指向原节点; - 单个查询取第一条结果;多元素查询(
findElements)用|合并所有命中路径(xpath.ts)。
这一机制的代价与边界也随之明确:选择器必须在原生 XML 上唯一可定位,翻译走的是“位置轴”而非属性匹配,因此页面在获取源码与实际查询之间发生重排时可能定位漂移;另外只有xpath策略 +NATIVE_APP上下文会被翻译,accessibility id、class name等其他策略直接透传给驱动处理。
6. 验证与测试入口
仓库为该插件提供了可直接运行的验证材料:
- 夹具:test/fixtures/ 下成对存放原始与转换结果,如 android.xml ↔ android-transformed.xml、ios.xml ↔ ios-transformed.xml,另有边界场景夹具 ios-edge.xml 与 web-view.xml;
- 单测:test/unit/plugin.spec.ts 验证插件命令拦截,test/unit/source.spec.ts 验证 XML 转换,test/unit/xpath.spec.ts 验证选择器翻译;
- CLI 入口:index.ts 暴露了一个命令行转换工具,用法为
node <构建产物>/index.js <xmlDataPath> <platform> [optsJson],把转换结果打印到 stdout、unknown 统计打印到 stderr,也支持--smoke-test冒烟检查(对应test:smoke脚本)。
7. 小结与使用边界
- 该插件是 Appium 官方 monorepo 内的独立包,插件名
universal-xml,通过appium plugin install universal-xml安装、--use-plugins=universal-xml激活; - 它统一的是节点名 + 属性名 + XPath 查询三层:Page Source 输出通用 XML(node-map.ts 与 attr-map.ts 定义全部映射),XPath 则通过 indexPath 反查被翻译成原生位置表达式(xpath.ts);
- 未收录的名称/属性原样保留并打 warn 日志,映射表以源码文件为准持续扩展;
- 适用边界:仅拦截
xpath策略且上下文为NATIVE_APP的元素查找;findElements在无匹配时返回空数组而非报错;bounds换算、resource-id剥离等预处理目前只针对 Android 源(iOS 转换器为空操作),这些行为均可从 transformers.ts 直接印证。
【免费下载链接】appiumCross-platform automation framework for all kinds of apps, built on top of the W3C WebDriver protocol项目地址: https://gitcode.com/GitHub_Trending/ap/appium
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考