id - Android、iOS

问题起源

问题来自于使用 appium inspector 时,同一元素安卓端与IOS端locator 的id 表现不同,安卓有id, 但是iOS没有id 只有 Accessiblity id , 基于此,撰写本片文章;

为什么 Android 有 ID,而 iOS 没有?

  • Android Inspector 可以看到 id

  • iOS Inspector 却没有 id

  • 同一个 RN 页面,两端显示的属性完全不同

于是很容易产生一个误解:

「是不是 Appium 对 iOS 支持不好?」

答案是否定的。

真正原因不是 Appium,而是 Android 与 iOS 从诞生开始就采用了完全不同的 UI 设计哲学。Appium 只是读取系统提供的信息,并不会凭空创造属性。

Android:为什么每个控件几乎都有 ID?

Android 支持给 View 分配 Resource ID,但并不要求每个 View 都必须有 Resource ID。

Android 于 2008 年发布,由 Google 设计。Google 希望 Android 成为一个开放、可扩展、便于开发的平台,因此提出了一个核心思想:

Everything is a Resource(万物皆资源)。

Android 从一开始就是一个 声明式 UI(XML)+ Resource 系统。在 Android 中,字符串、颜色、图片、布局、动画,甚至按钮的编号,都属于 Resource。

例如:

复制代码
<Button
    android:id="@+id/login_button"/>

Android 并不会把 "login_button" 这个字符串保存到每一个 Button 中。为了提高运行效率,Android 在编译 APK 时,会提前完成资源的管理工作。

整个过程可以分为 4 个阶段

第一步:扫描所有 Resource(Resource Collect)

Android 编译 APK 时,aapt2(Android Asset Packaging Tool 2)首先会扫描整个 res/ 目录同时会收集所有资源(例如:@string/app_name等)

第二步:建立 Resource Table(资源映射表)

收集所有资源以后,Android 会建立一张资源映射表(Resource Table),其作用就是将资源名称(Resource Name)映射(Mapping)到唯一的 Resource ID;

注: Resource Table(二进制的一个资源表),将资源名称映射(Mapping)到一个唯一的 32 位无符号整数 Resource ID (无符号--> 可以表示更多的资源且本身不会进行计算更符合"标识符(Identifier)"的语义,之所以需要映射是因为CPU更擅长处理整数)

第三步:生成 R 类

注:R 类只是 开发者访问 Resource Table 的一层类型安全封装。

建立 Resource Table 后,Android 自动生成:

|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| public final class R { ``public static final class id { ``public static final int login_button = ``0x7f080012``; ``public static final int password_edit = ``0x7f080013``; ``} } |

因此开发者可以写:findViewById(R.id.login_button); 而底层就是使用的映射 0x7f080012 去访问;

第四步:运行时绑定到 View

当 LayoutInflater 加载 XML:

复制代码
<Button
    android:id="@+id/login_button"/>

内部会执行类似:

复制代码
Button button = new Button();

button.setId(R.id.login_button);

最终:

复制代码
Button

id = 0x7f080012

以后button.getId(); 就可以拿到0x7f080012;

这里的 @+id/login_button 并不是普通字符串,而是告诉 Android:

在 Resource Table 中创建一个新的资源编号。

需要注意的是,android:id 是一个可选属性,只有开发者主动声明后,aapt2 才会在编译阶段将其加入 Resource Table,并生成对应的 Resource ID。运行时,拥有 Resource ID 的 View 才会暴露 resource-id 属性

并不是每个 View 都需要被程序访问(Reference)。只有需要被程序引用、操作或区分的 View,才需要 android:id

程序运行后,每个 View 都知道自己的 Resource ID,因此 UIAutomator 可以直接读取: resource-id = login_button

Appium Inspector 又把它包装成:Find By → id,所以很容易让人误会Android 有一种叫 Appium ID 的东西。实际上没有,只是:Appium 的 id 定位策略,在 Android 平台上,底层最终匹配的是 Android 的 resource-id

为什么 Google 要这样设计?

Google 希望把"资源"从"代码"中解耦出来,并在编译阶段完成资源管理,从而获得更高的性能、更好的可维护性和更强的扩展能力;

整个设计目的可以总结成四点:统一管理所有资源、代码与资源解耦、编译期完成资源管理、提高运行效率

iOS:为什么没有 Resource ID?

Android 和 iOS 从第一天开始的设计思想完全不同:Android 更强调 开发效率与开放性 ;而 Apple 从诞生开始更强调 用户体验、一致性和系统控制力

  • Android 设计目标:资源(Resource)驱动 UI。
  • iOS 设计目标:对象(Object)驱动 UI。

Android 最初(2008 年)大量采用 XML 描述 UI,但是XML 本身只是文本,系统解析 XML 时,需要知道xml 当中描述的UI究竟是哪个 Button,于是Google 建立了 Resource Table 来实现资源的映射;

