Article / 2026/06/16

Compose_避免不必要重组

1. [重组触发原理](重组触发原理) 2. [参数稳定性](参数稳定性) 3. [State 读取位置优化](state读取位置优化) 4. [derivedStateOf](derivedstateof) 5. [key 的正确使...

Jetpack Compose 避免不必要重组

目录

  1. 重组触发原理
  2. 参数稳定性
  3. State 读取位置优化
  4. derivedStateOf
  5. key 的正确使用
  6. Lambda 稳定性
  7. List 稳定性问题
  8. 拆分 Composable
  9. graphicsLayer 跳过重组
  10. Modifier 优化
  11. 工具:检测无效重组
  12. 总结速查表

重组触发原理

什么时候会重组

触发重组的条件(同时满足):
  1. Composable 读取的 State 发生了变化
  2. 传入的参数被 Compose 判定为"不稳定"或"已变化"

Compose 决定是否跳过重组的逻辑

新参数到来时,Compose 检查每个参数:

参数类型稳定?
  ├─ 是 → 比较新旧值是否相等
  │         相等 → 跳过重组 ✅
  │         不等 → 执行重组

  └─ 否 → 无法确定是否变化 → 直接执行重组(保守策略)
// Compose 用 == 比较参数是否变化
// 稳定类型才会做这个比较,不稳定类型直接重组

参数稳定性

稳定 vs 不稳定

Compose 编译器会分析每个类型,打上稳定或不稳定标记:

稳定类型(Compose 信任,会做相等比较):
  ✅ 基本类型:Int, Long, Float, Double, Boolean, Char, String
  ✅ 函数类型:() -> Unit,(T) -> R
  ✅ 所有属性都是 val 且类型也稳定的数据类
  ✅ 标注了 @Stable 或 @Immutable 的类
  ✅ Compose 内置类型:Color, Dp, Offset, Size...

不稳定类型(Compose 不信任,直接重组):
  ❌ List, Set, Map(接口,无法确定实现是否可变)
  ❌ 包含 var 属性的类
  ❌ 没有标注的普通类(即使实际上不可变)
  ❌ 来自其他模块未被 Compose 编译器分析的类

@Immutable

告知 Compose:此类所有属性永远不会变化,是最强的稳定承诺:

// ❌ 普通数据类,Compose 不确定是否稳定
data class User(val name: String, val age: Int)

// ✅ 标注 @Immutable,Compose 信任此类,相同实例不触发重组
@Immutable
data class User(val name: String, val age: Int)

@Composable
fun UserCard(user: User) {
    // user 未变化时,整个 UserCard 被跳过
    Text(user.name)
}

@Stable

告知 Compose:此类的 == 结果可信,且公开属性变化时会通知 Compose。 适合属性是 var 但受控变化的类:

@Stable
class UiState {
    var isLoading by mutableStateOf(false)   // var,但通过 State 通知变化
    var data by mutableStateOf<List<Item>>(emptyList())
    var error by mutableStateOf<String?>(null)
}

// @Stable 告知 Compose:
// 1. 两个实例 == 相等 → 结果可信
// 2. 属性变化会通过 State 通知
// → Compose 可以安全地做跳过判断

实践:数据类加 @Immutable

// 场景:ViewModel 暴露 UiState 给 Composable
// ❌ 普通数据类:每次 copy() 都是新对象,内容相同也触发重组
data class HomeUiState(
    val title: String = "",
    val items: List<Item> = emptyList(),
    val isLoading: Boolean = false
)

// ✅ 加 @Immutable:内容相同时跳过重组
@Immutable
data class HomeUiState(
    val title: String = "",
    val items: List<Item> = emptyList(),
    val isLoading: Boolean = false
)

// ViewModel
class HomeViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(HomeUiState())
    val uiState = _uiState.asStateFlow()

    fun updateTitle(title: String) {
        _uiState.update { it.copy(title = title) }
        // 若 items 和 isLoading 未变,使用 @Immutable 的 HomeUiState
        // 则读取 items 的 Composable 不会重组
    }
}

State 读取位置优化

