Jetpack Compose重组优化:机制解析与性能提升策略

1,184 阅读16分钟

前言

理解 重组机制 是优化 Jetpack Compose 性能的核心。本文将讲解重组的触发、决策与执行流程,并提供实用性能优化策略。

一、Jetpack Compose 重组机制

重组是Compose实现  “状态驱动UI”  的核心,其流程可分为 触发 → 决策 → 执行 三个环节。

整体的流程图如下: image.png

1.1 触发:状态变化 → 快照标记 → 重组调度

  1. 状态变化mutableStateOf 等可观察状态的值改变(比如 count.value += 1)。
  2. 快照标记:Compose的快照系统(Snapshot)记录状态变化,将其标记为  “脏” (Dirty)。
  3. 重组调度:重组调度器(Recomposer)收集所有依赖该状态的 Composable 函数(通过状态的观察者列表),将它们加入重组队列。

关于快照系统具体是怎么做到精准追踪状态变化的,我在另一篇文章里聊了聊,欢迎感兴趣的朋友继续阅读:Jetpack Compose重组原理(一):快照系统如何精准追踪状态变化

1.2 决策:调用点/输入检测

在理解决策流程前,需要先了解Compose的类型稳定性概念,这是判断"参数类型稳定"的依据。

稳定类型的定义
如果某种类型要被视为稳定类型,则必须符合以下条件:

  • 对于相同的两个实例,其 equals 的结果将始终相同
  • 如果类型的某个公共属性发生变化,组合将收到通知
  • 所有公共属性类型也都是稳定类型

编译器直接视为稳定的类型

  • 所有基元值类型:BooleanIntLongFloatChar
  • 字符串
  • 所有函数类型 (lambda)

理解了类型稳定性后,我们来看重组的决策流程
重组调度器启动后,并不会直接执行函数,而是先进行一系列检查,以决定是否跳过重组。这个决策流程遵循一套清晰的规则,如下图所示: image.png Compose跳过单个可组合函数重组的5个核心条件

实际上,这5个条件可以理解为两个层次的检查:

  1. 结构稳定性检查(条件1)调用点位置不变(第一个判断节点,“否”分支继续)

  2. 输入稳定性检查(条件2-5) :即 输入是否改变

    • 返回类型为Unit(第二个判断节点,“是”分支继续)
    • 无禁止跳过的注解(第三个判断节点,“否”分支继续)
    • 所有参数类型稳定(第四个判断节点,“是”分支继续;“否”分支需检查是否被标记为稳定)
    • 参数值未更改(最后一个判断节点,“是”分支跳过重组)

只有当调用点不变(条件1满足)且输入未变化(条件2-5全部满足)时,Compose才会跳过重组。

理解调用点的关键性

在重组决策中,调用点的概念很重要但却相对抽象。为了更直观地理解调用点的含义及其对重组的影响,我们用一个生活中的例子来说明:
我们可以将界面UI的构成比作一间教室

  • 可组合函数如同教室里的课桌,其位置(在代码树中的位置)和类型(是Text还是Button)是相对固定的。
  • 而每次组合时传入的状态数据,则像是课桌上摆放的物品(比如一本数学书或一个笔袋)。

