详解 Compose background 的重组陷阱

0 阅读7分钟

0.png

在 Jetpack Compose 的开发中,动画和状态的频繁变化是家常便饭。然而,如果不注意状态读取的时机,很容易陷入“性能陷阱”,导致频繁且不必要的重组(Recomposition),从而引发卡顿和掉帧。

本文将结合源码,从一个最常见的背景色动画陷阱入手,探讨为什么会发生过度重组,并给出利用 Compose 渲染阶段特性的解决方案。

background 的性能陷阱

我在一次开发过程中,意外的发现 background 造成的重组问题。

我的本意是想通过动画 progress 来进行颜色的变化,下面是一段示例代码:

@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)
    }
}

1.png

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

2.gif

Component Tree 中查看重组次数:

3.png

没错,175 次!

问题在哪

这里的 progress 是一个由 animateFloatAsState 驱动的 State,它的值在动画执行的数秒内会频繁改变(跟随屏幕刷新率,可能是每秒 60 次甚至 120 次)。

因为我们在组合阶段(Composition)直接读取了 progress 的值(即作为 background 的参数传入),Compose 引擎会将 BackgroundSection 标记为依赖于该状态。

progress 每一帧更新时,都会触发整个 BackgroundSection 的重组。

大量的重组会消耗宝贵的 CPU 资源,这也是很多 Compose 列表或复杂页面出现卡顿的罪魁祸首。

Compose 渲染三阶段

4.png

相信这张图各位已经看过无数次了,堪比经典的 Activity 生命周期图

Compose 的渲染管线分为三个主要阶段,它们按顺序执行:

  1. 组合(Composition):决定显示什么(What)。执行 @Composable 函数,构建并更新 UI 树(Slot Table/LayoutNode 树)。
  2. 布局(Layout):决定在哪里显示(Where)。遍历 UI 树,包含测量(Measure)子节点大小,和放置(Placement)子节点位置。
  3. 绘制(Draw):如何渲染到屏幕上(How)。将 UI 绘制到屏幕上。

优化的核心准则:状态读取发生在哪一个阶段,该状态发生改变时,就会从该阶段开始,重新执行该阶段及其之后的阶段。

那么想想我们上面的代码,实际上根本不需要 Composition 和 Layout,只需要在 Draw 的时候发生变化即可。

可是怎么才能在不触发重组的情况下,只触发绘制呢?

延迟执行的艺术

在 Kotlin 中,给函数传递一个普通参数(如 Modifier.background(color)),参数的值必须在函数调用时(即 Composition 阶段)就被计算出来。这就迫使你在重组的时候读取了状态。这也会导致状态的变更会发生重组。

而传递一个 Lambda(如 Modifier.drawBehind { ... }),你是将代码块传递给了底层,让 Compose 在它觉得合适的时候(Layout 或 Draw 阶段)再去执行。这就成功地把状态读取的行为“推迟”到了后面的阶段。

由此,我们就能精确控制界面的刷新范围,将每一帧的性能消耗压缩到极致。

好,让我们来看看解决方案:


@Composable
private fun DrawBehindSection() {
    //...
    Box(
        modifier = Modifier
            // ...
            // 在绘制阶段才读取 progress 状态
            .drawBehind { drawRect(lerp(Color.Red, Color.Blue, progress)) },
        contentAlignment = Alignment.Center
    ) {
        // ...
    }
}

5.png

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

6.gif

查看重组次数:

6.png

没错,1 次!

这一次其实是文字的变化导致的。

实战中,你可能还用过 offset,当你使用普通 offset 的时候,编译器其实会提示你,更换为带 Lambda 的 offset { ... }

offset

普通的 offset 代码是这样的:

Box(
    modifier = Modifier
        .size(60.dp)
        .background(Color.Green)
        // 陷阱:在组合阶段传入改变的值
        .offset(x = (progress * 120).dp, y = 0.dp), 
    contentAlignment = Alignment.Center
)

background 一样,由于传入的是具体的 Dp 原始数值,progress 的高频更新会导致组件在每一帧都发生重组。

7.png

类似的动画,在触发的时候,重组执行了 163 次。

优化方案则是将状态变化推迟到布局的放置阶段(Placement),优化后的代码如下,非常简单:

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 阶段)。

8.png

1 次!

当我们在这个 Lambda 内读取 progress 时,状态更新只会触发组件的重新放置(Re-placement),而不会引起重组(Recomposition)或重新测量(Re-measure)。相比于引发全局重组,它的性能损耗微乎其微。

为什么

我估计很多开发者会问了,这,什么情况,重组消失了?

嗯,准确来讲,是因为没有走第一个阶段 Composition!

drawBehind 接收一个 Lambda 表达式,这个 Lambda 会在绘制阶段被调用。当我们在 Lambda 内部读取 progress 时,Compose 运行时(Runtime)记录到的状态变更仅发生在 Draw 阶段。

这意味着,当 progress 更新时,Compose 只需要使当前的绘制逻辑失效,并直接重新执行绘制阶段的代码,完全跳过重组和重新布局阶段。由此,我们将性能开销降到了最低。

为了深入理解为什么 Modifier.background() 会引发重组,而 drawBehindoffset { ... } 不会,我们需要理解 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 阶段读取

比如在自定义 Layoutmeasure 闭包中读取状态。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 带来了巨大的开发便利,但也要求开发者对其底层的渲染管线有清晰的认知。

总结下来的黄金法则就是:状态在哪个阶段被读取,就会从哪个阶段开始引发更新。

  • 如果只是改变颜色、透明度等视觉效果,使用 drawBehindgraphicsLayer 将状态读取推迟到绘制阶段。
  • 如果只是改变位置,使用 Lambda 版本的 offset 将状态读取推迟到布局阶段。
  • 尽量避免在组合阶段读取高频变化的动画状态(比如直接传给普通的 Modifier)。

哦,对了,怎么用 graphicsLayer ?——看这里

掌握这些技巧,你的 Compose 应用将告别卡顿,丝滑如飞!