读取 State 的位置决定了重组的范围。 越晚读取,影响范围越小。

三个阶段读取的影响

Composition 阶段读取 State → 触发 Composition + Layout + Drawing(最大影响)
Layout 阶段读取 State      → 触发 Layout + Drawing(跳过 Composition)
Drawing 阶段读取 State     → 只触发 Drawing(最小影响,最优)

把 State 读取推迟到 Drawing 阶段

var color by remember { mutableStateOf(Color.Red) }

// ❌ Composition 阶段读取:color 变化触发整个 Composable 重组
Box(modifier = Modifier.background(color).size(100.dp))

// ✅ Drawing 阶段读取:color 变化只触发重绘,不重组
Box(
    modifier = Modifier
        .size(100.dp)
        .drawBehind {
            drawRect(color)   // Drawing 阶段才读取 color
        }
)

把 State 读取推迟到 Layout 阶段

var offsetY by remember { mutableStateOf(0f) }

// ❌ Composition 阶段读取:触发重组
Modifier.offset(y = offsetY.dp)

// ✅ Layout 阶段读取:跳过 Composition,只做 Layout + Drawing
Modifier.offset { IntOffset(0, offsetY.roundToInt()) }

动画场景的最优实践

// 透明度动画:用 graphicsLayer,完全在 GPU 层,不触发任何 Compose 阶段
val alpha by animateFloatAsState(if (visible) 1f else 0f)

// ❌ Composition 阶段读取
Box(modifier = Modifier.alpha(alpha))        // .alpha() 在 Composition 读取

// ✅ graphicsLayer:Drawing 阶段读取,且由 GPU 直接执行变换
Box(modifier = Modifier.graphicsLayer { this.alpha = alpha })

derivedStateOf

适用场景:从高频变化的 State 派生低频变化的结果,过滤掉中间无意义的重组。

原理

没有 derivedStateOf:
  count: 0 → 1 → 2 → 3 → 4 → 5 ...
  isEven: 触发重组 × 每次 count 变化(全部重组)

有 derivedStateOf:
  count: 0 → 1 → 2 → 3 → 4 → 5 ...
  isEven: false → true → false → true ...(只在结果变化时重组)
// ❌ count 每次变化都触发读取了 showScrollTop 的 Composable 重组
val showScrollTop = listState.firstVisibleItemIndex > 0

// ✅ firstVisibleItemIndex 从 0→1 才触发重组(false→true)
//    从 1→2,2→3 时 showScrollTop 仍为 true,不触发重组
val showScrollTop by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

表单校验场景

@Composable
fun RegisterForm() {
    var name  by remember { mutableStateOf("") }
    var email by remember { mutableStateOf("") }
    var phone by remember { mutableStateOf("") }

    // ❌ 每次输入字符都重组读取 canSubmit 的 Composable
    val canSubmit = name.isNotBlank() && email.contains("@") && phone.length == 11

    // ✅ 只有校验结果(true/false)变化时才重组
    val canSubmit by remember {
        derivedStateOf {
            name.isNotBlank() && email.contains("@") && phone.length == 11
        }
    }

    // 提交按钮:只在 canSubmit 变化时重组(从不可点击 → 可点击,或反向)
    Button(enabled = canSubmit, onClick = { }) { Text("注册") }
}

derivedStateOf 使用原则

需要 derivedStateOf 的条件:
  1. 输入 State 变化频率 > 输出结果变化频率
  2. 输出是布尔值(true/false)、枚举、有限状态

不需要 derivedStateOf 的场景:
  输入变化必然导致输出变化 → 用 remember(key) 即可
  val doubled = remember(count) { count * 2 }  // count 变化,doubled 必然变化

key 的正确使用

LazyList 中的 key

没有 key 时,LazyList 按位置匹配旧项,插入/删除会导致大量不必要重组:

// ❌ 无 key:在列表头部插入一项,所有项都重组
LazyColumn {
    items(users) { user ->
        UserRow(user)
    }
}

