
在 Jetpack Compose 的开发中,动画和状态的频繁变化是家常便饭。然而,如果不注意状态读取的时机,很容易陷入"性能陷阱",导致频繁且不必要的重组(Recomposition),从而引发卡顿和掉帧。
本文将结合源码,从一个最常见的背景色动画陷阱入手,探讨为什么会发生过度重组,并给出利用 Compose 渲染阶段特性的解决方案。
background 的性能陷阱
我在一次开发过程中,意外的发现 background 造成的重组问题。
我的本意是想通过动画 progress 来进行颜色的变化,下面是一段示例代码:
kotlin
@Composable
private fun BackgroundSection() {
var animationStarted by remember { mutableStateOf(false) }
// 动画状态定义
val progress by animateFloatAsState(
targetValue = if (animationStarted) 1f else 0f,
animationSpec = tween(durationMillis = 2000)
)
SectionTitle("1. Using background")
Button(
onClick = { animationStarted = !animationStarted },
modifier = Modifier.fillMaxWidth()
) {
Text(
text = if (animationStarted) "Stop" else "Start",
fontSize = 14.sp
)
}
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.size(200.dp)
.background(lerp(Color.Red, Color.Blue, progress)), // 陷阱!
contentAlignment = Alignment.Center
) {
Text(text = "background", color = Color.White, fontSize = 14.sp)
}
}

初看之下没什么问题,那我们使用 Layout Inspector 来看下当前点击开始按钮的时候,发生了什么。

在 Component Tree 中查看重组次数:

没错,175 次!
问题在哪
这里的 progress 是一个由 animateFloatAsState 驱动的 State,它的值在动画执行的数秒内会频繁改变(跟随屏幕刷新率,可能是每秒 60 次甚至 120 次)。
因为我们在组合阶段(Composition)直接读取了 progress 的值(即作为 background 的参数传入),Compose 引擎会将 BackgroundSection 标记为依赖于该状态。
当 progress 每一帧更新时,都会触发整个 BackgroundSection 的重组。
大量的重组会消耗宝贵的 CPU 资源,这也是很多 Compose 列表或复杂页面出现卡顿的罪魁祸首。
Compose 渲染三阶段

相信这张图各位已经看过无数次了,堪比经典的 Activity 生命周期图
Compose 的渲染管线分为三个主要阶段,它们按顺序执行:
- 组合(Composition) :决定显示什么(What)。执行
@Composable函数,构建并更新 UI 树(Slot Table/LayoutNode 树)。 - 布局(Layout):决定在哪里显示(Where)。遍历 UI 树,包含测量(Measure)子节点大小,和放置(Placement)子节点位置。
- 绘制(Draw):如何渲染到屏幕上(How)。将 UI 绘制到屏幕上。
优化的核心准则:状态读取发生在哪一个阶段,该状态发生改变时,就会从该阶段开始,重新执行该阶段及其之后的阶段。
那么想想我们上面的代码,实际上根本不需要 Composition 和 Layout,只需要在 Draw 的时候发生变化即可。
可是怎么才能在不触发重组的情况下,只触发绘制呢?
延迟执行的艺术
在 Kotlin 中,给函数传递一个普通参数(如 Modifier.background(color)),参数的值必须在函数调用时(即 Composition 阶段)就被计算出来。这就迫使你在重组的时候读取了状态。这也会导致状态的变更会发生重组。
而传递一个 Lambda(如 Modifier.drawBehind { ... }),你是将代码块传递给了底层,让 Compose 在它觉得合适的时候(Layout 或 Draw 阶段)再去执行。这就成功地把状态读取的行为"推迟"到了后面的阶段。
由此,我们就能精确控制界面的刷新范围,将每一帧的性能消耗压缩到极致。
好,让我们来看看解决方案:
kotlin
@Composable
private fun DrawBehindSection() {
//...
Box(
modifier = Modifier
// ...
// 在绘制阶段才读取 progress 状态
.drawBehind { drawRect(lerp(Color.Red, Color.Blue, progress)) },
contentAlignment = Alignment.Center
) {
// ...
}
}

在这里,我们使用了 Modifier.drawBehind { ... } 替代了直接调用 Modifier.background()。那么效果百闻不如一见:

查看重组次数:

没错,1 次!
这一次其实是文字的变化导致的。
实战中,你可能还用过 offset,当你使用普通 offset 的时候,编译器其实会提示你,更换为带 Lambda 的 offset { ... }。
offset
普通的 offset 代码是这样的:
kotlin
Box(
modifier = Modifier
.size(60.dp)
.background(Color.Green)
// 陷阱:在组合阶段传入改变的值
.offset(x = (progress * 120).dp, y = 0.dp),
contentAlignment = Alignment.Center
)
和 background 一样,由于传入的是具体的 Dp 原始数值,progress 的高频更新会导致组件在每一帧都发生重组。

