我想让同事知道我很懂 Compose 怎么办?

0 阅读6分钟

0.png

前几天,我的好同事彭于晏读完我前面几篇关于 Compose 的文章后,准备在组内好好推广一下,做一次分享(毕竟也有 KPI)。

他问了我一个很真实的问题:有没有什么技巧,讲出来能让大家觉得“这个人确实懂 Compose,而且还挺厉害”?

我想了想,答案还真有。

不是背 API,也不是堆概念,而是能说清楚:Compose 为什么会重组,状态在哪里被读取,以及如何控制它的影响范围

好,正文开始!

Jetpack Compose 遵循"状态变更 → 重组 → UI 更新"的声明式范式。

如果状态处理不当会引发不必要的重组,直接拖累页面性能。

本文通过错误示例与正确示例的对比,介绍三种减少重组的实战技巧。

正确处理快速变化

❌ 错误示例

val showTopBar = lazyListState.firstVisibleItemIndex > 20

有时我们会从一个状态派生出另一个状态。

如果源状态变化频率很高(比如滚动中的 LazyListState),就会导致重组过于频繁。Android Studio 也会对此显示警告。

✅ 正确示例

// 使用 derivedStateOf  
val showTopBar by remember {  
    derivedStateOf { lazyListState.firstVisibleItemIndex > 20 }  
}  
  
// 使用 snapshotFlow  
var showTopBar by remember { mutableStateOf(false) }  
  
LaunchedEffect(lazyListState) {  
    snapshotFlow { lazyListState.firstVisibleItemIndex }  
        .map { it > 20 }  
        .distinctUntilChanged()  
        .collect { visible -> showTopBar = visible }  
}

两种方案各有所长:

  • derivedStateOf:在内部定义计算代码块,源状态变化时自动产出派生结果,适合纯粹的状态派生场景。
  • snapshotFlow:将状态转为 Flow,可自定义数据处理管线(mapdistinctUntilChanged 等),适合需要异步处理或触发副作用(如 Toast)的场景。

选择原则:

  • 如果只是通过一个状态生成另一个值,中间不需要额外的处理过程或管线,用 derivedStateOf
  • 如果需要处理副作用、接入处理管线,或存在更复杂的中间处理过程,用 snapshotFlow

避开组合阶段

❌ 错误示例

Text(  
    modifier = Modifier  
        .align(Alignment.Center)  
        .offset(y = 60.dp * animatedValue)  // 组合阶段读取状态  
        .scale(animatedValue),              // 组合阶段读取状态  
    text = "Animate"  
)

使用状态配合 Modifier 动态改变位置、大小、透明度是常见做法。

但如果在组合阶段(Composition)直接读取快速变化的状态(如动画值、滚动偏移量),每次状态更新都会迫使 Compose 重新执行整个 Composable 函数,引发高频重组。

✅ 正确示例

Text(  
    modifier = modifier  
        .offset { IntOffset(0, (60 * animatedValue).toInt()) } // 布局阶段读取  
        .drawWithContent { // 绘制阶段读取  
              scale(scale = animatedValue) {  
                  this@drawWithContent.drawContent()  
              }  
         },  
    text = "Animate"  
)  
  
// 或者更推荐的图形变换方式:  
Text(  
    modifier = modifier  
        .offset { IntOffset(0, (60 * animatedValue).toInt()) } // 布局阶段读取  
        .graphicsLayer { // 绘制阶段读取  
            scaleX = animatedValue  
            scaleY = animatedValue  
        },  
   text = "Animate"  
)