// ✅ 有 key:Compose 按 ID 匹配,只有真正变化的项重组
LazyColumn {
    items(users, key = { it.id }) { user ->
        UserRow(user)
    }
}
插入前:[A, B, C]         插入前:[A, B, C]
插入后:[X, A, B, C]      插入后:[X, A, B, C]

无 key(按位置匹配):      有 key(按 ID 匹配):
  位置0: A→X  重组 ❌        X: 新建
  位置1: B→A  重组 ❌        A: 复用,不重组 ✅
  位置2: C→B  重组 ❌        B: 复用,不重组 ✅
  位置3: 新建C               C: 复用,不重组 ✅

key() 函数控制 Composable 身份

// 当数据的逻辑意义变化时,用 key() 强制重建(而非复用旧状态)
@Composable
fun UserProfile(userId: String) {
    key(userId) {
        // userId 变化时,内部所有 remember 被清空,重新初始化
        var isExpanded by remember { mutableStateOf(false) }
        ProfileContent(userId, isExpanded)
    }
}

// 不用 key 的问题:
// userId 从 "A" 变为 "B" 时,isExpanded 的旧值(true)被错误复用

Lambda 稳定性

Lambda 是 Compose 中最常见的不稳定来源。

问题:每次重组 Lambda 都是新对象

@Composable
fun ParentScreen() {
    var count by remember { mutableStateOf(0) }

    // ❌ 每次 ParentScreen 重组,都创建新的 onClick lambda 对象
    // 导致 ChildButton 参数"变化",触发不必要重组
    ChildButton(onClick = { println("clicked, count=$count") })
}

@Composable
fun ChildButton(onClick: () -> Unit) {
    Button(onClick = onClick) { Text("Click") }
}

解法一:rememberUpdatedState(Lambda 不参与重组判断)

@Composable
fun ParentScreen() {
    var count by remember { mutableStateOf(0) }
    val latestCount by rememberUpdatedState(count)

    // ✅ 稳定的 lambda 引用,内部通过 latestCount 读取最新值
    val onClick = remember {
        { println("clicked, count=${latestCount}") }
    }

    ChildButton(onClick = onClick)
}

解法二:将 Lambda 移到 ViewModel

// ✅ ViewModel 中的函数引用是稳定的
class MyViewModel : ViewModel() {
    var count by mutableStateOf(0)
        private set

    fun onButtonClick() {
        println("clicked, count=$count")
        count++
    }
}

@Composable
fun ParentScreen(viewModel: MyViewModel = viewModel()) {
    // viewModel::onButtonClick 是方法引用,在 Kotlin 中每次都是新对象
    // 但 ViewModel 本身稳定,Compose 编译器可以优化
    ChildButton(onClick = viewModel::onButtonClick)
}

解法三:@Stable 的函数持有类

@Stable
class ClickHandler(private val viewModel: MyViewModel) {
    val onClick: () -> Unit = { viewModel.onButtonClick() }
}

@Composable
fun ParentScreen(viewModel: MyViewModel = viewModel()) {
    val handler = remember(viewModel) { ClickHandler(viewModel) }
    // handler.onClick 是稳定对象上的稳定属性
    ChildButton(onClick = handler.onClick)
}

List 稳定性问题

List<T> 接口本身是不稳定的,即使内容没变,Compose 也无法确定,会触发重组。

方案一:@Immutable 包装

@Immutable
data class ImmutableList<T>(val items: List<T>)

// ✅ ImmutableList 是稳定的,内容相同时跳过重组
@Composable
fun ItemList(list: ImmutableList<String>) {
    list.items.forEach { Text(it) }
}

方案二:使用 kotlinx.collections.immutable

// build.gradle
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.7")

// PersistentList 被 Compose 编译器识别为稳定类型
val items: PersistentList<String> = persistentListOf("A", "B", "C")

@Composable
fun ItemList(items: PersistentList<String>) {
    // items 类型稳定,内容相同时跳过
    items.forEach { Text(it) }
}

// ViewModel 中
class ListViewModel : ViewModel() {
    private val _items = MutableStateFlow(persistentListOf<Item>())
    val items = _items.asStateFlow()

