mcpField
C
cross-app-tests-with-uiautomator

cross-app-tests-with-uiautomator

github.com/skydoves测试与质量33963 次安装22 周前更新
代码仓库

关于 cross-app-tests-with-uiautomator

面向 Android 仪器测试的技能指南,介绍如何使用 UiAutomator 跨应用及系统界面进行测试。重点涵盖现代选择器、对象失效恢复和跨应用启动。

主要功能

  1. 01跨进程驱动应用与系统界面
  2. 02使用 BySelector 和 UiObject2 查找控件
  3. 03等待异步界面状态
  4. 04恢复失效的 UiObject2 引用
  5. 05启动其他应用并等待界面切换

适用场景

  1. 01测试设置、通知栏或快速设置
  2. 02验证分享表单等跨应用流程
  3. 03排查 StaleObjectException
  4. 04从主屏幕启动应用并确认界面

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/skydoves/android-testing-skills --skill cross-app-tests-with-uiautomator

安装后在 Claude Code 中输入/cross-app-tests-with-uiautomator确认已加载。

详细介绍

使用 UiAutomator 进行跨应用测试 — 操作系统 UI 和其他应用

UiAutomator 是唯一可以跨进程边界操作 UI 的 AndroidX 测试框架,包括设置、拨号器、系统通知和其他应用的 Activity。当事件需要跨越到其他窗口时,Espresso 会拒绝操作(InjectEventSecurityException)。UiAutomator 基于平台的 AccessibilityNodeInfo 树运行,因此可以看到所有窗口。本技能介绍了现代 BySelector / UiObject2 API、单例的获取方式、StaleObjectException 的恢复模式,以及跨应用启动路径。

何时使用此技能

  • 测试必须操作系统 UI(设置、拨号器、系统通知、快捷设置、最近任务)。
  • 测试涉及开发者的应用和另一个应用(分享面板、联系人选择器、相机)。
  • 测试到达其他窗口时,Espresso 抛出了 InjectEventSecurityException。
  • 用户报告了 StaleObjectException,正在调试缓存节点的生命周期。
  • 用户询问如何在测试中返回设备主屏幕、按下最近任务键或切换飞行模式。
  • 用户在同一测试中混用 BySelector 和 UiSelector,不清楚哪种是当前 API。

何时不使用此技能

  • UI 完全位于开发者的应用内 — Espresso(Views)或 Compose 测试速度更快、可靠性更高。Views 请参阅 ../../espresso/writing-espresso-tests/SKILL.md。
  • 尚未设置运行器栈 — 从 ../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md 开始。
  • 用户只需推送设备端状态更改(关闭动画、intent 存根)— 请参阅 ../../../adb/control/injecting-input-and-state/SKILL.md。
  • 测试只需在运行后抓取日志 — 请参阅 ../../../adb/observability/extracting-logs-with-logcat/SKILL.md。

前提条件

  • 按照 ../../runner/running-instrumented-tests-with-androidjunit4/SKILL.md 设置运行器栈。
  • androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")。
  • 一台可用 accessibility_service 的实体或虚拟 Android 设备(默认可用)。
  • 在 API 30+ 上,若要通过 Context.startActivity 启动其他包中的组件,测试 APK 清单中必须包含 <queries> 条目,声明目标包或 intent action。

工作流

  • 1. 每个测试只获取一次 UiDevice,通过单例并显式传入 instrumentation。 无参 getInstance() 已标记为 @Deprecated(R4 第 56–58 行)——如果此前未初始化,则会抛出 IllegalStateException:
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.UiDevice

private lateinit var device: UiDevice

@Before fun setUp() {
    device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
}
  • 2. 通过 UiDevice 方法发送硬件按键和全局操作。 来源:R4 第 88–104 行。
device.pressBack()
device.pressHome()
device.pressMenu()
device.pressRecentApps()                                // GLOBAL_ACTION_RECENTS
device.pressKeyCode(KeyEvent.KEYCODE_VOLUME_DOWN)
device.openNotification()                                // GLOBAL_ACTION_NOTIFICATIONS
device.openQuickSettings()                               // GLOBAL_ACTION_QUICK_SETTINGS
device.wakeUp()                                          // power-on if off
device.sleep()                                           // power-off if on
  • 3. 通过 By 工厂使用 BySelector(现代方式)查找 UI 元素。 选择器是近似不可变的构建器;包私有构造函数确保只能使用静态工厂。条件默认以 AND 连接。来源:R4 第 185–258 行。
import androidx.test.uiautomator.By