因此Android 必须给每一个 View 一个 Resource ID。否则 xml 解析之后就根本不知道谁是谁。

至于iOS, iPhone 第一代发布的时间为2007 年;当时 Apple 的理念完全为:Everything is an Object, 即整个 UIKit 都建立在 Objective-C 对象之上。

|----------------------------------------------------|
| UIButton *loginButton = [[UIButton alloc] init]; |

loginButton 是一个指针变量 ,保存了这个对象地址;相当于使用这个对象的地址作为唯一的标识,利用已经存在的规则而并非像 Google 一样去创建"新规则";

为什么后来iOS又增加 Accessibility Identifier?

如果没有 Resource ID,那么自动化测试怎么办?

答案是:Accessibility 最初根本不是为了自动化测试而设计的。

Accessibility 的历史

iOS 最初没有 Resource ID,是因为 UIKit 采用对象驱动架构,开发者始终通过对象引用访问控件,不需要资源映射。

Apple 从很早就开始投入无障碍(Accessibility)能力,希望:

  • 视障用户能够操作手机

  • VoiceOver 能读出按钮名称

  • 残障人士能够正常使用 iPhone

于是系统需要知道,这是一个按钮、它叫什么、它能不能点击,Apple 为了支持 VoiceOver 等无障碍功能,引入了 Accessibility Framework,让每个控件拥有可供辅助技术识别的"语义信息"。

随着 XCUITest 和 Appium 的发展,Apple 又在 Accessibility Framework 中增加了 accessibilityIdentifier,它不会参与无障碍朗读,而是提供一个稳定、不会随对象地址变化的唯一标识,因此逐渐成为 iOS 自动化测试最推荐的定位方式。

另外accessibilityLabel 与 accessibilityIdentifier 有着不同的含义:

|--------------------------------------------------------------------------------------|
| button.accessibilityLabel = "登录" button.accessibilityIdentifier = "login_button" |

  • accessibilityLabel: 给用户(无障碍)是使用,会被 VoiceOver 朗读,自动化测试可用使用,但不推荐,容易因文案变化失效
  • accessibilityIdentifier:给开发/测试工具,不会被 VoiceOver 朗读,推荐自动化测试使用,稳定且不受文案影响;

因此现在 iOS 自动化最佳实践通常会要求前端(iOS 开发)为关键控件设置 accessibilityIdentifier(很多跨平台框架会将 testID 映射到它),而不是依赖界面显示文本进行定位。

testID

关于 testID :testID 并不是 iOS、Android 或 Appium 官方提供的概念。 它实际上是跨平台框架(尤其是 React Native)为了统一自动化测试而抽象出来的一个属性。

随着跨平台框架的发展(尤其是 React Native),开发者希望:同一套代码,同时支持 Android 和 iOS。但是两个平台的定位方式完全不同,安卓依赖resource-id , iOS依赖accessibilityIdentifier;

于是 React Native 定义了一个统一的属性:

|----------------------------------------|
| <Button testID=``"login_button" /> |

开发者只需要写一次:

|---------------------------|
| testID=``"login_button" |

React Native 会根据不同的平台,自动转换成对应的原生属性。

关于 testID 在不同平台如何映射:

  • iOS 几乎就是直接映射: testID="login_button" → view.accessibilityIdentifier = "login_button"
  • Android :testID 会被桥接到 Android 原生,使 UIAutomator/Appium 能够识别这个测试标识。 (Android 并不会识别 testID。识别 testID 的是 React Native。React Native 在创建 Android View 时,把 testID 主动调用 Android 原生 API 写到了 View 对象里)

注:关于"桥接",就是把 JS 属性翻译成 Java API 调用

上面使用 view.setTag(R.id.react_test_id, testID)React Native Android 多个版本中的典型实现方式 ,用于帮助理解"桥接"的过程。React Native 内部实现会随着版本(尤其是 Fabric 新架构)演进而调整,Appium/UIAutomator 最终读取该标识的具体细节也可能有所变化。但核心思想始终一致:testID 是 React Native 定义的属性,React Native 负责把它转换为原生 View 上可供测试框架读取的信息,而不是 Android Resource System 为它生成一个新的 resource-id

在 Appium Inspector 中,很多时候仍然会以 resource-id 或可识别的测试属性形式呈现,这也是为什么不少人感觉它像 resource-id

总的来说:

iOS 没有 Resource ID 并不是缺少一个功能,而是因为它从架构设计上就不需要这一层"资源映射"。 Android 在创建 View 之前需要先解析资源并完成"资源名 → Resource ID"的映射;而 iOS 创建出来的就是 UIViewUIButton 等对象,开发过程中一直通过对象引用来访问它们,因此没有引入类似 Android Resource Table 的机制。直到后来为了支持无障碍和 UI 自动化测试,Apple 才增加了 accessibilityIdentifier,它只是 UIView 的一个普通字符串属性,而不是像 Android Resource ID 那样参与编译和资源管理

