关于 cross-app-tests-with-uiautomator
面向 Android 仪器测试的技能指南,介绍如何使用 UiAutomator 跨应用及系统界面进行测试。重点涵盖现代选择器、对象失效恢复和跨应用启动。
主要功能
- 01跨进程驱动应用与系统界面
- 02使用 BySelector 和 UiObject2 查找控件
- 03等待异步界面状态
- 04恢复失效的 UiObject2 引用
- 05启动其他应用并等待界面切换
适用场景
- 01测试设置、通知栏或快速设置
- 02验证分享表单等跨应用流程
- 03排查 StaleObjectException
- 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恢复摘要。