第1章 State Hoisting 核心概念:状态上提、重组触发条件、Compose 稳定类型

本章是《State Hoisting 避免重组合实战》的基石,围绕三个紧密关联的核心概念展开:状态上提(State Hoisting)、重组触发条件与Compose 稳定类型。只有透彻理解这三者的定义、原理与协作关系,才能在后续章节中精准定位过度重组的根因,并运用状态上提模式写出高性能、可维护的 Compose UI。

1.1 状态上提(State Hoisting)

定义:状态上提是一种将状态从子可组合项“提升”到调用方(父可组合项或 ViewModel)的设计模式。上提后的子组件变为无状态(Stateless),仅通过参数接收数据,通过回调触发事件,从而彻底解耦状态的读写职责。

核心原则:单向数据流——状态向下传递,事件向上传递。

状态上提的三大收益

  1. 可复用性:无状态组件可在不同父组件中直接复用,无需关心内部状态细节。
  2. 可测试性:状态逻辑集中在父组件或 ViewModel 中,可独立进行单元测试。
  3. 重组可控:状态变化引发的重组范围被限制在真正依赖该状态的组件树内。

实现流程(两步法)

  1. 将子组件内的 remember + mutableStateOf 移除,改为 val value: T 参数与 onEvent: (T) -> Unit 回调。
  2. 在父组件中通过 remember 持有状态,并将状态值传给子组件,同时传入更新回调。

代码示例:反模式 vs 正模式

// ====== 反模式:状态困在子组件中,无法复用 ======
@Composable
fun BadCounter() {
    var count by remember { mutableStateOf(0) }
    Button(onClick = { count++ }) {
        Text("点击了 $count 次")
    }
}

// ====== 正模式:状态上提,子组件无状态 ======
@Composable
fun GoodCounter(
    count: Int,
    onIncrement: () -> Unit
) {
    Button(onClick = onIncrement) {
        Text("点击了 $count 次")
    }
}

@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    // 可以复用多个 GoodCounter,共享同一状态
    GoodCounter(count = count, onIncrement = { count++ })
    GoodCounter(count = count, onIncrement = { count++ })
}

1.2 重组触发条件

Compose 的重组(Recomposition)是指当可观察状态发生变化时,框架重新执行受影响的可组合项 lambda,以更新 UI 树。准确理解触发条件,是避免“过度重组”的第一步。

三大触发条件

触发条件 说明 示例
State 值变化 通过 mutableStateOf / MutableStateFlow 等持有的状态发生写操作。 count++ 触发读取 count 的组件重组
传入参数变化 可组合项的 key 参数或普通参数的 equals 比较结果为 false。 Text(text = name) 中 name 变化
Snapshot 失效 Compose 的 Snapshot 系统标记了某个状态读取记录为“脏”。 跨协程修改状态时自动触发

重组范围规则:只有读取了变化状态的可组合项才会被标记为“需要重组”。未读取该状态的兄弟或父组件会跳过重组。

// 演示重组范围 —— 仅 Text 读取了 count,Button 不重组
@Composable
fun RecompositionScopeDemo(count: Int, onIncrement: () -> Unit) {
    Text("当前计数: $count")   // count 变化时重组
    Button(onClick = onIncrement) {
        Text("增加")           // 不读取 count,不重组
    }
}

调试技巧:在 Android Studio 中开启 “Show Recomposition”(Layout Inspector → 齿轮图标 → Show borders / Show recomposition),闪烁的边框即表示发生重组的组件。

1.3 Compose 稳定类型

重组触发条件中提到“参数变化通过 equals 比较”。但如果一个类型被 Compose 判定为不稳定(Unstable),则即使 equals 返回 true,Compose 仍会保守地触发重组。这就是“稳定类型”存在的意义。

分类与注解

注解 语义 编译期影响 典型场景
@Immutable 所有属性均为不可变(val + 非可变类型) Compose 认为该类型永远不变,跳过重组 data class 全部使用 val + 基础类型
@Stable 属性可变,但 Compose 可追踪其变化 Compose 信任 equals 结果,仅在变化时重组 ViewModel 中的 StateFlow;含 MutableState 的类
未标注(默认) Compose 无法推断稳定性 每次传入新实例都触发重组 普通 data class 含 var 字段
// ====== @Immutable 示例 ======
@Immutable
data class ImmutableUser(val name: String, val age: Int)

@Composable
fun UserProfile(user: ImmutableUser) {
    Text("${user.name} - ${user.age}")  // 仅当 user 实例变化时重组
}

// ====== @Stable 示例(含可变状态) ======
@Stable
class MutableUser(name: String, age: Int) {
    var name by mutableStateOf(name)
    var age by mutableStateOf(age)
}

@Composable
fun MutableUserProfile(user: MutableUser) {
    Text("${user.name} - ${user.age}")  // 属性变化时精准重组
}

// ====== 不稳定类型 —— 会触发过度重组 ======
data class UnstableUser(var name: String, var age: Int)
// 即使 name/age 未变,传入新实例也会导致重组

常见错误:在 data class 中使用 var 字段且未加 @Stable,会导致 Compose 将其标记为不稳定。每次父组件重组时,即使数据未变,子组件也会被迫重组。解决方案:使用 @Immutable + val,或 @Stable + mutableStateOf。

1.4 三者关系与实战对照

以下 SVG 架构图直观展示了状态上提、重组触发条件与稳定类型在 Compose 组件树中的定位与协作关系。

State Hoisting 核心架构 ← 上一章 📖 返回目录 下一章 →