重组就像是老师巡视教室并核对信息的过程,主要因两种变化触发:

  1. 布局变化(调用点变化):课桌被移动、新增或移除。教室的局部布局(例如某一排)发生了变化。老师需要重新核对发生变化区域(重组范围)内所有课桌的位置关系,更新座位表,但教室其他未受布局变动影响的区域不会被检查。(范围重组
  2. 物品变化(输入改变):某张课桌上的数学书换成了物理书。老师只需更新这一张课桌的记录。(局部重组

因此,老师的工作逻辑可以总结为:

  • 跳过重组:课桌的布局没变(数量、位置、顺序都保持不变)  &&  课桌上摆放的物品没变
    (调用点未变 && 输入未改变)
  • 执行重组:课桌的布局变了(移动、增加或减少)  ||  课桌上摆放的物品变了
    (调用点变化 || 输入改变)

1.3 执行:组合 → 布局 → 绘制

Compose 会通过几个不同的阶段来渲染帧。比如Android View 系统有 3 个主要阶段:测量、布局和绘制。而Compose 和它非常相似,但开头多了一个叫做“组合”的重要阶段。

image.png

  1. 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
  2. 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。一般是父布局首先将自己的约束传给子布局,子布局测量自己,再将大小传给父布局,父布局再进行放置。(但另一些布局如LazyColumn不是这样。因为着重介绍重组性能优化,此处不展开)
  3. 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。

二、Compose 重组优化策略

在了解了Compose重组的机制之后,我们来看看有哪些常用的优化策略 image.png

2.1 减小重组的范围

2.1.1 跳过可组合函数重组

1.2小节中详细说了跳过可组合函数需满足

  • 调用点位置不变
  • 返回类型为Unit
  • 无禁止跳过的注解
  • 所有参数类型稳定
  • 参数值未更改

这几点都比较好理解,这里只着重讲下参数为lambda形式时需要注意的地方。我们看一个简单的栗子

页面上现有3个按钮,按钮上面有一个色块用来显示更新的颜色,默认为红色。 image.png

代码如下:

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
             ...
             val viewModel by viewModels<MainActivityViewModel>()
             RgbSelector(color = viewModel.color, onColorClick = {
                 viewModel.changeColor(it)  
             })
             ...
        }
    }
}
class MainActivityViewModel : ViewModel() {
    var color by mutableStateOf(Color.Red)
        private set

    fun changeColor(color: Color) {
        this.color = color 
    }
}
@Composable
fun RgbSelector(
    color: Color,
    onColorClick: (Color) -> Unit,
    modifier: Modifier = Modifier
) {
    Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
        Box(modifier = Modifier
            .size(100.dp)
            .background(color))
        Spacer(modifier = Modifier.height(16.dp))
        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
            Button(
                onClick = {
                onColorClick(Color.Red)
            }) {
                Text(text = "Red")
            }
            Button(onClick = {
                onColorClick(Color.Green)
            }) {
                Text(text = "Green")
            }
            Button(onClick = {
                onColorClick(Color.Blue)
            }) {
                Text(text = "Blue")
            }
        }
    }
}

运行上面代码,点击文本为绿色或蓝色的按钮时,色块和所有按钮都会触发重组,但理想情况下,只需重组色块和被点击的按钮。
下面是Layout Inspector(3.1小节会讲)查看RgbSelector重组情况。右下3个按钮组合次数都+1 RgbBox.gif

原因就是lambda参数传了{ viewModel.changeColor(it) }:

  • Kotlin编译器会将lambda函数编译为简单的匿名类
  • ViewModel会作为参数传递给这个匿名类
  • ViewModel因为是一个复杂对象,不会被标记为稳定类

具体看看{ viewModel.changeColor(it) }对应的Java代码:

         new Function1() { // 无固定 receiver,每次创建新实例
             public void invoke_8_81llA(long it) {
                 null.invoke$lambda$0(viewModel$delegate).changeColor-8_81llA(it); // 通过委托访问 viewModel
             }
         }
  • Lambda 捕获了 viewModel 变量(即使 viewModel 是 val,但其指向的对象可能变化)。
  • 每次父组件重组时,都会创建新的 Function1 实例(因为 lambda 重新声明)。
  • Compose 将其视为 不稳定参数

所以{ viewModel.changeColor(it) }每次重新组合时都会被视为一个新的实例,也就导致了RgbSelector不是全部参数类型稳定,然后整个RgbSelector都重组了。

修改方案有2种

  • 使用函数引用(Function Reference)语法,直接传递方法引用
  • 使用remember

传递方法引用

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
             ...
             val viewModel by viewModels<MainActivityViewModel>()
             RgbSelector(color = viewModel.color, onColorClick = viewModel::changeColor)
             ...
        }
    }
}

下面是Layout Inspector查看RgbSelector重组情况,右下仅点击按钮组合次数+1 RgbBox.gif

对应的Java代码:

       new Function1(invoke$lambda$0(viewModel$delegate)) { // 使用固定 receiver(viewModel 实例)
             public void invoke_8_81llA(long p0) {
                 ((MainActivityViewModel) this.receiver).changeColor-8_81llA(p0); // 直接调用固定函数
             }
         }
  • viewModel::changeColor 是一个 函数引用,指向viewModel的固定方法。
  • 编译后生成单例Function1对象,实例不会变化(即使父组件重组)。
  • Compose 将其视为 稳定参数

使用remember

