关于 controlling-the-test-clock
面向 Android Compose 测试开发者的参考技能,说明 MainTestClock 如何驱动帧、重组、动画和协程任务。内容解释时间推进规则及 v1 到 v2 测试方式的差异。
主要功能
- 01讲解 MainTestClock 与调度器
- 02说明 16 ms 帧周期
- 03解释帧时长的向上取整
- 04介绍自动与手动时钟控制
- 05说明 v2 中排队任务的处理
适用场景
- 01排查推进时间后的帧数异常
- 02将 Compose 测试从 v1 迁移到 v2
- 03推进测试直至满足 Compose 状态条件
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ skydoves/ android-testing-skills --skill controlling-the-test-clock安装后在 Claude Code 中输入/controlling-the-test-clock确认已加载。
详细介绍
控制测试时钟——帧、时间与 Recomposer
在 Compose 测试中,MainTestClock 是驱动重组、动画和 LaunchedEffect 的唯一旋钮。本技能介绍它的接口、其背后的 16 ms 帧模型,以及 autoAdvance 如何将测试从“框架驱动时钟”切换为“测试驱动时钟”。动画方案和空闲/等待方案分别位于相邻技能中;本技能会介绍它们共同依赖的机制。
何时使用本技能
- 开发者询问
mainClock.advanceTimeBy(...)实际会做什么、为什么会向上取整,或者为什么动画看起来差一帧。 - 开发者正从
androidx.compose.ui.test.junit4.createComposeRule(v1)迁移到androidx.compose.ui.test.junit4.v2.createComposeRule(v2),并且测试现在需要显式调用mainClock.runCurrent()或advanceTimeBy(0)。 - 开发者提到
MainTestClock、TestCoroutineScheduler、TestMonotonicFrameClock、ComposeTimeoutException、advanceTimeUntil,或“测试时钟与墙上时钟”。 - 开发者正在编写一个需要指定帧数的测试,但不确定
advanceTimeBy(5)会产生零帧、一帧还是多帧。 - 开发者询问为什么经过一帧后,动画的
playTime仍为0。
何时不要使用本技能
- 目标是编写确定性动画测试(
autoAdvance = false方案)。请使用../testing-animations-deterministically/SKILL.md。 - 目标是等待
IdlingResource、异步工作或“UI 已稳定”。请使用../synchronizing-with-idle/SKILL.md。 - 目标是了解更高层级的测试结构(规则接线、
setContent、JUnit 生命周期)。请使用../../patterns/structuring-a-compose-test/SKILL.md。 - 目标是在
createComposeRule和runComposeUiTest之间进行选择。请使用../../setup/choosing-test-rule-vs-runtest/SKILL.md。
前提条件
- 测试源集包含
androidx.compose.ui:ui-test和androidx.compose.ui:ui-test-junit4(或不依赖 JUnit 的runComposeUiTest)。 - 使用
createComposeRule()获取ComposeContentTestRule,或使用runComposeUiTest { … }获取ComposeUiTest接收器。 - **推荐:**使用 v2 入口点(
androidx.compose.ui.test.junit4.v2.createComposeRule、androidx.compose.ui.test.v2.runComposeUiTest)。v1 形式已标注为@Deprecated(level = WARNING)(skydoves 热辣观点 #6)。
心智模型
MainTestClock 不是 MonotonicFrameClock——它是一个接口,其推进方法会驱动 kotlinx.coroutines.test.TestCoroutineScheduler。Recomposer 会从 TestMonotonicFrameClock 读取帧;它的 withFrameNanos 是通过针对同一个调度器调用 delay(frameDelayMillis) 实现的(TestMonotonicFrameClock.jvmAndAndroid.kt:105-117)。推进调度器会触发 delay,进而产生一帧,运行 onPerformTraversals,并触发每个 compose 根节点的测量和布局。
MainTestClock.advanceTimeBy(ms)
│
▼ AbstractMainTestClock.advanceScheduler
TestCoroutineScheduler.advanceTimeBy(ms) → runCurrent()
│
▼ delay(frameDelayMillis) inside TestMonotonicFrameClock fires
performFrame() ── awaiters (withFrameNanos) resume FIRST
│ ── then onPerformTraversals → recomposition → measure/layout
▼
Recomposer applies snapshot writes
因此,设置 autoAdvance = false 就足以冻结整个 UI:除非显式调用 mainClock.advance*,否则不会有其他操作推进调度器(MainTestClock.kt:43-48 KDoc)。
MainTestClock 的接口
每个条目均来自 compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt。
| 成员 | 行为 |
|---|---|
currentTime: Long | 测试时钟的时间,单位为毫秒。不是墙上时钟时间。读取 scheduler.currentTime(AbstractMainTestClock.kt:35-36)。 |
scheduler: TestCoroutineScheduler | 重组器和时钟共用的调度器。在外部调用 runCurrent() 时很有用(MainTestClock.kt:84-99)。 |
autoAdvance: Boolean | 默认值为 true。为 true 时,框架会在 waitForIdle/waitUntil 期间自动推进时钟。为 false 时,只有显式调用 advanceTime* 才会推进时钟(MainTestClock.kt:101-115)。 |
advanceTimeByFrame() | 精确推进一帧(Android/Desktop 上为 16 ms)。实现方式为 advanceScheduler(frameDelayMillis)(AbstractMainTestClock.kt:40-42)。 |
advanceTimeBy(milliseconds, ignoreFrameDuration = false) | 会向上取整到帧时长的最接近倍数,除非 ignoreFrameDuration = true(AbstractMainTestClock.kt:44-52,MainTestClock.kt:120-145)。 |
advanceTimeUntil(timeoutMillis = 1_000, condition) | 逐帧推进,直到 condition() 为 true。超时时间使用测试时钟。超时后抛出 ComposeTimeoutException(MainTestClock.kt:147-167,AbstractMainTestClock.kt:54-72)。 |
runCurrent() 在 AbstractMainTestClock 中公开(AbstractMainTestClock.kt:96-98)——它会执行当前时间到期的所有任务,但不会推进时钟。在 v2 中切换状态后,如果必须先运行已排队的任务再进行下一次断言,就可以使用它。
帧模型
- 帧时长:
DefaultFrameDelay = 16_000_000Lns = 16 ms(TestMonotonicFrameClock.jvmAndAndroid.kt:33)。 performFrame内部每帧的执行顺序:- 所有
withFrameNanos等待者都会以新的帧时间恢复执行。动画在此运行。 - 执行
onPerformTraversals——组合 + 测量 + 布局。 - 分发等待者排队的恢复执行任务。
- 所有
引自 MainTestClock.kt:49-60:
如果同时存在待处理的重组和等待帧时间的动画,推进此时钟会_先_向动画发送新的帧时间,_然后_执行重组。……由于动画在重组_之前_收到帧时间,因此在通过切换状态变量启动动画后的第一帧中,动画不会获得其开始时间。
最后这句话就是为什么每个动画测试都会在 setContent 后立即额外调用一次 advanceTimeByFrame() 来“启动”动画:切换状态的那一帧会安排动画;下一帧会将其播放时间初始化为 0(skydoves 的独到见解 #3)。
工作流
1. 读取 currentTime 只能用于观察,不能将其用作同步机制
// RIGHT — assert the clock advanced as much as the test asked
val before = rule.mainClock.currentTime
rule.mainClock.advanceTimeBy(durationMillis = 320)
assertEquals(320, rule.mainClock.currentTime - before)
// WRONG
while (rule.mainClock.currentTime < target) { /* spin */ }
// WRONG because: nothing in this loop drives the scheduler. The loop runs forever.
2. 选择合适的推进方法
| 目标 | 调用 |
|---|---|
| “恰好运行一帧。” | mainClock.advanceTimeByFrame() |
| “运行 N 帧。” | mainClock.advanceTimeBy(N * 16L) |
| “运行已知时长的动画。” | mainClock.advanceTimeBy(durationMillis = 300)(向上取整到下一个 16 ms 边界) |
| “推进时间,但不生成新帧。” | mainClock.advanceTimeBy(milliseconds = 8, ignoreFrameDuration = true) |
| “等待 Compose 状态条件变为 true。” | mainClock.advanceTimeUntil(timeoutMillis = 5_000) { state.value == … } |
3. 在 v2 中更改状态后,先运行待处理任务再断言
v2 的 createComposeRule 使用 StandardTestDispatcher 进行组合;任务会排队,而不会立即执行。如果 autoAdvance = false,waitForIdle 和节点查询都不会清空这些任务。解决方法是调用 mainClock.runCurrent() 或 mainClock.advanceTimeBy(0):
rule.mainClock.autoAdvance = false
rule.runOnUiThread { uiState = newValue }
// Under v2, the snapshot write is queued. Drain it before asserting.
rule.mainClock.advanceTimeByFrame()
rule.onNodeWithTag("status").assertTextEquals("Updated")
如果 isStandardTestDispatcherSupportEnabled 为 true,AbstractMainTestClock.advanceTimeUntil 会先调用 scheduler.runCurrent()(AbstractMainTestClock.kt:59-61)。
4. 将 ComposeTimeoutException 视为对条件的判定,而不是对时钟的判定
当测试时钟推进超过 timeoutMillis,但 condition() 仍未返回 true 时,advanceTimeUntil 会抛出 ComposeTimeoutException("Condition still not satisfied after $timeoutMillis ms")(AbstractMainTestClock.kt:65-68)。此异常意味着由时钟驱动的工作无法满足该条件——通常是缺少快照写入、LaunchedEffect 从未启动,或在错误的线程上读取了状态。
模式
模式:向上取整
// WRONG
rule.mainClock.advanceTimeBy(milliseconds = 5)
// expectation: "1 frame ran"
// WRONG because: the framework rounds 5 ms UP to the next 16 ms multiple — i.e. 16 ms.
// One frame did run, but the developer is reading the API as if it could advance by 5 ms.
// Use ignoreFrameDuration = true if sub-frame stepping is genuinely required.
// RIGHT
rule.mainClock.advanceTimeByFrame() // exactly 16 ms, exactly one frame
// or
rule.mainClock.advanceTimeBy(milliseconds = 5, ignoreFrameDuration = true) // 5 ms, 0 frames
模式:动画的“启动”帧
// WRONG
rule.mainClock.autoAdvance = false
rule.setContent { Crossfade(showFirst) { /* … */ } }
rule.mainClock.advanceTimeBy(300) // expect animation finished
rule.onNodeWithText("Second").assertExists()
// WRONG because: the toggle frame only schedules the animation; the animation's playTime is 0
// going into the next frame. The first 16 ms of the 300 are eaten by setup.
// RIGHT — see CrossfadeTest.kt:70-93
rule.mainClock.autoAdvance = false
rule.setContent { Crossfade(showFirst) { /* … */ } }
rule.mainClock.advanceTimeByFrame() // kick off
rule.mainClock.advanceTimeBy(durationMillis = 300) // step
rule.onNodeWithText("Second").assertExists()
模式:从 v1 迁移到 v2 后,状态读取仍然过时
// WRONG (v2)
val rule = androidx.compose.ui.test.junit4.v2.createComposeRule()
@Test fun foo() {
var counter by mutableStateOf(0)
rule.setContent { Text(counter.toString()) }
rule.runOnUiThread { counter = 1 }
rule.onNodeWithText("1").assertExists()
// WRONG because: with StandardTestDispatcher, the snapshot apply is queued.
// No frame has been produced. The composition still reads `0`.
}
// RIGHT (v2)
@Test fun foo() {
var counter by mutableStateOf(0)
rule.setContent { Text(counter.toString()) }
rule.runOnUiThread { counter = 1 }
rule.mainClock.advanceTimeByFrame()
rule.onNodeWithText("1").assertExists()
}
模式:advanceTimeUntil 节省实际时间
// LESS PREFERRED — wall clock
rule.waitUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
// PREFERRED — test clock; framework iterates frame-by-frame and the timeout is in
// test clock time (no real wall-clock burn between iterations).
rule.mainClock.advanceTimeUntil(timeoutMillis = 5_000) { state.value == Phase.Done }
当 autoAdvance == true 时,两种方式都会推进测试时钟:waitUntil 每次迭代都会调用 mainClock.advanceTimeByFrame() 并执行 Thread.sleep(10)(ComposeUiTest.android.kt:899-902);advanceTimeUntil 则只按帧推进测试时钟(AbstractMainTestClock.kt:54-72)。区别在于超时来源——实际时间还是测试时钟——以及测试时钟方式没有 Thread.sleep 开销。
强制规则
- 必须将
MainTestClock.currentTime仅视为测试时钟时间。将其与System.currentTimeMillis()比较毫无意义。 - 必须在意图是“恰好一帧”时调用
advanceTimeByFrame()(而非advanceTimeBy(16))。这能清楚表达意图,也避免考虑舍入问题。 - 必须了解
advanceTimeBy(milliseconds = N)会向上取整到下一个frameDelayMillis的整数倍(Android/Desktop 上为 16 ms)。传入ignoreFrameDuration = true可禁用舍入(AbstractMainTestClock.kt:44-52)。 - 必须在 v2(
StandardTestDispatcher)下切换状态后、读取结果 UI 状态前,调用advanceTimeByFrame()(或advanceTimeBy(0)/runCurrent())。v1 的UnconfinedTestDispatcher会立即分发任务,无需此操作;v2 则需要(skydoves 热点观点 #6)。 - 不得将
advanceTimeUntil用于 Compose 快照系统之外的条件(例如Job.isCompleted、OkHttp回调)。这些条件不会因测试时钟走动而变为真——此时应使用waitUntil(实际时间)或IdlingResource。参见../synchronizing-with-idle/SKILL.md。 - 不得混淆
MainTestClock和MonotonicFrameClock。它们是不同类型。MainTestClock暴露的调度器会驱动重组器使用的TestMonotonicFrameClock。 - **优先使用:**v2 入口点,使分发器与
kotlinx.coroutines.test.runTest语义一致。v1 的createComposeRule/runComposeUiTest形式已弃用,并标记为WARNING。 - **优先使用:**只要等待的条件可通过 Compose 状态观察到,就优先使用
advanceTimeUntil而非waitUntil。测试时钟更快且确定;实际时间方式每次迭代会休眠 10 ms。
验证
- 所有调用点都使用
advanceTimeByFrame()表示“一帧”;仅当时长本身有意义时才使用advanceTimeBy(...)。 - 不存在未附注释说明意图或未设置
ignoreFrameDuration = true的advanceTimeBy(milliseconds = X)调用,其中X < 16。 - 从 v1 迁移到 v2 后,每次状态变更后的断言之前,都调用
mainClock.advanceTimeByFrame()/advanceTimeBy(0)/runCurrent(),或依靠runOnIdle { … }清空排队任务。 -
advanceTimeUntil的调用点仅读取 Compose 状态——不读取Job.isCompleted,也不读取外部计数器。 - 时钟测试中出现
ComposeTimeoutException时,应将其视为缺少快照写入或在错误线程上读取状态,而不是“提高超时时间”的理由。
参考资料
- Android Developers — Compose 测试:https://developer.android.com/develop/ui/compose/testing
- Android Developers — 测试动画:https://developer.android.com/develop/ui/compose/animation/testing
- Compose UI 发行说明:https://developer.android.com/jetpack/androidx/releases/compose-ui
compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/MainTestClock.kt— 公共接口和ComposeTimeoutException。compose/ui/ui-test/src/commonMain/kotlin/androidx/compose/ui/test/AbstractMainTestClock.kt— 舍入规则、runCurrent、advanceScheduler。compose/ui/ui-test/src/jvmAndAndroidMain/kotlin/androidx/compose/ui/test/TestMonotonicFrameClock.jvmAndAndroid.kt—DefaultFrameDelay = 16_000_000L,基于delay的帧循环。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/MainTestClockImpl.android.kt— Android 实现。compose/ui/ui-test/src/androidMain/kotlin/androidx/compose/ui/test/ComposeUiTest.android.kt— 第 885-905 行中,waitUntil的 10 ms 实时时钟休眠。- 同级技能:
../testing-animations-deterministically/SKILL.md— 完整的autoAdvance = false配方。 - 同级技能:
../synchronizing-with-idle/SKILL.md— 如何在waitForIdle、waitUntil和advanceTimeUntil之间进行选择。