By.text("Submit")                              // exact, case-sensitive
By.textContains("ubmi")                        // case-sensitive substring
By.textMatches("Sub.*")                        // Pattern.compile regex
By.desc("Open menu")                           // content description
By.res("com.example.app", "submit_btn")        // package + res id
By.res("com.example.app:id/submit_btn")        // qualified res id
By.clazz("android.widget.Button")              // fully-qualified, or ".widget.Button" auto-prefixed with "android"
By.clazz(android.widget.Button::class.java)
By.pkg("com.android.settings")
By.depth(2)                                    // depth from window root
By.checkable(true)
By.hasChild(By.text("Inner"))                  // sugar for hasDescendant(child, 1)
By.hasDescendant(By.text("anywhere"))
By.hasParent(By.clazz("...LinearLayout"))
By.hasAncestor(By.res("...container"))
  • 4. 通过 device.findObject(...) 获取 UiObject2 并执行操作。 未找到匹配项时,findObject 返回 null(绝不会抛出异常)。进行交互时,优先使用 device.wait(Until.findObject(By...), timeoutMillis),让查找能够适应缓慢的渲染:
import androidx.test.uiautomator.Until

val submit = device.wait(Until.findObject(By.res("com.example", "submit")), 5_000)
    ?: error("Submit not found within 5s")

submit.click()
submit.longClick()
submit.setText("hello")
submit.clear()
submit.swipe(Direction.LEFT, 0.8f)            // percent ∈ [0,1]
submit.scroll(Direction.DOWN, 1.0f)
submit.scrollUntil(Direction.DOWN, Until.findObject(By.text("Bottom")))
submit.pinchOpen(0.5f)
submit.pinchClose(0.5f)

UiObject2 会缓存一个 AccessibilityNodeInfo。每次操作都会调用 getAccessibilityNodeInfo(),该方法会调用 device.waitForIdle(),然后调用 mCachedNode.refresh();若失败,则会运行已注册的 UiWatcher,重试一次,若再次失败,则抛出 StaleObjectException(R4 第 281–286、622–637 行)。

  • 5. 使用 Until 条件等待异步 UI 状态。 根据 R4 第 495–540 行,条件分为三种:
// SearchCondition (consume the device tree)
device.wait(Until.hasObject(By.text("Loaded")), 5_000)
device.wait(Until.gone(By.text("Loading")), 5_000)
val obj: UiObject2? = device.wait(Until.findObject(By.text("OK")), 5_000)
val list: List<UiObject2>? = device.wait(Until.findObjects(By.clazz("...Button")), 3_000)

// UiObject2Condition (consume a UiObject2 — for state predicates)
val checked = button.wait(Until.checked(true), 5_000)

// EventCondition (consume AccessibilityEvents — for window transitions)
val newWindowFired = device.performActionAndWait(
    { button.click() }, Until.newWindow(), 10_000,
)
  • 6. 重新查找以从 StaleObjectException 中恢复。 StaleObjectException extends RuntimeException——这是一个非受检异常;持有长期存在的 UiObject2 时,必须处理:
fun clickRetry(selector: BySelector, timeoutMillis: Long = 5_000) {
    val obj = device.wait(Until.findObject(selector), timeoutMillis)
        ?: error("Not found: $selector")
    try {
        obj.click()
    } catch (e: StaleObjectException) {
        device.wait(Until.findObject(selector), timeoutMillis)?.click()
            ?: throw e
    }
}

更好的做法是每次交互前都重新查找,而不是长期持有引用。常见原因包括:Activity 转换、Compose 重组并替换语义节点、RecyclerView 回收、配置更改(R4 第 639–645 行)。

  • 7. 启动其他包——优先使用 Context.startActivity,而非 executeShellCommand。 根据 R4 第 147–151 行,executeShellCommand 标记为 @Discouraged(UiDevice.java:1432);Javadoc 建议对 stdin/stderr 使用 UiAutomation.executeShellCommandRwe。日常启动应用时,请使用进程内的 intent 方式,并在 API 30+ 中添加 <queries> 清单项:

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <queries>
        <package android:name="com.android.settings" />
        <intent>
            <action android:name="android.settings.SETTINGS" />
        </intent>
    </queries>
    
</manifest>
val ctx = ApplicationProvider.getApplicationContext<Context>()
ctx.startActivity(
    Intent(android.provider.Settings.ACTION_WIFI_SETTINGS)
        .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
)
device.wait(Until.hasObject(By.pkg("com.android.settings").depth(0)), 10_000)

如果用户确实需要通过 shell 启动(例如没有 Intent 能解析的深层链接):