类似的动画,在触发的时候,重组执行了 163 次。
优化方案则是将状态变化推迟到布局的放置阶段(Placement),优化后的代码如下,非常简单:
kotlin
Box(
modifier = Modifier
.size(60.dp)
.background(Color.Blue)
// 优化:使用 Lambda 版本的 offset
.offset {
// 在布局的 Placement 阶段才读取 progress 状态
IntOffset(x = (progress * 120 * density).toInt(), y = 0)
},
contentAlignment = Alignment.Center
)
Modifier.offset { ... } 接收一个返回 IntOffset 的 Lambda。这个 Lambda 是在 Compose 的 Layout 阶段中执行的(具体一点就是 Layout 中的 Placement 阶段)。

1 次!
当我们在这个 Lambda 内读取 progress 时,状态更新只会触发组件的重新放置(Re-placement),而不会引起重组(Recomposition)或重新测量(Re-measure)。相比于引发全局重组,它的性能损耗微乎其微。
为什么
我估计很多开发者会问了,这,什么情况,重组消失了?
嗯,准确来讲,是因为没有走第一个阶段 Composition!
drawBehind 接收一个 Lambda 表达式,这个 Lambda 会在绘制阶段 被调用。当我们在 Lambda 内部读取 progress 时,Compose 运行时(Runtime)记录到的状态变更仅发生在 Draw 阶段。
这意味着,当 progress 更新时,Compose 只需要使当前的绘制逻辑失效,并直接重新执行绘制阶段的代码,完全跳过重组和重新布局阶段。由此,我们将性能开销降到了最低。
为了深入理解为什么 Modifier.background() 会引发重组,而 drawBehind 或 offset { ... } 不会,我们需要理解 Compose 框架底层的快照状态系统 和它的依赖追踪机制。
快照状态与依赖追踪
Compose 编译器和运行时会自动追踪状态被读取的位置。
当你使用 val progress by animateFloatAsState(...) 时,progress 本质上是一个 State<Float>。当你读取 progress.value(或者通过委托属性读取 progress)时,Compose 运行时会记录下:"是谁在读取这个状态?"
这个"谁",指的是当前正在执行的作用域(Scope)。
Composition 阶段读取
当直接写 Modifier.background(Color.Red.copy(alpha = progress)) 时,progress 的读取发生在普通的 @Composable 函数体中。
这个函数的作用域是 RecomposeScope。Compose 会记录:示例代码中 BackgroundSection 这个 Composable 函数依赖于 progress。
结果:当 progress 每帧更新时,Compose 别无选择,只能将示例代码中 BackgroundSection 标记为失效(Invalidate),并重新执行一遍整个函数(Recomposition)。
然后顺着管线,继续执行 Layout 阶段(因为可能有新的节点产生),最后执行 Draw 阶段。
Measure 阶段读取
比如在自定义 Layout 的 measure 闭包中读取状态。Compose 会记录:该节点的测量的过程依赖于此状态。
结果:状态更新时,跳过 Composition 阶段,直接从该节点的 Measure 阶段开始,重新测量、重新 Placement、最后重绘 Draw。
Placement 阶段读取
例如上面示例的 offset { ... }。
Modifier.offset { IntOffset(x = progress, y = 0) } 接收的是一个 lambda。这个 lambda 并不会在 Composable 函数执行时(组合阶段)被调用,而是在 Layout 阶段的第二步------Placement 时才被调用。
它的作用域是 PlacementScope。Compose 记录:只有该节点的放置过程依赖于 progress。
结果:当 progress 更新时,Compose 甚至不需要重新测量(因为大小没变),直接从 Placement 阶段开始,重新计算位置,然后 Draw。这比普通的重组快得多。
Draw 阶段读取
Modifier.drawBehind { drawRect(color = ..., alpha = progress) } 的 lambda 是在管线的最后一步------Draw 阶段调用的。
它的作用域是 DrawScope。Compose 记录:该节点的绘制指令依赖于 progress。
结果:当 progress 更新时,Compose 直接跳过 Composition 和 Layout 阶段(不用重组函数,不用测量大小,不用计算位置),直接重新调用这个绘制 lambda。这是极其轻量级的操作。
你应该已经发现了规律:优化方案(drawBehind { ... }、offset { ... })都使用了 Lambda 闭包。
总结
Compose 的声明式 UI 带来了巨大的开发便利,但也要求开发者对其底层的渲染管线有清晰的认知。
总结下来的黄金法则就是:状态在哪个阶段被读取,就会从哪个阶段开始引发更新。
- 如果只是改变颜色、透明度等视觉效果,使用
drawBehind或graphicsLayer将状态读取推迟到绘制阶段。 - 如果只是改变位置,使用 Lambda 版本的
offset将状态读取推迟到布局阶段。 - 尽量避免在组合阶段读取高频变化的动画状态(比如直接传给普通的 Modifier)。
哦,对了,怎么用 graphicsLayer ?------看这里。
掌握这些技巧,你的 Compose 应用将告别卡顿,丝滑如飞!