前言
理解 重组机制 是优化 Jetpack Compose 性能的核心。本文将讲解重组的触发、决策与执行流程,并提供实用性能优化策略。
一、Jetpack Compose 重组机制
重组是Compose实现 “状态驱动UI” 的核心,其流程可分为 触发 → 决策 → 执行 三个环节。
整体的流程图如下:
1.1 触发:状态变化 → 快照标记 → 重组调度
- 状态变化:mutableStateOf 等可观察状态的值改变(比如 count.value += 1)。
- 快照标记:Compose的快照系统(Snapshot)记录状态变化,将其标记为 “脏” (Dirty)。
- 重组调度:重组调度器(Recomposer)收集所有依赖该状态的 Composable 函数(通过状态的观察者列表),将它们加入重组队列。
关于快照系统具体是怎么做到精准追踪状态变化的,我在另一篇文章里聊了聊,欢迎感兴趣的朋友继续阅读:Jetpack Compose重组原理(一):快照系统如何精准追踪状态变化
1.2 决策:调用点/输入检测
在理解决策流程前,需要先了解Compose的类型稳定性概念,这是判断"参数类型稳定"的依据。
稳定类型的定义
如果某种类型要被视为稳定类型,则必须符合以下条件:
- 对于相同的两个实例,其 equals 的结果将始终相同
- 如果类型的某个公共属性发生变化,组合将收到通知
- 所有公共属性类型也都是稳定类型
编译器直接视为稳定的类型
- 所有基元值类型:Boolean、Int、Long、Float、Char 等
- 字符串
- 所有函数类型 (lambda)
理解了类型稳定性后,我们来看重组的决策流程。
重组调度器启动后,并不会直接执行函数,而是先进行一系列检查,以决定是否跳过重组。这个决策流程遵循一套清晰的规则,如下图所示:
Compose跳过单个可组合函数重组的5个核心条件
实际上,这5个条件可以理解为两个层次的检查:
-
结构稳定性检查(条件1) :调用点位置不变(第一个判断节点,“否”分支继续)
-
输入稳定性检查(条件2-5) :即 输入是否改变
- 返回类型为Unit(第二个判断节点,“是”分支继续)
- 无禁止跳过的注解(第三个判断节点,“否”分支继续)
- 所有参数类型稳定(第四个判断节点,“是”分支继续;“否”分支需检查是否被标记为稳定)
- 参数值未更改(最后一个判断节点,“是”分支跳过重组)
只有当调用点不变(条件1满足)且输入未变化(条件2-5全部满足)时,Compose才会跳过重组。
理解调用点的关键性
在重组决策中,调用点的概念很重要但却相对抽象。为了更直观地理解调用点的含义及其对重组的影响,我们用一个生活中的例子来说明:
我们可以将界面UI的构成比作一间教室:
- 可组合函数如同教室里的课桌,其位置(在代码树中的位置)和类型(是Text还是Button)是相对固定的。
- 而每次组合时传入的状态数据,则像是课桌上摆放的物品(比如一本数学书或一个笔袋)。
重组就像是老师巡视教室并核对信息的过程,主要因两种变化触发:
- 布局变化(调用点变化):课桌被移动、新增或移除。教室的局部布局(例如某一排)发生了变化。老师需要重新核对发生变化区域(重组范围)内所有课桌的位置关系,更新座位表,但教室其他未受布局变动影响的区域不会被检查。(范围重组)
- 物品变化(输入改变):某张课桌上的数学书换成了物理书。老师只需更新这一张课桌的记录。(局部重组)
因此,老师的工作逻辑可以总结为:
- 跳过重组:课桌的布局没变(数量、位置、顺序都保持不变) && 课桌上摆放的物品没变。
(调用点未变 && 输入未改变) - 执行重组:课桌的布局变了(移动、增加或减少) || 课桌上摆放的物品变了。
(调用点变化 || 输入改变)
1.3 执行:组合 → 布局 → 绘制
Compose 会通过几个不同的阶段来渲染帧。比如Android View 系统有 3 个主要阶段:测量、布局和绘制。而Compose 和它非常相似,但开头多了一个叫做“组合”的重要阶段。
- 组合:要显示什么样的界面。Compose 运行可组合函数并创建界面说明。
- 布局:要放置界面的位置。该阶段包含两个步骤:测量和放置。一般是父布局首先将自己的约束传给子布局,子布局测量自己,再将大小传给父布局,父布局再进行放置。(但另一些布局如LazyColumn不是这样。因为着重介绍重组性能优化,此处不展开)
- 绘制:渲染的方式。界面元素会绘制到画布(通常是设备屏幕)中。
二、Compose 重组优化策略
在了解了Compose重组的机制之后,我们来看看有哪些常用的优化策略
2.1 减小重组的范围
2.1.1 跳过可组合函数重组
1.2小节中详细说了跳过可组合函数需满足
- 调用点位置不变
- 返回类型为Unit
- 无禁止跳过的注解
- 所有参数类型稳定
- 参数值未更改
这几点都比较好理解,这里只着重讲下参数为lambda形式时需要注意的地方。我们看一个简单的栗子
页面上现有3个按钮,按钮上面有一个色块用来显示更新的颜色,默认为红色。
代码如下:
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
原因就是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
对应的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
对应的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的重组情况
可以看到,每次点击按钮,之前的几个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参数
可以看到,已经存在的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 如何识别调用点
那么 groupKey 和 objectKey 又是什么呢?
从底层实现看重组机制,Compose 通过 SlotTable 来管理整个组合树,它包含两个核心数组:
- Groups 数组:存储组合树的结构信息,实际上是一个int数组,以5个int为一个group元素
- 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),基于源码行号计算得来。这保证了:
- 相同位置的组件有相同的 key
- 不同位置的组件有不同的 key
- 位置变化时 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未传key,objectKey默认为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中组合执行次数:
可以看到即使没有传入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形式可以将状态读取从组合阶段推迟到布局/绘制阶段。
下面通过具体例子来看看这种优化的效果:
@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
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")
}
}
}
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")
}
}
}
}
可以从上图看出,每次向下滚动一个子项时,都会发生重组。也可以log日志打开,每次向下滚动一个子项,都会打印一次日志。
但实际上,我们仅需列表的第一项不可见时重组,后续向下滚动都不需要重组了。这里就是状态变化超过想要更新 UI。
使用derivedStateOf修改
@SuppressLint("FrequentlyChangingValue")
@Composable
fun DerivedStateOfItem(modifier: Modifier = Modifier) {
...
val showButton by remember {
derivedStateOf {
// 只有条件变化时才会触发重组
listState.firstVisibleItemIndex > 0
}
}
...
}
现在就仅有 listState.firstVisibleItemIndex > 0变化时才会重组了。
三、Compose重组优化工具:布局检察器、编译器报告
3.1 Layout Inspector
在第2小节的一些示例中,我们使用了Layout Inspector的重组计数功能来量化优化效果。
以下是它的操作步骤:
3.1.1 打开Layout Inspector:运行项目到模拟器,点击下图中的图标
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.
解决办法: zhuanlan.zhihu.com/p/661454651
3.1.2 点击app中的组件,查看Layout Inspector页面变化
图中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 编译器报告
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
}
需要着重关注的几个数据:
- knownUnstableArguments(不稳定参数数量)
- skippableComposables(可跳过重组的函数)
- restartGroups / totalGroups(重启作用域效率)
- memoizedLambdas(记忆化Lambda)
- inferredStableClasses(稳定类识别)
四、总结与延伸
本文我们探讨了Compose重组机制的优化策略。通过上述方法,我们能有效提升重组性能。需要注意的是,完整的重组性能优化不仅包含上述策略,还涉及:
- 布局阶段优化: 比如使用 ConstraintLayout 替代深层嵌套的 Row/Column 组合,减少布局计算的开销。
- 绘制阶段优化: 比如使用 drawWithCache 复用昂贵对象,避免在 draw{} 块中频繁分配内存。
- 遵守阶段职责: 严禁在 @Composable 函数或布局修饰符中执行 I/O 操作、密集计算或分配大量临时对象。
感谢阅读,希望本文对你有所帮助,如有任何不对的地方,欢迎大家指出。
注:文中涉及的优化示例代码(优化前后)已添加详细注释,并整合在 Demo 中。