20、动画测试:animateFloatAsState测试、动画断言、帧控制
动画这东西,说实话,在 Compose 里既让人爱又让人恨。爱的是写起来太爽了,几行代码就能让 UI 活起来;恨的是——怎么测?
我记得刚接手一个项目时,有个点赞动画总是偶尔卡顿。开发说「我本地没问题啊」,测试说「我这边就是会闪一下」。两边都没撒谎,但问题就是复现不了。后来我写了个动画测试,把每一帧的 alpha 值都打印出来,才发现是某个状态重置时机不对。
所以今天我们就来聊聊,怎么用代码把动画「抓住」、怎么断言它跑对了、以及怎么控制帧的节奏。
20.1 animateFloatAsState 的测试思路
animateFloatAsState 是 Compose 里最常用的动画 API 之一。它把目标值的变化自动转换成平滑的过渡。但测试时有个麻烦——动画是异步的,默认情况下测试框架不会等它跑完。
我个人的习惯是:先搞清楚动画的「终点」是什么,然后直接断言终点值。中间过程要不要测?看场景。如果是关键路径(比如登录按钮的缩放动画),我会测中间状态;如果是装饰性动画,测终点就够了。
来看一个例子:
@Composable
fun AnimatedAlphaButton(visible: Boolean, onClick: () -> Unit) {
val alpha by animateFloatAsState(
targetValue = if (visible) 1f else 0f,
animationSpec = tween(durationMillis = 300)
)
Button(
onClick = onClick,
modifier = Modifier.alpha(alpha)
) {
Text("点击我")
}
}
测试这个动画,最直接的方式是:
@Test
fun testAnimatedAlphaButton_visibleToInvisible() {
composeTestRule.setContent {
var visible by remember { mutableStateOf(true) }
AnimatedAlphaButton(visible = visible) {
visible = false
}
}
// 初始状态:按钮可见
composeTestRule.onNodeWithText("点击我").assertExists()
// 触发隐藏
composeTestRule.onNodeWithText("点击我").performClick()
// 等待动画结束
composeTestRule.waitForIdle()
// 断言按钮不可见(alpha 为 0)
composeTestRule.onNodeWithText("点击我").assertDoesNotExist()
}
这里有个关键点:waitForIdle() 会等待所有动画完成。但如果你只关心终点,其实不用管中间过程。
animationSpec 替换成 tween(durationMillis = 1),或者直接用 spring(dampingRatio = 10f) 让动画瞬间完成。这样测试跑得飞快。
20.2 动画断言:不止是 assertExists
动画测试里,最常用的断言其实就几个:
| 断言方法 | 用途 | 示例场景 |
|---|---|---|
assertExists() |
元素存在 | 动画结束后按钮出现 |
assertDoesNotExist() |
元素不存在 | 动画结束后按钮消失 |
assertIsDisplayed() |
元素可见(alpha > 0) | 淡入动画进行中 |
assertLeftPositionInRoot() |
断言位置 | 位移动画终点坐标 |
assertWidthEquals() |
断言宽度 | 缩放动画终点尺寸 |
但说实话,这些断言只能测「最终状态」。如果你想测动画的「过程」,就得用帧控制。
assertIsDisplayed() 在 alpha 接近 0 时可能返回 true,因为 Compose 的可见性判断有个阈值(默认 0.01)。如果你要测「完全透明」,最好用 assertDoesNotExist() 或者自定义 Semantics 属性。
20.3 帧控制:让动画「慢动作」回放
为什么要控制帧?因为有些动画 bug 只在特定进度出现。比如一个旋转动画,在 45° 时文字会重叠;或者一个缩放动画,在 80% 时阴影渲染异常。
Compose 测试框架提供了 mainClock 来控制时间。你可以手动推进动画的每一帧:
@Test
fun testAnimatedAlphaButton_frameByFrame() {
composeTestRule.setContent {
var visible by remember { mutableStateOf(true) }
AnimatedAlphaButton(visible = visible) {
visible = false
}
}
// 冻结动画时钟
composeTestRule.mainClock.autoAdvance = false
// 触发隐藏
composeTestRule.onNodeWithText("点击我").performClick()
// 手动推进 100ms
composeTestRule.mainClock.advanceTimeBy(100)
// 此时 alpha 应该介于 0 和 1 之间
// 可以用自定义断言来检查
// 再推进 200ms(总共 300ms,动画结束)
composeTestRule.mainClock.advanceTimeBy(200)
// 断言最终状态
composeTestRule.onNodeWithText("点击我").assertDoesNotExist()
}
这里的关键是 autoAdvance = false。关闭自动推进后,时间就完全由你掌控了。你可以每 16ms 推进一帧(60fps),也可以一次推进 100ms。
我曾经用这个技巧抓到一个很隐蔽的 bug:一个位移动画在 200ms 时位置计算错误,导致按钮「跳」了一下。正常跑动画根本看不出来,但逐帧推进时,那个跳变非常明显。
mainClock.autoAdvance = false冻结时间mainClock.advanceTimeBy(ms)手动推进- 推进后立即触发 recomposition,可以断言中间状态
- 记得最后推进到动画结束,否则测试会卡住
20.4 自定义动画断言
有时候内置断言不够用。比如你想断言某个 Composable 的 alpha 值是不是 0.5。这时候可以自定义 Semantics 属性:
// 在 Composable 中暴露 alpha 值
Modifier.semantics {
testTag = "alphaButton"
// 自定义属性
this[AlphaKey] = alpha
}
// 定义 Key
val AlphaKey = SemanticsPropertyKey<Float>("AlphaKey")
var SemanticsPropertyReceiver.alpha by AlphaKey
然后在测试中读取:
@Test
fun testAlphaAtHalfway() {
composeTestRule.setContent { /* ... */ }
composeTestRule.mainClock.autoAdvance = false
composeTestRule.onNodeWithText("点击我").performClick()
composeTestRule.mainClock.advanceTimeBy(150) // 一半时间
val alpha = composeTestRule
.onNodeWithTag("alphaButton")
.fetchSemanticsNode()
.config[AlphaKey]
assertTrue(alpha in 0.4f..0.6f, "Alpha should be around 0.5 at halfway")
}
说实话,自定义 Semantics 属性有点重。我一般只在关键动画上这么做。大多数情况下,用 assertIsDisplayed() 和 assertDoesNotExist() 就够了。
20.5 知识体系总览
下面这张图总结了动画测试的核心逻辑:
20.6 避坑指南
最后分享几个我踩过的坑:
- 动画没跑完就断言:这是最常见的错误。记得调用
waitForIdle()或者手动推进到结束。 - 多个动画同时进行:如果页面里有多个
animateFloatAsState,它们可能互相影响。我建议每个动画单独测,或者用testTag区分。 - 动画 Spec 用了无限循环:比如
repeatable(RepeatMode.Reverse)。这种情况下waitForIdle()永远不会结束。要么在测试中替换 Spec,要么用advanceTimeBy手动控制。 - Compose 版本差异:不同版本的 Compose 动画引擎行为略有不同。我建议在 CI 上跑动画测试时,锁定 Compose 版本。
嗯,动画测试其实没那么玄乎。说白了就是三件事:等它跑完、看它对不对、不行就一帧一帧查。掌握了这三个套路,大部分动画 bug 都能在测试阶段揪出来。
公众号:蓝海资料掘金营,微信deep3321