为什么 React Native 两个平台都能定位?

React Native 能让两个平台都能定位,并不是因为 Android 和 iOS 都认识 testID,而是因为 React Native 在原生层分别做了不同的适配,而 Appium 又在自动化层分别对 UiAutomator2 和 XCUITest 做了统一封装。

简单来说就是,React Native 把 JS API 翻译成 Android / iOS 的原生 API。Appium 把统一的自动化 API 翻译成 Android / iOS 的自动化框架

React NativeAppium 看成两个不同层面的"适配器(Adapter)",它们都遵循了经典的软件设计原则------抽象(Abstraction)+ 平台适配(Platform-specific Implementation)

  • React Native 面向的是开发者 ,它抽象了 UI 开发接口(如 testIDstyleonPress),开发者只需要编写一套 JavaScript/JSX,React Native 再根据平台分别调用 Android 和 iOS 的原生 API。
  • Appium 面向的是自动化测试开发者 ,它抽象了自动化操作接口(如 findElementclicksendKeys),底层再分别调用 Android 的 UiAutomator2 和 iOS 的 XCUITest

这也是跨平台框架最核心的思想:对开发者暴露一套统一 API,对底层平台做不同实现(Platform-specific Implementation)。testID 只是这个思想的一个典型例子

回到我们这次的截图

Android:resource-id = field_name

因此:Find By → id : field_name

iOS:name = field_name

因此:Find By → accessibility id : field_name

说明这一页 RN 已经设置了 testID

如果没有设置:Android 可能仍能依赖 Resource ID;iOS 很可能只能使用 XPath 或 Class Chain。

Q: ios 的accessibility identifier 是不会改变的吧(只要前端写了 testid)

一般情况下,只要前端写的是固定 testID,iOS 上对应的 accessibilityIdentifier 就是稳定的,不会因为按钮位置、页面布局或文案变化而自动改变。

例如:

复制代码
<View testID="field_name" />

只要代码中的 "field_name" 没被修改,React Native 在 iOS 端映射出来的标识通常也会保持 "field_name"。Apple 将 accessibilityIdentifier 定义为供 UI 自动化脚本识别元素的字符串;它本身不会由系统随机生成或随着布局变化自动变化。

但需要注意:稳定不等于永远不会变。

以下情况仍可能导致定位失效:

  • 开发把 testID="field_name" 改成了其他值;
  • testID 是动态拼接的,例如 field_${index},列表顺序变化后值也可能变化;
  • 开发删除了 testID,或者把它移动到了外层/内层组件;
  • 多个元素使用了相同 testID,导致定位不唯一;
  • 组件重构后,testID 没有继续传递到底层原生 View;
  • Inspector 中看到的是 labelname 的回退值,而不是真正固定的 identifier;
  • 页面元素尚未创建、被销毁重建,或处于不可访问的树结构中。

React Native 也明确说明 Android 与 iOS 的无障碍实现存在平台差异,因此同一个属性在两端的最终暴露形式可能不完全一致

建立知识体系

不要死记:

Android 用 id,iOS 用 accessibility id。

真正应该记住的是下面这张思维图:

复制代码
Android(开放、资源驱动)
android:id
      │
Resource ID
      │
UIAutomator
      │
Appium ID
​
​
iOS(对象驱动、无障碍复用)
UIView
      │
Accessibility Identifier
      │
Accessibility Tree
      │
XCTest
      │
WebDriverAgent
      │
Appium Accessibility ID
相关推荐
恋猫de小郭1 小时前
超好用 R8 Configuration Analyzer, 优化你的 App 大小和内存
android·前端·flutter
又见情义1 小时前
Android 13 系统应用裁剪:RK3568平台下的精简之道
android
苦瓜花1 小时前
【Android】LiveData
android
格林威2 小时前
C#图像处理:使用imagemagick实现像素放大水印转换等多种功能
android·开发语言·图像处理·人工智能·计算机视觉·c#·视觉检测
雨白9 小时前
深入理解 Java/Kotlin 反射、注解与泛型:从原理到实战
android·架构
喜欢打篮球的普通人13 小时前
LLVM Backend Lowering 从入门到实战:把 IR 变成机器码的完整链路
android·java·数据库
萝卜er16 小时前
BroadcastReceiver 静态注册与动态注册-《Android深水区(七)》
android
萝卜er16 小时前
ContentProvider 与跨进程数据访问-《Android深水区(八)》
android
萝卜er17 小时前
Service、前台服务与后台限制-《Android深水区(六)》
android