对于无法使用 :: 的方式传入Lambda参数,可以使用remember

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
             ...
             val viewModel by viewModels<MainActivityViewModel>()
             val changeColorLambda = remember<(Color) -> Unit> {
                 {
                     viewModel.changeColor(it)
                 }
             }
             RgbSelector(color = viewModel.color, onColorClick = changeColorLambda)
             ...
        }
    }
}

下面是Layout Inspector查看RgbSelector重组情况,右下仅点击按钮组合次数+1 RgbBox.gif

对应的Java代码:

    // 检查是否已有记忆值
    if (it$iv$iv == Composer.Companion.getEmpty()) { 
        // 首次组合:创建新的 Function1 实例
        Object value$iv$iv = new Function1() {
            public void invoke_8_81llA(long it) {
                null.invoke$lambda$0(viewModel$delegate).changeColor-8_81llA(it);
            }
            public Object invoke(Object p1) {
                this.invoke-8_81llA(((Color)p1).unbox-impl());
                return Unit.INSTANCE;
            }
        };
        
        // 存储实例以便后续重组复用
        $composer.updateRememberedValue(value$iv$iv); 
        var10000 = value$iv$iv;
    } else {
        // 后续重组:复用已存储的实例
        var10000 = it$iv$iv; 
    }

    // 获取记忆的 lambda 实例
    Function1 changeColorLambda = (Function1)var14; 

    // 传递稳定的实例给 RgbSelector
    RgbSelector(color = viewModel.color, onColorClick = changeColorLambda)

用 remember 让 lambda 参数为稳定类型的原因:重组会使用之前的实例

2.1.2 在列表中使用 key

LazyColumn 等列表中,为每一项提供一个唯一且稳定的 key。这帮助Compose在数据集变化(如排序、增删)时,准确识别出哪些项是新增、移动或移除的,从而重用现有组件实例,避免不必要的重组。

举个例子

@Composable
fun LazyColumnItem(modifier: Modifier = Modifier) {
    var items by remember { mutableStateOf(listOf(1, 2, 3)) }
    Column(modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
        LazyColumn {
            items(items) { item ->
                Text("Item $item")
            }
        }
        Button(onClick = {
            items = listOf(items.size + 1) + items
        }) { Text("add item") }
    }
}

点击按钮,添加一个Text,看看不使用Key的重组情况 RgbBox.gif 可以看到,每次点击按钮,之前的几个Text也会跟着执行组合阶段。

下面再看看使用Key

@Composable
fun LazyColumnItem(modifier: Modifier = Modifier) {
    var items by remember { mutableStateOf(listOf(1, 2, 3)) }
    Column(modifier = modifier.fillMaxSize(), verticalArrangement = Arrangement.Center) {
        LazyColumn {
            items(items, key = { it }) { item -> // 1  注意此处传入了key
                Text("Item $item")
            }
        }
        Button(onClick = {
            items = listOf(items.size + 1) + items
        }) { Text("add item") }
    }
}

只是在代码1处添加了key参数 key.gif 可以看到,已经存在的Text,会自动跳过组合阶段的重新执行

列表中使用 key 可以跳过组合阶段的原因
先说原因:LazyColumn子项groupKey相同,会比较objectKey,而 objectKey 如果不传,会默认使用index

// objectKey的伪代码
val objectKey = key?.invoke(item) ?: index // 未提供key时使用index

在上面 未传入Key 的例子中,Text组件是添加前面的,导致其他的Text组件的 index发生了变化, 即objectKey变化了,所以就会执行组合阶段。
当我们 传入key 时 ,比较objectKey 就是 比较传入的key了,当前传入的key是list子元素本身,这个是不会变化的。因此当添加了一个 唯一且稳定 的key时,后面Text组件就不会执行组合阶段了。

底层实现:SlotTable 如何识别调用点
那么 groupKeyobjectKey 又是什么呢?
从底层实现看重组机制,Compose 通过 SlotTable 来管理整个组合树,它包含两个核心数组:

  • Groups 数组:存储组合树的结构信息,实际上是一个int数组,以5个int为一个group元素 image.png
  • Slots 数组:存储与每个 Group 相关的数据

组合树中的每个可组合项(包括可组合函数和内置组件如Text、Button等)都被表示为一个 Group。每个 Group 元素内部都包含key信息,这个key与代码位置相关。每个 Group 包含两个关键标识:

  • groupKey:存储在 Groups 数组中,由编译器根据代码位置生成,可以理解为 课桌在教室中的固定位置 (如第三排第二列)
  • objectKey:存储在对应 Group 的 Slot 数组中,用于区分同一位置的多个实例,可以理解为 "给每个课桌加的编号"

