关于 synchronizing-with-idle
面向 Jetpack Compose 测试编写者的技能,梳理各类等待与空闲 API 的语义和超时来源,并提供选择方法,帮助处理异步 UI 同步问题。
主要功能
- 01解释 Compose 的空闲条件
- 02对比等待 API 与超时
- 03提供按条件选择的决策表
- 04说明 IdlingResource 与 Espresso 桥接
- 05给出状态读写和断言模式
适用场景
- 01排查节点缺失或数量不符的间歇性失败
- 02等待 Snackbar、导航或 IO 状态更新
- 03判断 Compose 状态与外部任务该用哪种等待
- 04为异步外部工作注册 IdlingResource
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill synchronizing-with-idle安装后在 Claude Code 中输入/synchronizing-with-idle确认已加载。
详细介绍
与空闲状态同步——选择合适的等待方式
Compose 测试使用了错误的等待原语,是导致测试不稳定的头号原因。本技能列出了 ComposeTestRule 和 ComposeUiTest 上所有空闲/等待 API,明确界定“空闲”的实际含义,并提供选择指南。动画专用的等待方式见 ../testing-animations-deterministically/SKILL.md;底层时钟语义见 ../controlling-the-test-clock/SKILL.md。
何时使用本技能
- 测试偶尔因“未找到节点”或“节点数量不匹配”而失败,但生产代码没有问题。
- 开发者想用
Thread.sleep(2000)等待屏幕、Snackbar、导航过渡或由 IO 驱动的状态。 ViewModel通过协程发布状态,测试需要等待 UI 反映该状态。- 开发者询问
IdlingResource、Espresso 桥接、runOnIdle与runOnUiThread的区别,或runWhenIdle是否更快。 - 开发者提到
waitUntil、waitUntilExactlyOneExists、waitUntilNodeCount、AndroidComposeUiTestTimeoutException或IdlingPolicies.setMasterPolicyTimeout。 - 开发者在测试线程读取或修改状态(例如
state.firstVisibleItemIndex),并询问为什么有时会得到过期数据。
何时不使用本技能
- 条件涉及暂停时钟上的动画。请使用
../testing-animations-deterministically/SKILL.md和mainClock.advanceTimeUntil。 - 不清楚
MainTestClock本身的机制(帧、舍入、调度器)。请先阅读../controlling-the-test-clock/SKILL.md。 - 测试失败的原因是测试标签/查找器问题,而非异步时序。请使用
../../debug/printing-the-semantics-tree/SKILL.md和../../finders/finding-nodes-by-tag-text-content/SKILL.md。 - 实际问题涉及 Espresso 互操作。请使用
../../interop/testing-with-espresso-interop/SKILL.md(同级技能,范围独立)。
前置条件
androidx.compose.ui:ui-test和androidx.compose.ui:ui-test-junit4(或runComposeUiTest)。- 来自
createComposeRule()的ComposeContestTestRule(首选 v2:androidx.compose.ui.test.junit4.v2.createComposeRule)。 - 对于实验性的
waitUntilNodeCount/waitUntilExactlyOneExists/waitUntilAtLeastOneExists/waitUntilDoesNotExist:在测试类或方法上添加@OptIn(ExperimentalTestApi::class)。
“空闲”的含义
以下三个条件必须同时成立(compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:174-186):
- 没有待处理的重组。 没有待处理的快照应用通知,没有待处理工作的重组器,也没有在测试帧时钟上等待的
withFrameNanos调用。 - 没有待处理的绘制调用。 测量和布局流程已经运行;在 Android 上,框架已请求绘制流程(Android 上由 Choreographer 驱动绘制,测试时钟不会驱动绘制——
MainTestClock.kt:36-41)。 - 所有已注册的
IdlingResource均报告isIdleNow == true。 非 Compose 异步工作(如OkHttp调用、Room查询)通过这种方式参与同步。
Compose 自身的工作会通过 ComposeIdlingResource 自动注册(compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt),它会循环排空重组器、快照和帧时钟等待项,每次调用最多 100 帧(第 109 行)。如果测试需要超过 100 帧才能稳定下来,说明每次 waitForIdle 要处理的工作过多。
基础 API
以下各项均来自 ComposeUiTest.kt / ComposeTestRule.jvmAndAndroid.kt。
| API | 效果 | 超时来源 |
|---|---|---|
waitForIdle() | 阻塞当前线程,直到满足三个空闲条件。启用 autoAdvance = true 时,还会自动推进时钟以清空待处理工作。 | 墙上时钟 — IdlingPolicies.getMasterIdlingPolicy() |
awaitIdle() | waitForIdle 的 suspend 变体。 | 相同 |
waitUntil(conditionDescription, timeoutMillis = 1_000, condition) | 阻塞,直到 condition() 返回 true。每次迭代都会调用 mainClock.advanceTimeByFrame()(当 autoAdvance == true 时)并调用墙上时钟的 Thread.sleep(10)(ComposeUiTest.android.kt:899-902)。超时依据 System.nanoTime() 测量,因此即使测试时钟也在推进,超时仍按墙上时钟计算。 | 墙上时钟 — 每次调用的 timeoutMillis |
waitUntilNodeCount(matcher, count, timeoutMillis = 1_000L) | @ExperimentalTestApi。通过 waitUntil 检查 onAllNodes(matcher).fetchSemanticsNodes(atLeastOneRootRequired = false).size == count(ComposeUiTest.kt:275-285)。 | 墙上时钟 |
waitUntilExactlyOneExists(matcher, timeoutMillis = 1_000L) | @ExperimentalTestApi。waitUntilNodeCount(matcher, 1, timeoutMillis) 的语法糖。 | 墙上时钟 |
waitUntilAtLeastOneExists(matcher, timeoutMillis = 1_000L) | @ExperimentalTestApi。(ComposeUiTest.kt:298-305) | 墙上时钟 |
waitUntilDoesNotExist(matcher, timeoutMillis = 1_000L) | @ExperimentalTestApi。waitUntilNodeCount(matcher, 0, timeoutMillis) 的语法糖。 | 墙上时钟 |
runOnIdle { … } | 先调用 waitForIdle(),再调用 runOnUiThread { … }。状态修改和读取的默认方式。 | 墙上时钟(内部的 waitForIdle) |
runOnUiThread { … } | 通过 Instrumentation.runOnMainSync 提交一个 FutureTask(AndroidSynchronization.android.kt)。如果已在 UI 线程,则就地运行。不会等待空闲。 | 无 |
runWhenIdle { … } | 先调用 waitForIdle(),再调用 runOnUiThread { … },但会抑制 lambda 内节点查询触发的隐式 waitForIdle。仅用于断言的代码块执行更快。不得在内部修改状态。 | 墙上时钟 |
awaitAndRunWhenIdle { … } | runWhenIdle 的 suspend 变体。 | 墙上时钟 |
hasPendingWork(): Boolean | 被动快照:主时钟上是否有等待者、是否有快照更改,或重组器是否有待处理工作?仅在 autoAdvance = false 时可靠(ComposeUiTest.kt:251-256)。 | 无 |
IdlingResource 注册 | rule.registerIdlingResource(myResource) / unregisterIdlingResource。 | 墙上时钟 |
超时来源 — 不要混淆
| API | 来源 | 默认值 | 覆盖方式 |
|---|---|---|---|
waitForIdle / awaitIdle | 墙上时钟 — Espresso IdlingPolicies 主超时 | 26 s | 在 @Before 中调用 IdlingPolicies.setMasterPolicyTimeout(...) |
waitUntil(…) 系列 | 墙上时钟 | 每次调用 1000 ms | 每次调用设置 timeoutMillis |
MainTestClock.advanceTimeUntil(…) | 测试时钟 | 每次调用 1000 ms | 每次调用设置 timeoutMillis |
runComposeUiTest(testTimeout = …) | 墙上时钟 | 60.seconds | runComposeUiTest(testTimeout = 5.minutes) { … };抛出 AndroidComposeUiTestTimeoutException |
Skydoves 热点观点 #4:waitUntil 的超时按墙上时钟计算;advanceTimeUntil 按测试时钟计算。等待条件可通过 Compose 状态观察时,始终优先使用 mainClock.advanceTimeUntil。仅当条件不属于 Compose 快照系统时才使用 waitUntil,例如 Job.isCompleted、由 LaunchedEffect 增加的计数器,或外部服务。
RobolectricIdlingStrategy 使用相同的 Espresso IdlingPolicies.getMasterIdlingPolicy(),因此全局调用 setMasterPolicyTimeout 延长超时时间,同样适用于主机(Robolectric)和设备测试。
Espresso 桥接
Espresso 有自己的 androidx.test.espresso.IdlingResource 接口,它与 Compose 的 androidx.compose.ui.test.IdlingResource 并不相同。桥接接口是 EspressoLink(compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt):
Espresso (instrumentation tests)
│ withStrategy { … }
▼
EspressoLink ──implements──> androidx.test.espresso.IdlingResource
│
│ delegates isIdleNow() to ──> IdlingResourceRegistry
▼
IdlingResourceRegistry — Compose's registry
│
├── ComposeIdlingResource (recomposer + snapshot + frame-clock awaiters)
└── any rule.registerIdlingResource(...)
主机测试中的 RobolectricIdlingStrategy 也会读取同一个注册表。开发者注册 Compose IdlingResource;桥接由框架完成。
interface IdlingResource {
val isIdleNow: Boolean
fun getDiagnosticMessageIfBusy(): String? = null
}
(摘自 compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResource.kt:37-52。)重写 getDiagnosticMessageIfBusy(),以便在等待超时时显示有用的信息 — 它会追加到超时异常文本中。
决策矩阵
Need to wait for… | Use
------------------------------------------|----------------------------------------------
A node count to stabilize | waitUntilNodeCount / waitUntilExactlyOneExists
A node to appear | waitUntilAtLeastOneExists
A node to disappear | waitUntilDoesNotExist
A Compose state condition | mainClock.advanceTimeUntil { state }
A non-Compose condition (Job, counter) | waitUntil(conditionDescription) { condition }
"Just settle the UI" | waitForIdle() (or runOnIdle for read+act)
External async work (HTTP, DB) | rule.registerIdlingResource(MyIdlingResource)
A paused-clock animation frame | mainClock.advanceTimeBy(...) -- different skill
A particular state.value to read safely | rule.runOnIdle { state.value }
工作流
1. 与状态交互时,默认使用 runOnIdle
在测试线程中读取或修改 Compose 状态会与 recomposer 发生竞争。recomposer 会在主线程上应用快照写入;测试线程可能会观察到尚未完全应用的状态。runOnIdle 会等待系统空闲,然后将操作派发到 UI 线程,从而解决此问题:
val firstVisibleIndex = rule.runOnIdle { state.firstVisibleItemIndex }
(skydoves 的犀利观点 #5 — 将状态变更统一通过 runOnIdle 或 runOnUiThread 处理。)
2. 只读断言代码块应使用 runWhenIdle
rule.runWhenIdle {
val node = rule.onNodeWithTag("counter").fetchSemanticsNode()
assertEquals("3", node.config[SemanticsProperties.Text].first().text)
val rect = node.boundsInRoot
// ... more reads ...
}
在普通的 runOnIdle 中,每次节点查询都会重新运行 waitForIdle()。在 runWhenIdle 中,由于代码块开始前已确认系统空闲,因此这些隐式等待会被抑制。这对手动逐帧推进的测试很重要 — 在 runOnIdle 中进行节点查询会反复调用 waitForIdle(),而在 autoAdvance = true 时,它会自动推进时钟,破坏测试的手动控制。不得在 runWhenIdle 中修改状态。
3. 等待 N 个节点匹配时,使用实验性的 waitUntilExactlyOneExists 等方法
@OptIn(ExperimentalTestApi::class)
@Test fun snackbarAppears() {
rule.setContent { /* … triggers a snackbar after 200 ms … */ }
rule.onNodeWithTag("trigger").performClick()
rule.waitUntilExactlyOneExists(hasTestTag("snackbar"), timeoutMillis = 2_000)
rule.onNodeWithTag("snackbar").assertTextEquals("Saved")
}
默认的 1000 ms 超时时间(ComposeUiTest.kt:278、ComposeUiTest.kt:301、ComposeUiTest.kt:321、ComposeUiTest.kt:334)对于 snackbar 和底部弹出层动画来说通常太短。请在每次调用时显式调高。
4. 仅在等待非 Compose 条件时使用 waitUntil { … }
// SnackbarHostTest.kt:77-88
val job = scope.launch {
hostState.showSnackbar("1")
Truth.assertThat(resultedInvocation).isEqualTo("1")
hostState.showSnackbar("2")
Truth.assertThat(resultedInvocation).isEqualTo("12")
hostState.showSnackbar("3")
Truth.assertThat(resultedInvocation).isEqualTo("123")
}
rule.waitUntil { job.isCompleted }
Job.isCompleted 不是 Compose 状态;mainClock.advanceTimeUntil { job.isCompleted } 会循环并超时,因为无论时钟推进多少,Job 都不会自行完成。
5. 对真正的外部异步工作注册 IdlingResource
class HttpIdlingResource(private val client: OkHttpClient) : IdlingResource {
override val isIdleNow: Boolean
get() = client.dispatcher.runningCallsCount() == 0
override fun getDiagnosticMessageIfBusy(): String? =
"${client.dispatcher.runningCallsCount()} HTTP calls still in flight"
}
@Before fun setUp() { rule.registerIdlingResource(HttpIdlingResource(client)) }
@After fun tearDown() { rule.unregisterIdlingResource(HttpIdlingResource(client)) }
注册后,每次 waitForIdle(以及每次节点查询中隐式调用的 waitForIdle)都会阻塞,直到 isIdleNow == true。必须确保 isIdleNow 足够轻量 — 它会在主线程上调用(IdlingResource.kt:38-46)。
模式
模式:错误 — 使用 Thread.sleep 等待节点
// WRONG
@Test fun successScreen() {
rule.setContent { App() }
rule.onNodeWithTag("login").performClick()
Thread.sleep(2_000)
rule.onNodeWithTag("dashboard").assertExists()
}
// WRONG because: Thread.sleep is unrelated to Compose's synchronization. It either over-
// waits (slowing the test) or under-waits (flake when CI is slow). Compose's own
// IdlingResource is already wired; just wait on the node directly.
// RIGHT
@OptIn(ExperimentalTestApi::class)
@Test fun successScreen() {
rule.setContent { App() }
rule.onNodeWithTag("login").performClick()
rule.waitUntilExactlyOneExists(hasTestTag("dashboard"), timeoutMillis = 2_000)
rule.onNodeWithTag("dashboard").assertExists()
}
模式:错误 — 使用 waitUntil 等待可通过 Compose 状态观察的条件
// WRONG
rule.waitUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
// WRONG because: timeout is measured in wall clock and each iteration burns ~10 ms
// of real time on Thread.sleep(10) (ComposeUiTest.android.kt:899-902). For a Compose
// state condition, advanceTimeUntil drives the test clock and is deterministic.
// RIGHT
rule.mainClock.advanceTimeUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
(skydoves 的犀利观点 #4。)
模式:错误 — 从测试线程读取状态
// WRONG
val i = state.firstVisibleItemIndex
assertEquals(2, i)
// WRONG because: the test thread races the recomposer. The read may observe a half-applied
// snapshot, especially right after a click or scroll. Compose's docs are explicit: state
// reads from outside the UI thread are not synchronized.
// RIGHT
val i = rule.runOnIdle { state.firstVisibleItemIndex }
assertEquals(2, i)
(skydoves 的犀利观点 #5。)
模式:hasPendingWork() — 仅用于暂停时钟的被动检查
rule.mainClock.autoAdvance = false
rule.setContent { /* … */ }
rule.mainClock.advanceTimeByFrame()
rule.runOnUiThread { trigger = true }
assertTrue(rule.hasPendingWork()) // recomposition queued, no frame yet
rule.mainClock.advanceTimeByFrame()
assertFalse(rule.hasPendingWork()) // frame applied
摘自 ComposeUiTest.kt:251-256:
如果
autoAdvance为true,测试框架会持续处理待处理的工作。在这种情况下,调用此方法得到的只是某一时刻的快照,通常会返回false。如果工作已排队但框架尚未自动推进,它可能会短暂返回true,因此结果转瞬即逝、不可靠,不适合用于驱动测试逻辑。
当 autoAdvance == true 时,不得依据 hasPendingWork() 驱动测试逻辑。
模式:为缓慢的 CI 环境调高主空闲超时
@Before fun setUp() {
IdlingPolicies.setMasterPolicyTimeout(60, TimeUnit.SECONDS)
}
当 waitForIdle(而非 waitUntil)在 CI 上超时、但在本地通过时使用。EspressoLink 和 RobolectricIdlingStrategy 都遵循此策略。
强制规则
- 必须优先使用
mainClock.advanceTimeUntil { state },而不是rule.waitUntil { state },前提是等待条件读取的是 Compose 状态(skydoves 热点观点 #4)。 - 必须将测试线程中的状态读取包装在
runOnIdle { … }或runOnUiThread { … }中。在测试线程直接读取state.value会与重组器发生竞争(skydoves 热点观点 #5)。 - 必须在时钟暂停(
autoAdvance = false)时,使用runOnUiThread(而不是runOnIdle)进行状态修改;runOnIdle隐式调用的waitForIdle不适用于该模式。请参阅../testing-animations-deterministically/SKILL.md。 - 必须对包含大量节点读取的纯断言代码块使用
runWhenIdle { … }——它会抑制多余的逐次查询waitForIdle。不得在其中修改状态。 - 必须优先使用
waitUntilExactlyOneExists/waitUntilAtLeastOneExists/waitUntilDoesNotExist,而不是手动编写waitUntil { onAllNodes(...).fetchSemanticsNodes().isNotEmpty() }。这些方法更清晰,并会在超时消息中提供conditionDescription。 - 不得在
autoAdvance == true时将hasPendingWork()视为可采取行动的依据。它只是被动快照,只有在时钟暂停时才可靠(ComposeUiTest.kt:251-256)。 - 不得使用
Thread.sleep等待状态稳定。请改用waitUntil*、mainClock.advanceTimeUntil或IdlingResource(skydoves 热点观点 #7)。唯一合理的Thread.sleep用法是在 RenderThread 上进行 ripple/截图测试。 - **优先:**为每个真实的外部异步源(HTTP、DB、外部计数器)使用一个
IdlingResource,在@Before中注册,在@After中注销。重写getDiagnosticMessageIfBusy(),让超时消息更有用。 - **优先:**在
@Before中使用IdlingPolicies.setMasterPolicyTimeout(...),以应对 CI 上因 Agent 运行缓慢导致的偶发失败——它会统一延长 Espresso 和 Robolectric 的超时时间。
验证
- 除了文档中说明的 RenderThread/截图例外,所有测试方法中都不存在
Thread.sleep。 - 测试线程中的每次状态读取都已包装在
runOnIdle { … }中(搜索:grep -nP 'state\.\w+' src/androidTest)。 - 对 Compose 状态条件使用的每个
waitUntil { state.value … }都已迁移为mainClock.advanceTimeUntil { state.value … }。 - 每种“等待节点”逻辑都使用
waitUntilExactlyOneExists/waitUntilAtLeastOneExists/waitUntilDoesNotExist,而不是对fetchSemanticsNodes()手动编写waitUntil。 - 每个外部异步源都有一个
IdlingResource,并在@Before中注册、在@After中注销。 - 没有任何调用点在
autoAdvance == true时依赖hasPendingWork()。 - CI 运行连续完成 50 次迭代,且没有出现
AndroidComposeUiTestTimeoutException或ComposeTimeoutException偶发失败。
参考资料
- Android Developers — Compose 测试:https://developer.android.com/develop/ui/compose/testing
- Android Developers — Compose 测试速查表:https://developer.android.com/develop/ui/compose/testing-cheatsheet
- Espresso
IdlingPolicies参考:https://developer.android.com/reference/androidx/test/espresso/IdlingPolicies compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:174-260—waitForIdle、awaitIdle、waitUntil、runOnIdle、runOnUiThread、runWhenIdle、awaitAndRunWhenIdle、hasPendingWork。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/ComposeUiTest.kt:275-335— 实验性的waitUntilNodeCount/waitUntilExactlyOneExists/waitUntilAtLeastOneExists/waitUntilDoesNotExist扩展。compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResource.kt— 公共IdlingResource接口。compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/IdlingResourceRegistry.jvmAndAndroid.kt— 汇总用户注册的资源与 Compose 自身资源的注册表。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeIdlingResource.android.kt— Compose 自动注册的资源;第 109 行设有 100 帧上限。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/EspressoLink.android.kt—androidx.test.espresso.IdlingResource桥接。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt:885-908—waitUntil每隔 10 ms 进行一次墙上时钟轮询。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/AndroidSynchronization.android.kt—runOnUiThread通过Instrumentation.runOnMainSync提交FutureTask。compose/material3/material3/src/androidDeviceTest/kotlin/androidx/compose/material3/SnackbarHostTest.kt:77-88— 典型的rule.waitUntil { job.isCompleted }。- 同级技能:
../controlling-the-test-clock/SKILL.md—MainTestClock、帧模型、advanceTimeUntil。 - 同级技能:
../testing-animations-deterministically/SKILL.md— 使用暂停时钟的动画测试。 - 同级技能:
../../patterns/structuring-a-compose-test/SKILL.md— 空闲等待期间的 JUnit 生命周期。 - 同级技能:
../../debug/printing-the-semantics-tree/SKILL.md— “未找到节点”实际是查找器问题而非异步问题时的处理方法。