    fun addItem(item: Item) {
        _items.update { it.add(item) }   // 返回新的不可变列表
    }
}

方案三:Compose 编译器配置(强推断稳定性)

// compose_compiler_config.conf
// 强制将特定类视为稳定(适合无法修改源码的第三方库)

// build.gradle
composeCompiler {
    stabilityConfigurationFile = rootProject.layout.projectDirectory
        .file("compose_compiler_config.conf")
}

// compose_compiler_config.conf 内容:
// com.example.MyClass  (将此类视为稳定)

拆分 Composable

把读取不同 State 的 UI 拆分成独立的 Composable,让重组范围最小化。

问题:大 Composable 包含多个 State

// ❌ 一个大 Composable 读取多个 State
// 任意一个 State 变化,整个 Screen 重组
@Composable
fun BadScreen(viewModel: MyViewModel) {
    val userState by viewModel.userState.collectAsState()
    val listState by viewModel.listState.collectAsState()
    val badgeCount by viewModel.badgeCount.collectAsState()

    Column {
        // badgeCount 变化 → 整个 Column 重组,包括复杂的 UserHeader 和 ItemList
        Badge(count = badgeCount)
        UserHeader(user = userState.user)
        ItemList(items = listState.items)
    }
}

解法:拆分为独立 Composable

// ✅ 每个 Composable 只读取自己需要的 State
@Composable
fun GoodScreen(viewModel: MyViewModel) {
    Column {
        BadgeSection(viewModel)    // 只读 badgeCount
        UserSection(viewModel)     // 只读 userState
        ListSection(viewModel)     // 只读 listState
    }
}

@Composable
private fun BadgeSection(viewModel: MyViewModel) {
    val badgeCount by viewModel.badgeCount.collectAsState()
    Badge(count = badgeCount)
    // badgeCount 变化 → 只有 BadgeSection 重组
}

@Composable
private fun UserSection(viewModel: MyViewModel) {
    val userState by viewModel.userState.collectAsState()
    UserHeader(user = userState.user)
    // userState 变化 → 只有 UserSection 重组
}

@Composable
private fun ListSection(viewModel: MyViewModel) {
    val listState by viewModel.listState.collectAsState()
    ItemList(items = listState.items)
    // listState 变化 → 只有 ListSection 重组
}

将 State 读取下移(State Deferral)

// ❌ 父层读取 State,子层传参(父层重组带动子层重组)
@Composable
fun Parent() {
    val scrollOffset by scrollState.collectAsState()
    ToolbarWithAlpha(alpha = scrollOffset / 300f)  // 父层读取,父层重组
}

// ✅ 传递 lambda,子层按需读取(只有子层重组)
@Composable
fun Parent() {
    // 把读取行为下沉到子组件内部
    ToolbarWithAlpha(alphaProvider = { scrollState.value / 300f })
}

@Composable
fun ToolbarWithAlpha(alphaProvider: () -> Float) {
    Box(
        modifier = Modifier.graphicsLayer {
            alpha = alphaProvider()   // Drawing 阶段才读取,只触发重绘
        }
    )
}

graphicsLayer 跳过重组

graphicsLayer 内的属性变化完全在 GPU 层处理,不触发 Compose 任何阶段

// 动画场景:所有变换用 graphicsLayer
val transition = rememberInfiniteTransition()
val scale by transition.animateFloat(0.8f, 1.2f, infiniteRepeatable(tween(1000)))
val alpha by transition.animateFloat(0.5f, 1.0f, infiniteRepeatable(tween(800)))

// ✅ scale 和 alpha 每帧变化,但不触发 Composable 重组
// 变换直接由 RenderNode → GPU 完成
Image(
    painter = painterResource(R.drawable.logo),
    contentDescription = null,
    modifier = Modifier.graphicsLayer {
        scaleX = scale
        scaleY = scale
        this.alpha = alpha
    }
)

Modifier 优化

避免在 Modifier 中创建新对象

// ❌ 每次重组都创建新的 RoundedCornerShape 对象
Box(modifier = Modifier.clip(RoundedCornerShape(8.dp)))