对于大多数非列表组件,由于 objectKey 使用默认值,Compose 主要比较 groupKey(课桌位置)来判断调用点是否变化。而对于列表中的组件,由于它们共享相同的 groupKey(在代码中的位置相同),Compose 就需要通过比较 objectKey(课桌编号)来识别具体的组件实例。

简单来说:

  • 非列表组件:主要看 groupKey (课桌位置) 是否相同,因为 objectKey (课桌编号) 通常为默认值
  • 列表组件:由于所有列表项使用相同的 groupKey(课桌位置相同),因此通过比较 objectKey(课桌编号) 来识别不同的组件实例

当检测到调用点位置变化时,Compose 会认为 UI 结构发生了变化,从而触发重组。

LazyColumn实现
LazyColumn子项组件因为代码位置相同,所以groupKey都是一样的。看下LazyColumn为什么子项组件groupKey会相同,子项对应Java代码如下:

...
$composer.startReplaceGroup(-805600041); //
TextKt.Text--4IGK_g("Item " + item, ..., ...);
$composer.endReplaceGroup();
...

/// Composer类
@ComposeCompilerApi public fun startReplaceGroup(key: Int)

-805600041 是编译器生成的 位置键(positional key),基于源码行号计算得来。这保证了:

  1. 相同位置的组件有相同的 key
  2. 不同位置的组件有不同的 key
  3. 位置变化时 key 变化 → 导致重组

startReplaceGroup 是往Group数组中插入一个可以被替换的 Group,LazyColumn子项插入相同的key。此时groupKey相同,需要比较objectKey

比较groupKey和objectKey逻辑是在 ComposerImpl.start(key: Int, objectKey: Any?, kind: GroupKind, data: Any?)方法中,具体代码:

private fun start(key: Int, objectKey: Any?, kind: GroupKind, data: Any?) {
    ...
    val forceReplace = !kind.isReusable && reusing
    ...
    val slotKey = reader.groupKey
    // slotKey -> SlotTable中Group数组子元素里的key
    // reader.groupObjectKey -> Slot数组子元素中的objectKey。
    if (!forceReplace && 
        slotKey == key // groupKey 匹配(LazyColumn 中总是相同)
        && objectKey == reader.groupObjectKey) { // objectKey 匹配
        // The group is the same as what was generated last time.
        startReaderGroup(isNode, data)
    }
    ...
}

先说说forceReplace表示是否需要强制替换,或者说强制组合

  • kind.isReusable:当前组是否可重用,默认值为 true(大多数可组合函数是可重用的)
  • reusing:当前是否处于重用模式,在重组过程中处理可重用组时为 true

代码中:
slotKey -> SlotTable中Group数组子元素里的key
reader.groupObjectKey -> Slot数组子元素中的objectKey。

知道了上面这些后,我们可以得出下面的结论:
1.因为是LazyColumn,所以里面组件的 GroupKey都相同,需要比较objectKey
2.LazyColumn未传keyobjectKey默认为index。如果改变了index,那么就会执行组合阶段。
3.LazyColumn传入key,objectKey就是是传入的key,只要传入的key不变,那么调用点没有变化

特殊情况(尾部添加)
看下在尾部添加Text组件, 并且不传入Key的情况,此时已存在子项的index不会变化,代码如下:

@Composable
fun LazyColumnText() {
    var items by remember { mutableStateOf(listOf(1, 2, 3)) }
    LazyColumn {
        items(items) { item -> // 注意此处没有传入key
            Text("Item $item")
        }
    }
    Button(
        onClick = {
            items =  items + listOf(items.size + 1) // 尾部添加子元素
        }
    ) {
        Text("Add item at bottom")
    }
}

看看Layout Inspector中组合执行次数: output.gif 可以看到即使没有传入Key,前面的Text组件也组合次数也不会增加,此时objectKey默认为index,而index没有变化,因此前面的Text组件可以跳过组合。

请注意:这种情况仅限于在列表尾部添加新项

再看下如果是使用 稳定但非唯一 的key
此处仅修改list,添加一个重复的数组元素2

    ...
    var items by remember { mutableStateOf(listOf(1, 2, 2, 3)) }
    ...