device.executeShellCommand("am start -n com.android.settings/.Settings")
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)
  • 8. 必要时调整 Configurator 的全局超时;在 teardown 时恢复。 设置作用于整个进程。默认值(R4 第 550–558 行):等待空闲 10s、等待选择器 10s(仅适用于旧版 UiObject)、操作确认 3s、滚动事件等待 1s。
import androidx.test.uiautomator.Configurator

@Before fun setUp() {
    val c = Configurator.getInstance()
    originalIdle = c.waitForIdleTimeout
    c.waitForIdleTimeout = 5_000   // tighter for fast tests
}

@After fun tearDown() {
    Configurator.getInstance().waitForIdleTimeout = originalIdle
}

setKeyInjectionDelay 已标记为 @Deprecated(“不再使用此参数(文本会直接设置,而不是通过按键设置)。”——R4 第 575–577 行)。请勿调用。

  • 9. 了解何时应回退到旧版 UiSelector / UiObject。 UiSelector 会在每次操作时通过 Configurator.getWaitForSelectorTimeout() 重新解析节点,因此不会过期,但每次调用的开销更大,且会使用受检异常 UiObjectNotFoundException。现代的 BySelector / UiObject2 是推荐的默认方式。仅在需要与旧测试代码互操作,或必须采用重新解析语义时使用旧版方式。R4 第 594–619 行。

  • 10. 启动后切勿立即调用 findObject(By.text("...")) 并假定它能找到目标窗口。 By.text 会匹配当前屏幕上的内容,可能是临时启动画面或上一个应用。请始终与 By.pkg(...) 结合使用,或显式等待:

device.pressHome()
ctx.startActivity(Intent(Settings.ACTION_SETTINGS).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK))
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)   // gate on package
device.wait(Until.findObject(By.text("Network & internet")), 5_000)?.click()

模式

模式:错误做法 vs 正确做法——不等待包转换便操作 Settings
// WRONG
device.findObject(By.text("Settings")).click()
// WRONG because: at the moment of the call, By.text("Settings") matches whatever text reads
// "Settings" anywhere on the current screen — that might be a launcher tile, a notification,
// or a previous-app menu item. The test silently navigates to the wrong place.
// RIGHT
device.pressHome()
device.executeShellCommand("am start -a android.settings.SETTINGS")
device.wait(Until.hasObject(By.pkg("com.android.settings")), 5_000)
device.wait(Until.findObject(By.text("Network & internet")), 5_000)?.click()
模式:错误做法 vs 正确做法——无参 UiDevice.getInstance()
// WRONG
val device = UiDevice.getInstance()
// WRONG because: UiDevice.java:344 — the no-arg getInstance() is @Deprecated and throws
// IllegalStateException if the singleton has not been previously initialized with the
// instrumentation argument. Setting it implicitly via androidTest is unreliable across
// runner versions.
// RIGHT
val device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
模式:错误做法 vs 正确做法——在转换过程中长期持有 UiObject2
// WRONG
val tile = device.wait(Until.findObject(By.descContains("Wi-Fi")), 5_000)
device.openQuickSettings()
tile.click()                            // throws StaleObjectException — quick settings replaced the layout
// RIGHT — re-acquire after the transition
device.openQuickSettings()
val tile = device.wait(Until.findObject(By.descContains("Wi-Fi").clickable(true)), 5_000)
tile?.click()
device.pressBack()
模式:关闭所有通知
device.openNotification()
val shade = device.wait(
    Until.findObject(By.res("com.android.systemui:id/notification_stack_scroller")),
    5_000,
) ?: error("Notification shade not visible")

for (n in shade.findObjects(By.res("com.android.systemui:id/notification_layout"))) {
    n.swipe(Direction.RIGHT, 1.0f)
}
device.pressBack()
模式:从干净状态启动被测应用
@Before fun launchAppFromHome() {
    device.pressHome()
    val ctx = ApplicationProvider.getApplicationContext<Context>()
    val launch = ctx.packageManager.getLaunchIntentForPackage("com.example.app")!!
        .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
    ctx.startActivity(launch)
    device.wait(Until.hasObject(By.pkg("com.example.app").depth(0)), 5_000)
}