// ✅ 用 remember 缓存
val shape = remember { RoundedCornerShape(8.dp) }
Box(modifier = Modifier.clip(shape))

避免 Modifier 依赖频繁变化的 State

var offset by remember { mutableStateOf(Offset.Zero) }

// ❌ 每次 offset 变化,Modifier 重新创建,触发 Layout
Modifier.offset(x = offset.x.dp, y = offset.y.dp)

// ✅ lambda 版本,在 Layout 阶段读取,跳过 Composition
Modifier.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }

工具:检测无效重组

方式一:Layout Inspector(推荐)

Android Studio → View → Tool Windows → Layout Inspector
打开 "Recomposition Counts" 开关
→ 实时显示每个 Composable 的重组次数和跳过次数
→ 重组次数异常高的节点就是优化目标

方式二:Compose 编译器报告

// build.gradle
composeCompiler {
    reportsDestination = layout.buildDirectory.dir("compose_reports")
    metricsDestination = layout.buildDirectory.dir("compose_metrics")
}

// 执行:./gradlew assembleRelease
// 查看 build/compose_reports/ 下的报告
// 报告内容:每个类的稳定性分析,哪些参数是 unstable

报告示例:

restartable skippable scheme("[androidx.compose.ui.UiComposable]")
fun UserCard(
  stable user: User           ← 稳定,可跳过
  unstable items: List<Item>  ← 不稳定!导致无法跳过
)

方式三:添加重组计数器(调试用)

// 调试 Composable 重组次数
@Composable
fun RecompositionCounter(name: String) {
    val count = remember { mutableIntStateOf(0) }
    SideEffect {
        // SideEffect 在每次成功重组后执行
        count.intValue++
        println("[$name] 重组次数: ${count.intValue}")
    }
}

// 使用
@Composable
fun MyComposable() {
    RecompositionCounter("MyComposable")
    // ...
}

方式四:使用 Compose Multipreview + 慢速动画

// 在调试包中开启慢速动画,便于肉眼观察重组范围
// local.properties 或 debug 代码:
// android.compose.animation.debugSlowAnimation=true

总结速查表

问题与解法对照

问题原因解法
父组件重组带动子组件重组子组件参数不稳定@Immutable / @Stable
List 参数导致总是重组List<T> 接口不稳定@Immutable 包装 / PersistentList
颜色/透明度动画触发重组Composition 阶段读取drawBehind / graphicsLayer
位移动画触发重组Composition 阶段读取offset { } lambda 版本
高频 State 触发多余重组直接读取高频 StatederivedStateOf
Lambda 参数每次不同每次重组创建新 lambdarememberUpdatedState / 移到 ViewModel
大组件任意 State 变化全重组State 读取集中在顶层拆分 Composable / State Deferral
列表插入触发所有项重组无 key,按位置匹配items(key = { it.id })
用户 ID 变化旧状态残留没有强制重建key(userId) { }
Modifier 中创建新对象每次重组重新创建remember { Shape/Paint }

优化优先级

优先级 ①(影响最大,最容易忽略):
  → State 读取位置:尽量推迟到 Drawing 阶段

优先级 ②(最常见问题):
  → 参数稳定性:@Immutable / @Stable / PersistentList

优先级 ③(高频场景):
  → derivedStateOf:过滤高频 State 的无效重组

优先级 ④(架构层面):
  → 拆分 Composable:让重组范围最小化

优先级 ⑤(细节优化):
  → Lambda 稳定性、key 正确使用、Modifier 缓存

黄金法则

1. 能在 Drawing 阶段读 State,就不在 Composition 阶段读
2. 能在 Layout 阶段读 State,就不在 Composition 阶段读
3. 传给子组件的参数,尽量是稳定类型
4. 高频变化的 State → derivedStateOf → 低频触发重组
5. 大 Composable → 拆小 → 每个只读自己需要的 State
6. 先用工具确认真的有问题,再优化(不要过早优化)

Giscus 未启用:请在 src/site.config.ts 中配置 repoId 与 categoryId。