运行直接崩溃, 报错如下:
FATAL EXCEPTION: main
Process: com.xxx.xxx, PID: xxxx
java.lang.IllegalArgumentException: Key "2" was already used. If you are using LazyColumn/Row please make sure you provide a unique key for each item.
因此如果列表要添加 key,一定要添加稳定且唯一的key

并且建议始终在列表中使用 key,原因如下:

  • 列表可能进行排序/过滤等操作
  • 避免未来修改代码时引入性能问题

小结:当数据项的唯一标识(key)不变时,即使它在列表中的位置发生变化,Compose 也能通过 唯一且稳定 的key 识别这是 同一个 组件,从而跳过不必要的重组。

2.1.3 状态下沉

原理:通过将状态移至实际使用它的最小可组合函数范围内,实现精准的重组控制:

  • 调用点保持不变:父组件调用结构稳定,位置键不变
  • 输入变化精准定位:状态变化被隔离在子组件内部
  • 跳过不必要的重组
    • 父组件:调用点不变 + 输入未变 → 跳过整个父组件重组
    • 子组件:直接绑定状态 → 仅执行必要的重组

类比到上面的教室(1.2小节)

状态下沉就像让每张课桌自己管理自己的物品,而不是让老师统一管理:

  • 优化前:老师统一管理所有课桌的物品

    • 某张课桌上的书换了 → 老师重新巡视整个教室
    • 所有课桌都要被检查,即使它们的内容没变
  • 优化后:每张课桌自己管理自己的物品

    • 某张课桌上的书换了 → 只需要该课桌自己向老师报告
    • 老师只需要检查这一张课桌
    • 其他课桌不受影响,教室整体工作流程更高效

举个例子

// 优化前:状态在高层组件
@Composable
fun ListItem(title: String) {
    var isClicked by remember { mutableStateOf(false) } // 状态放在ListItem(高层组件)
    Column {
        Text(text = title) // 不相关但也会重组
        Button(onClick = { isClicked = !isClicked }) {
            Text(if (isClicked) "Clicked" else "Click Me") // 变化的部分
        }
    }
}

当点击按钮时,整个Column的作用域都会重新执行,但实际上只有Button需要重新执行,可以将isClicked下沉到Button中,从而优化重组性能

// 优化后:状态下沉到使用组件
@Composable
fun ListItem(title: String) {
    Column {
        Text(text = title) // 保持稳定,跳过重组
        ClickableButton() // 状态下沉到该子组件
    }
}

@Composable
fun ClickableButton() {
    var isClicked by remember { mutableStateOf(false) } // 状态放在需要的子组件
    Button(onClick = { isClicked = !isClicked }) {
        Text(if (isClicked) "Clicked" else "Click Me") // 仅此处重组
    }
}

小结

  • 点击按钮时,重组范围从整个 ListItem 缩小到 ClickableButton 组件
  • Text(text = title) 和父组件 ListItem 都跳过重组
  • 性能提升明显,特别是在列表等频繁更新的场景中

2.2 优化重组阶段

2.2.1 使用Lambda处理动态Modifier

Compose的重组分为组合、布局、绘制三个阶段。状态读取的位置决定了需要重组的范围。我们应该尽量将工作从组合阶段转移到布局或绘制阶段,从而减少不必要的组合执行。

很多Modifier(如offset、graphicsLayer)接受Lambda表达式。如果其值是动态的,使用Lambda形式可以将状态读取从组合阶段推迟到布局/绘制阶段。

image.png

下面通过具体例子来看看这种优化的效果:

@Composable
fun OffsetItem() {
    Column(
        modifier = Modifier.fillMaxSize(),
        verticalArrangement = Arrangement.Center
    ) {
        // 未使用Lambda表达式
        TestWithoutLambda()
        // 使用Lambda表达式
        TestWithLambda()
    }
}

@Composable
fun TestWithoutLambda() {
    var offsetX by remember { mutableIntStateOf(0) }
    Text(
        text = "hello world",
        modifier = Modifier.offset(x = offsetX.dp, y = 0.dp) // 组合阶段就会执行
    )
    Button(onClick = { offsetX += 100 }) { Text("Move") }
}