这里的核心思路是阶段降级:将状态的读取从“组合阶段”推迟到后续的渲染阶段。

  • 布局阶段(Layout) 读取:使用 Lambda 形式的 Modifier(如 offset { }
  • 绘制阶段(Draw) 读取:使用 drawWithContentgraphicsLayer { }

为什么阶段降级能提升性能?

Compose 的渲染流水线分为三个严格的先后阶段:组合(Composition)→ 布局(Layout)→ 绘制(Draw)

  • 组合阶段负责根据状态构建 UI 树。如果在这里读取状态,状态变化必定导致 UI 树重建(即触发重组)。
  • 布局阶段仅安排组件位置,绘制阶段仅负责将像素渲染到屏幕。

当我们在 offset { }graphicsLayer { } 的 Lambda 内部读取状态时,Compose 会智能地跳过组合阶段。状态变化时,只会触发对应组件的重新布局或重新绘制,从而彻底省去了最耗时的重组开销。

此法在此文中亦有提及。

缩小重组作用域

如果说上一个技巧是解决“在什么时间点读取”的问题,那么这个技巧则是解决“在 UI 树的什么位置读取”的问题。

⚠️ 欠佳示例

@Composable  
private fun Parent() {  
    var count by remember { mutableIntStateOf(0) }  
    
    // 父组件直接读取了 count 状态
    // count 变化时,Parent 会发生完整重组,连带其内部的其他子组件也可能受影响
    ChildA(count = count)  
    OtherStaticChild() 
}  
  
@Composable  
private fun ChildA(count: Int) {  
    Text("求关注 RockByte 公众号: $count")  
}

在 Compose 中,重组的最小单位是 Composable 函数。Compose 会精准追踪每个状态被哪些 Composable 读取了。

在上述代码中,由于 count 是在 Parent 的作用域内被读取(作为参数传递给 ChildA),一旦 count 发生变化,Parent 就会被标记为需要重组。这会导致 Parent 内部的 OtherStaticChild() 等无关组件也面临被重新评估的风险。

✅ 优化示例

@Composable  
fun Parent() {  
    val count = remember { mutableStateOf(0) }  
  
    // 传递 Lambda,Parent 并没有真正读取状态的值
    ChildB(countProvider = { count.value })  
    OtherStaticChild()
}  
  
@Composable  
fun ChildB(countProvider: () -> Int) {  
    // 状态的读取被推迟到了 ChildB 的内部
    Text("求关注 RockByte 公众号: ${countProvider()}") 
}  

通过将直接传值改为传递 Lambda,我们成功地将状态的读取动作从父组件“下放”到了真正需要它的子组件中。 此时,Parent 只是传递了一个函数引用,并没有调用 count.value。当状态变化时,只有调用了该 Lambda 的 ChildB 会发生重组,Parent 及其它兄弟组件则安然无恙。

避坑指南:不要过度设计

上述代码并无大问题(甚至可以说没有问题!),这里只是展示一种防止大范围重组的技巧。当然还有一种方法是 ChildB 内部自己处理状态的变化,不当做一个可以移植的组件。

官方架构指南的基石是状态提升(State Hoisting),传递纯数据(如 Int)能让组件完全无状态化,获得最好的可测试性和复用性。

因此,空间隔离技巧不应被滥用。只有当状态变化频率极高,或者你需要跨越深层组件树进行透传时,才推荐使用 Lambda 包装。

对于普通的点击计数、表单输入等低频状态,常规的直接传值依然是代码可读性最高的最佳实践。

原理:Compose 的快照追踪系统

Compose 的状态追踪由底层的 Snapshot System(快照系统) 驱动。每次状态的 getter 被调用时,都会被注册到当前的快照中。快照系统检测到状态变化后,会通知 Compose 运行时重新执行读取了该状态的 Composable。

核心逻辑在 mutableStateOf 的源码中:

override var value: T  
        get() = next.readable(this).value  // 触发读取记录
        set(value) =  
            next.withCurrent {  
                if (!policy.equivalent(it.value, value)) {  
                    next.overwritable(this, it) { this.value = value }  
                }  
            }

state.value 的读取会触发 readObserver,精准记录下“当前是哪个 Composable 作用域在读取我”:

public fun <T : StateRecord> T.readable(state: StateObject): T {  
    val snapshot = Snapshot.current  
    snapshot.readObserver?.invoke(state)  // 快照系统记录读取者 (Reader)
    return readable(this, snapshot.snapshotId, snapshot.invalid)  
        ?: sync {  
            val syncSnapshot = Snapshot.current  
            @Suppress("UNCHECKED_CAST")  
            readable(state.firstStateRecord as T, syncSnapshot.snapshotId, syncSnapshot.invalid)  
                ?: readError()  
        }  
}

正是基于这套机制,Compose 能够建立起“状态 → Composable”的订阅关系。我们通过 Lambda 改变了 getter 被调用的物理位置,也就改变了快照系统记录的订阅者,从而实现了重组作用域的精准控制。

总结

你会发现,Compose 的性能优化永远围绕重组展开,虽然重组本身不会造成严重的性能问题(重组正是 Compose 的核心理念),但关注并优化重组行为,能从源头上消除潜在的性能隐患。

需要注意的是,重组本身不是问题,问题通常出在状态读取范围过大、读取频率过高,或把高频状态提前放到了组合阶段读取。优化 Compose 性能时,更重要的是理解状态在哪里被读取,而不是机械地追求"少重组"。

先记住三条核心原则:

  1. 派生与过滤——高频变化的状态用 derivedStateOfsnapshotFlow 控制更新粒度,避免无意义的重组;
  2. 阶段降级——利用 offset { }graphicsLayer { } 等 Lambda 修饰符,将状态读取推迟到布局或绘制阶段,彻底避开组合阶段;
  3. 空间隔离——通过传递 Lambda 函数代替直接传值,将状态读取的作用域缩小到真正需要它的子组件内。

能把这三点讲清楚,你的同时听到的就不只是“少重组”这句口号,而是你真正理解了 Compose 的运行方式。