强制规则

  • 必须通过 UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) 获取 UiDevice。无参的 getInstance() 已标记为 @Deprecated,且不可靠。
  • 必须在新测试中优先使用 BySelector / UiObject2,而不是 UiSelector / UiObject。仅在互操作时使用旧版 API。
  • 必须在跨窗口切换后解析内部元素之前,通过 device.wait(Until.hasObject(By.pkg("...")), timeout) 等待切换完成。直接调用 findObject 会在当前窗口中查找,不会等待。
  • 必须处理 StaleObjectException(通过 device.wait(Until.findObject(By...), timeout) 重新查找),以应对在重组、切换或 RecyclerView 回收期间保留 UiObject2 引用的情况。
  • 必须在测试修改了 Configurator 设置时,于 @After 中恢复设置。设置是进程级的,会泄漏到其他测试中。
  • 不得调用 Configurator.setKeyInjectionDelay——该方法已标记为 @Deprecated,且不执行任何操作。
  • 不得依赖 executeShellCommand 来常规启动应用——该方法标记为 @Discouraged。在 API 30 及以上版本中,应使用 Context.startActivity 并在清单中添加 <queries> 条目。
  • 不得在未按包名进行筛选的情况下使用 device.findObject(By.text("..."))——文本匹配针对当前窗口内容,而当前窗口可能是临时启动画面。
  • **建议:**将任何单次交互封装为 device.wait(Until.findObject(...), timeout)?.click(),而不是间隔两行先调用 findObject 再调用 click。分两步操作会与界面渲染产生竞争。
  • **建议:**为测试启动的任何外部包添加 <queries>,否则在 API 30 及以上版本中可能会静默失败。

验证

  • 没有源文件调用不带参数的 UiDevice.getInstance()。
  • 没有源文件调用 Configurator.setKeyInjectionDelay。
  • 每个长期保留的 UiObject2 引用在执行操作时都有 try { ... } catch (StaleObjectException) 包裹,或者测试会在每次交互前重新查找对象。
  • 启动外部包的测试,在 src/androidTest/AndroidManifest.xml 中为该包或其 Intent action 添加了 <queries> 条目。
  • ./gradlew :<module>:connectedDebugAndroidTest 运行时,不会因 UiDevice.getInstance() 抛出 IllegalStateException,也不会有未处理的 StaleObjectException 传播到测试结果中。
  • 跨窗口切换后,不会在未先调用 device.wait(Until.hasObject(By.pkg(...)), ...) 的情况下立即调用 findObject(By.text(...))。
  • 如果在 @Before 中修改了 Configurator,则会在 @After 中恢复原始值。

参考资料

  • Android Developers — UiAutomator 概览:https://developer.android.com/training/testing/other-components/ui-automator
  • AndroidX Test(UiAutomator)发行说明:https://developer.android.com/jetpack/androidx/releases/test
  • UiAutomator API 参考:https://developer.android.com/reference/androidx/test/uiautomator/package-summary
  • androidx/test/uiautomator/UiDevice.java — 单例入口。第 344 行(已弃用的无参 getInstance)、第 357 行(当前的 getInstance(Instrumentation))、第 1432 行(@Discouraged executeShellCommand)。
  • androidx/test/uiautomator/By.java — BySelector 的静态工厂。text/desc/res/clazz/pkg/depth/checkable/hasChild/hasDescendant/hasParent/hasAncestor 的源码。
  • androidx/test/uiautomator/BySelector.java(838 行)— 完整的条件目录。第 128 行(为 .widget.Button 自动添加前缀 android)、第 198/211/224 行(正则表达式/包含/以……开头的约定)、第 605-650 行(maxDepth)、第 660 行(displayId)。
  • androidx/test/uiautomator/UiObject2.java(1152 行)— click、setText、swipe、scroll、scrollUntil、pinchOpen/Close、fling。第 1031 行 — 刷新失败且重试 watcher 后,getAccessibilityNodeInfo() 会抛出 StaleObjectException。
  • androidx/test/uiautomator/Until.java 第 43-456 行 — hasObject、gone、findObject、findObjects、newWindow、scrollFinished,以及 UiObject2Condition 谓词。
  • androidx/test/uiautomator/Configurator.java 第 36 行(警告需恢复设置)、第 108 行(仅旧版支持的 waitForSelector)、第 163 行(仅旧版支持的 actionAcknowledgment)、第 189 行(@Deprecated 的 setKeyInjectionDelay)、第 295 行(setDefaultDisplayId)。
  • androidx/test/uiautomator/StaleObjectException.java — extends RuntimeException。
  • tasks/research/R4-uiautomator.md — UiAutomator 2.3.0 完整深度解析。第 56-58 行(已弃用的单例)、第 185-258 行(BySelector 目录)、第 268-369 行(UiObject2 操作)、第 495-540 行(Until 条件)、第 594-619 行(现代版与旧版对照表)、第 622-662 行(StaleObjectException 恢复)。
  • docs/CORPUS.md 第 H.6 节 — UiAutomator 基础要点和 StaleObjectException 恢复摘要。

评论

支持 Markdown