@Composable
fun TestWithLambda() {
    var offsetX by remember { mutableIntStateOf(0) }
    Text(
        text = "hello world",
        modifier = Modifier.offset { IntOffset(offsetX, 0) } // 注意此处: 从组合阶段转移到布局阶段
    )
    Button(onClick = { offsetX += 100 }) { Text("Move") }
}

在Column中分别使用offset的非Lambda和Lambda方式,点击按钮,偏移Text

RgbBox – OffsetItem.kt [RgbBox.app.main] 2025-09-02 18-05-50.gif gif图中上面是没有使用Lambda表达式,下面是使用Lambda表达式

使用Layout Inspector查看重组阶段组合的次数,可以看到使用Modifier.offset()时,Button和Text组合次数均+1,而offset使用Lambda方式则只有Button组合次数+1,Text 成功地跳过了本次重组的组合阶段。

我们来看看源码,为什么使用Modifier(如offset)的Lambda能跳过组合阶段:
原理:offset Lambda版本将状态读取从组合阶段推迟到了布局阶段

fun Modifier.offset(offset: Density.() -> IntOffset) =
    this then
        OffsetPxElement(
            offset = offset,
            ...
        )

private class OffsetPxElement(
    val offset: Density.() -> IntOffset,
    val rtlAware: Boolean,
    val inspectorInfo: InspectorInfo.() -> Unit,
) : ModifierNodeElement<OffsetPxNode>() {
    override fun create(): OffsetPxNode {
        return OffsetPxNode(offset, rtlAware) // 1
    }
    ...
}


private class OffsetPxNode(var offset: Density.() -> IntOffset, var rtlAware: Boolean) :
    LayoutModifierNode, Modifier.Node() {
    ...
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints,
    ): MeasureResult {
        val placeable = measurable.measure(constraints)
        return layout(placeable.width, placeable.height) {
            val offsetValue = offset()    // 2 关键:状态在布局阶段读取
            ...
        }
    }
}

小结

  • 非Lambda版本Modifier.offset(x = offsetX.dp)组合阶段计算并读取状态
  • Lambda版本Modifier.offset { IntOffset(offsetX, 0) }布局阶段读取状态

通过使用Lambda版本,我们将状态读取从组合阶段推迟到布局阶段,当状态变化时,Text组件可以跳过组合阶段的重新执行,只进行布局和绘制。

2.2.2 使用remember缓存复杂操作

在组合函数中,应避免 重复执行复杂的操作(如复杂对象创建、数据转换、集合处理等)。可以使用 remember 缓存这些操作的结果,只有在依赖项变化时才重新计算,从而减少不必要的开销。

举个例子:在页面中画一个三角形

@Composable
fun UseRememberScreen() {
    // 用于三角形的放大
    var scale by remember { mutableFloatStateOf(1f) }
    // 用来触发页面重组,观察 remember 缓存复杂操作的作用
    var count by remember { mutableIntStateOf(1) }

    // 使用remember:仅 scale 改变时重新计算
    val pathWithRemember = remember(scale) {
        // 绘制三角形
        Path().apply {
            val size = 150f * scale
            val p1 = Offset(size / 2, 0f)
            val p2 = Offset(0f, size)
            val p3 = Offset(size, size)
            moveTo(p1.x, p1.y)
            lineTo(p2.x, p2.y)
            lineTo(p3.x, p3.y)
            close()
        }
    }

    // 未使用remember:每次重组都会重新创建路径
    val path = Path().apply { ... }

    Column(horizontalAlignment = Alignment.CenterHorizontally) {
        // 使用了remember路径
        Canvas(modifier = Modifier.size(100.dp)) {
            drawPath(path = pathWithRemember, color = Color.Red, style = Stroke(width = 4f))
        }
        // 未使用remember路径
        Canvas(modifier = Modifier.size(100.dp)) {
            drawPath(path = path, color = Color.Red, style = Stroke(width = 4f))
        }
        Button(onClick = { scale += 0.2f }) {
            Text("放大三角形")
        }
        Text("Count: $count")
        // 增加count按钮, 会触发页面重组
        Button(onClick = { count += 1 }) {
            Text("增加count")
        }
    }
}

output.gif gif图中,上面的三角形是使用了remember路径,下面的则未使用remember路径

当点击 增加count 按钮时,页面会重组。由于第一个Canvas使用remember缓存了路径,它不会重新创建路径(组合次数不会增加);而第二个Canvas没有使用remember,每次重组都会重新创建路径(组合次数增加)。

