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() 会等待所有动画完成。但如果你只关心终点,其实不用管中间过程。

小技巧: 如果动画时长很长(比如 5 秒),你可以在测试中把 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 知识体系总览

下面这张图总结了动画测试的核心逻辑:

动画测试知识体系 animateFloatAsState 动画断言 帧控制 终点值断言 waitForIdle() assertExists / assertDoesNotExist assertIsDisplayed 自定义 Semantics 属性 autoAdvance = false advanceTimeBy(ms) 逐帧推进(16ms) 核心原则:先测终点,再测过程,最后逐帧排查

20.6 避坑指南

最后分享几个我踩过的坑:

  • 动画没跑完就断言:这是最常见的错误。记得调用 waitForIdle() 或者手动推进到结束。
  • 多个动画同时进行:如果页面里有多个 animateFloatAsState,它们可能互相影响。我建议每个动画单独测,或者用 testTag 区分。
  • 动画 Spec 用了无限循环:比如 repeatable(RepeatMode.Reverse)。这种情况下 waitForIdle() 永远不会结束。要么在测试中替换 Spec,要么用 advanceTimeBy 手动控制。
  • Compose 版本差异:不同版本的 Compose 动画引擎行为略有不同。我建议在 CI 上跑动画测试时,锁定 Compose 版本。

嗯,动画测试其实没那么玄乎。说白了就是三件事:等它跑完、看它对不对、不行就一帧一帧查。掌握了这三个套路,大部分动画 bug 都能在测试阶段揪出来。


公众号:蓝海资料掘金营,微信deep3321