此优化与参数稳定性在减少开销的目标是一致的,但它们作用于不同层面。当组合函数的参数是稳定且未变化时,Compose 编译器会尝试跳过该函数;而对于函数内部的复杂计算,则可以使用 remember

需要注意的两点:

  • remember 如果没有提供正确的依赖项参数,那么缓存就无法在数据更新时失效和重建,从而导致UI显示旧数据。
  • 可以将 remember状态下沉(2.1.3)配合使用——将状态与计算一同封装到子组件。

2.3 减少非必要的重组次数

2.3.1 避免重组循环

重组循环: 在重组过程中修改状态 → 导致输入变化 → 触发新一轮重组
我们直接看官网的例子:

Box {
    var imageHeightPx by remember { mutableIntStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

在第一帧的组合阶段,imageHeightPx 最初为 0。因此,该代码会提供带有 Modifier.padding(top = 0) 的文本。后续的布局阶段会调用 LazyColumn 修饰符的回调,该回调会将 imageHeightPx 更新为图片的实际高度。这会更新 imageHeightPx 的值,从而发生第二帧,也就触发新一轮重组。

这是一个典型的重组循环例子。在实际开发中,应尽量避免这种在布局阶段回调中修改状态触发新一轮组合的模式。推荐使用 Compose 的布局系统本身来解决这类问题,例如使用 Column 的流式布局特性,或者利用 Modifier.weight() 等来动态分配空间,从而无需手动计算和设置高度/间距。

2.3.2 使用derivedStateOf合并高频状态更新

使用场景:状态或键的变化超过想要更新 UI 时

举个例子:向下滚动,列表的第一项已经不可见时,在滚动列表右侧显示一个按钮

@Composable
fun DerivedStateOfItem(modifier: Modifier = Modifier) {
    val listState = rememberLazyListState()
    val list = listOf(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
    Row {
        LazyColumn(state = listState) {
            items(list, key = { it }) { item ->
                Text("Item $item", modifier = modifier.height(150.dp))
            }
        }
        val showButton = listState.firstVisibleItemIndex > 0
        // Log.d("DerivedStateOfItem", "ComposeRecomposition")
        if (showButton) {
            Button(onClick = {}) {
                Text("button")
            }
        }
    }
}

output.gif 可以从上图看出,每次向下滚动一个子项时,都会发生重组。也可以log日志打开,每次向下滚动一个子项,都会打印一次日志。

但实际上,我们仅需列表的第一项不可见时重组,后续向下滚动都不需要重组了。这里就是状态变化超过想要更新 UI。

使用derivedStateOf修改

@SuppressLint("FrequentlyChangingValue")
@Composable
fun DerivedStateOfItem(modifier: Modifier = Modifier) {
    ...
    val showButton by remember {
        derivedStateOf {
            // 只有条件变化时才会触发重组
            listState.firstVisibleItemIndex > 0
        }
    }
    ...
}

output.gif 现在就仅有 listState.firstVisibleItemIndex > 0变化时才会重组了。

三、Compose重组优化工具:布局检察器、编译器报告

3.1 Layout Inspector

在第2小节的一些示例中,我们使用了Layout Inspector的重组计数功能来量化优化效果。

以下是它的操作步骤:

3.1.1 打开Layout Inspector:运行项目到模拟器,点击下图中的图标

image.png

AS 中打开 Layout Inspector 可能会报错:Could not download androidx.compose.ui;ui-android:xx.xx.xx from maven.google.com, Check the internet connection For offline repositories (not common) please specify -Dappinspection.use.dev,ar=true as a custom VM property. image.png 解决办法: zhuanlan.zhihu.com/p/661454651

3.1.2 点击app中的组件,查看Layout Inspector页面变化

image.png

图中1,2,3含义

  • 1. Compositions (组合次数) :此可组合项进入组合阶段的总次数。这包括了初始组合和所有后续的重组。
  • 2. Recomposing Children (重组子项次数) :此可组合项的直接子项进入组合阶段的总次数。这是一个累计值,有助于了解变更的影响范围。
  • 3. Skipped (跳过次数) :此可组合项在可能发生重组时,被成功跳过组合阶段的次数。这个数值越高,通常意味着性能优化得越好。

它适合用于对比优化前后的差异,可以直观验证策略有效性。

3.2 Compose 编译器报告

3.2.1 集成

在每个模块的 build.gradle 文件中添加以下内容

  android { ... }

  composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_compiler")
    metricsDestination = layout.buildDirectory.dir("compose_compiler")
  }

这是在 AGP 8.0 及更高版本中的配置方式。如果您使用的是较早版本的 Android Gradle 插件,可能需要使用 reportsDestination = file("$buildDir/compose_compiler") 的格式。

3.2.2 构建项目时会生成 Compose 编译器报告

image.png

reportsDestination 会输出几个文件。

  • ${modulename}-classes.txt: 关于本模块中类稳定性的报告。
  • ${modulename}-composables.txt: 关于模块中可组合项的可重启和可跳过程度的报告。。
  • ${modulename}-composables.csv: 可组合项报告的 CSV 版本,可以将其导入电子表格或使用脚本进行处理。
  • ${modulename}-module.json: 编译阶段的优化指标
可组合项报告

composables.txt 文件中会详细说明给定模块的每个可组合函数的相关情况,包括其参数的稳定性以及它们是否可重启或可跳过

restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun RgbSelector(
  stable color: Color
  stable onColorClick: Function1<Color, Unit>
  stable modifier: Modifier? = @static Companion
)

可以看到2.1.1小节用到的RgbSelector是可重启、可跳过,并且都是稳定参数。

类报告

文件 classes.txt 中包含关于给定模块中的类的类似报告。以下代码段是针对类 MainActivityViewModel 的输出:

stable class MainActivityViewModel {
  stable var color$delegate: MutableState<Color>
  stable var colorLambda$delegate: MutableState<Color>
  stable var colorRemember$delegate: MutableState<Color>
  <runtime stability> = Stable
}

MainActivityViewModel代码如下:

class MainActivityViewModel : ViewModel() {
    var color by mutableStateOf(Color.Red)
        private set

    fun changeColor(color: Color) {
        this.color = color 
    }
}
优化指标

module.json是整个项目当前的一个概要

    {
     "skippableComposables": 17,    // 可跳过重组的函数
     "restartableComposables": 26,
     "readonlyComposables": 0,
     "totalComposables": 26,
     "restartGroups": 26,
     "totalGroups": 31,
     "staticArguments": 35,
     "certainArguments": 1,
     "knownStableArguments": 346,
     "knownUnstableArguments": 1,   // 不稳定参数数量
     "unknownStableArguments": 0,
     "totalArguments": 347,
     "markedStableClasses": 0,
     "inferredStableClasses": 2,  // 稳定类识别
     "inferredUnstableClasses": 0,
     "inferredUncertainClasses": 0,
     "effectivelyStableClasses": 2,
     "totalClasses": 2,
     "memoizedLambdas": 20, // 记忆化Lambda
     "singletonLambdas": 1,
     "singletonComposableLambdas": 7,
     "composableLambdas": 9,
     "totalLambdas": 21 // 总的Lambda
    }

需要着重关注的几个数据:

  1. knownUnstableArguments(不稳定参数数量)
  2. skippableComposables(可跳过重组的函数)
  3. restartGroups / totalGroups(重启作用域效率)
  4. memoizedLambdas(记忆化Lambda)
  5. inferredStableClasses(稳定类识别)

四、总结与延伸

本文我们探讨了Compose重组机制的优化策略。通过上述方法,我们能有效提升重组性能。需要注意的是,完整的重组性能优化不仅包含上述策略,还涉及:

  • 布局阶段优化: 比如使用 ConstraintLayout 替代深层嵌套的 Row/Column 组合,减少布局计算的开销
  • 绘制阶段优化: 比如使用 drawWithCache 复用昂贵对象,避免在 draw{} 块中频繁分配内存。
  • 遵守阶段职责: 严禁在 @Composable 函数或布局修饰符中执行 I/O 操作、密集计算或分配大量临时对象。

感谢阅读,希望本文对你有所帮助,如有任何不对的地方,欢迎大家指出

注:文中涉及的优化示例代码(优化前后)已添加详细注释,并整合在 Demo 中。

五、参考资料

  1. Jetpack Compose 官方文档
  2. Philipp Lackner - Jetpack Compose Recomposition Explained
  3. Compose 官方备忘录 - 何时使用 derivedStateOf
  4. Compose Snapshots: we got THE expert to go in-depth - with Chuck Jazdzewski