本课目标:从编译器视角理解 @Composable 的本质,掌握重组机制的工作原理与性能优化手段,熟练运用状态管理核心 API,建立 Modifier 链式思维,理解副作用 API 的使用时机。本课是第1课的"发动机拆解",也是后续所有课程的语法地基。

系列整体规划

课次主题核心内容难度
第1课从零开始技术概览、环境搭建、第一个应用、代码解读⭐
第2课Compose 基础语法@Composable、状态管理、重组机制、Modifier 体系⭐⭐
第3课布局与组件Column/Row/Box、LazyColumn、Material3 组件库⭐⭐
第4课导航与路由Navigation Compose、类型安全路由、深层链接⭐⭐⭐
第5课网络与数据层Ktor 客户端、序列化、Repository 模式⭐⭐⭐
第6课状态管理与架构ViewModel、单向数据流、依赖注入⭐⭐⭐⭐
第7课平台适配与互操作expect/actual、SwiftUI 互操作、平台特定 API⭐⭐⭐⭐
第8课资源管理与主题多平台资源、图片加载、深浅色主题⭐⭐⭐
第9课测试与调试Compose UI 测试、单元测试、性能分析⭐⭐⭐⭐
第10课发布与部署Android/iOS/桌面/Web 打包发布、CI/CD⭐⭐⭐⭐⭐

第2课 Compose 基础语法

一、@Composable 函数的本质

1.1 一个思想实验:为什么不用 React 的模型

先做一个思想实验。React 的做法是:函数返回一个虚拟 DOM 对象,运行时负责 diff 和更新。这个模型的缺陷是——每次状态变化都要重建整棵虚拟 DOM 树,然后对比新旧树。虽然 React 用 memo、useMemo 等优化手段减少重建,但本质上无法避免"重建—对比"的开销。

Compose 选择了另一条路:让函数本身在状态变化时被重新执行,但运行时追踪哪些函数需要重新执行。 要实现这一点,函数必须携带运行时上下文——这就是 @Composable 注解存在的原因。它不是为了"标记"而存在,而是为了让编译器能在函数签名中注入组合上下文。

1.2 编译器插件的四项转换

当你写下 @Composable fun Greeting() { Text("Hello") },Kotlin 编译器插件在背后做了四件事:

第一,注入隐式的 Composer 参数。 函数签名被改写为 fun Greeting($composer: Composer)。你写的每一个 Text()、Button() 调用,编译器都会自动把 $composer 传递下去。这就是为什么普通函数不能调用 Composable——普通函数没有这个参数,编译器无法在调用链中传递组合上下文。

第二,生成分组(Group)信息。 编译器在函数体内插入 startGroup / endGroup 调用。这些分组构成了 Slot Table 的骨架——一个在内存中维护组合状态的核心数据结构。Slot Table 记录了每个 Composable 的调用位置、参数、状态引用、重组作用域等信息。

第三,包装函数体以支持跳过(Skipping)。 编译器生成"是否需要跳过重组"的判断逻辑。如果函数的输入参数没有变化,且没有读取变化的状态,运行时可以跳过该函数的重新执行。这是 Compose 性能优化的基石。

第四,支持默认参数在分组内求值。 Compose 对默认参数的处理比较特殊——默认值的计算被放在生成的 group 作用域内执行,而不是依赖 Kotlin 原生的默认参数机制。这也是为什么某些情况下 Composable 函数的默认参数会引发构建失败(尤其是 Kotlin/Native 场景下,公开的 Composable 函数带默认参数可能无法编译为静态库,需要标记为 internal)。

1.3 三条调用规则

理解了编译器的转换,就能理解 Composable 函数的调用规则:

规则一:@Composable 可以调用 @Composable 和普通函数,普通函数不能调用 @Composable。 因为普通函数没有 Composer 参数,编译器无法在其中传递组合上下文。

规则二:Composable 不能从普通 lambda 中调用。 比如 onClick = { Greeting() } 是不允许的,因为回调 lambda 被编译为普通函数,不携带 Composer。正确做法是改变状态,让重组自然发生。如果确实需要在回调中执行组合逻辑,使用 rememberCoroutineScope 或状态驱动。

规则三:Composable 可以返回值,但通常不返回。 语法上允许,但返回值的 Composable 无法被编译器正确追踪状态依赖,实践中应避免。Composable 的"返回"是 UI 描述,通过副作用写入 UI 树,而非通过返回值。

1.4 稳定性(Stability):跳过重组的前提

Compose 的智能重组依赖于输入参数的稳定性判断。如果一个类型被认为是"稳定的",Compose 就可以安全地比较其新旧实例,决定是否跳过重组。

稳定类型的条件:

  • equals() 对同一对实例始终返回相同结果
  • 类型的公开属性变化时,Composition 能被通知到
  • 所有公开属性都是稳定类型(或 MutableState)

默认稳定的类型:所有基本类型(Int、String、Boolean 等)、函数类型、MutableState。MutableState 是"可观察的稳定类型"——虽然它的值会变化,但 Compose 能收到通知,因此可以安全地依赖它做跳过判断。

自定义类型的推断:Compose 编译器会自动推断稳定性。如果类的所有构造参数都是稳定类型,该类被推断为稳定;如果包含 var 属性,则被推断为不稳定。你也可以用 @Stable 或 @Immutable 注解手动标记。

不稳定类型的性能代价:如果父 Composable 的参数是不稳定类型,每次父重组都会导致子 Composable 重新执行,无法跳过。这是 Compose 性能问题的常见根源之一。解决方式是用 @Immutable 注解标记、将 var 改为 val、或使用 immutableListOf 等稳定集合类型。

一条实用经验:当你发现某个 Composable 在日志中频繁重组,但它的输入并没有变化,第一个要检查的就是参数类型是否稳定。用 Compose 编译器报告(-P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=...)可以看到每个 Composable 的稳定性推断结果。

二、重组机制

2.1 什么是重组

重组(Recomposition)是 Compose 响应状态变化、重新执行受影响的 Composable 函数、更新 UI 的过程。

核心规则只有一条:只有读取了变化状态的 Composable 函数才会被重新执行。 如果某个 Composable 声明了状态但没有读取它的值,状态变化不会触发该函数的重组。这条规则决定了 Compose 的性能上限——重组范围由状态读取位置决定,而非由状态声明位置决定。

2.2 重组作用域

重组作用域(RecomposeScope)是 Compose 运行时管理的最小重组单元。编译器在编译期为每个可能独立重组的代码区域创建一个 RecomposeScope。

一个关键细节是:Column、Row、Box 等布局组件是内联函数。这意味着它们在编译后并不存在独立的函数调用层级,不会形成单独的重组作用域。实际的层级结构由这些内联函数展开后的代码决定。

理解这一点很重要。很多初学者以为"把 UI 拆成 Column 就能限制重组范围",实际上 Column 是内联的,拆分 Column 并不天然形成独立重组作用域。真正决定重组范围的是状态读取点和编译器生成的分组边界。

2.3 重组的工作流程

当状态发生变化时,运行时按以下步骤处理:

步骤一:状态修改被 Snapshot 系统捕获。 mutableStateOf 的 value 写入发生在 Snapshot 中。当 Snapshot 被 apply 时,修改的状态对象集合被收集。

步骤二:查找受影响的重组作用域。 Composition 维护一个观察映射:哪个状态对象被哪些 RecomposeScope 读取过。状态变化后,所有读取过该状态的作用域被标记为"无效"(invalidated)。

步骤三:调度重组。 Recomposer 接收无效化通知,将需要重组的作用域加入工作队列,在下一个帧时钟 tick 时执行重组。

步骤四:执行重组并应用变更。 重组执行器重新运行受影响的 Composable 函数,Composer 对比新旧 Slot Table,检测插入、删除、移动等操作,最后通过 Applier 将变更应用到 UI 树。

2.4 跳跃(Skipping)机制

如果一个 Composable 函数的输入参数没有变化,且它没有读取任何变化的状态,运行时可以跳过它的重新执行。

跳跃的条件:

  • 所有参数都是稳定类型,且新旧值 equals() 返回 true
  • 函数内部没有读取变化的状态

这是 Compose 性能的关键保障。一个设计良好的 Composable 树,在状态变化时只重新执行真正需要更新的少数节点。如果你的应用出现性能问题,通常是因为大量 Composable 因为不稳定参数而无法跳过,导致重组范围远超必要。

三、状态管理核心 API

3.1 mutableStateOf 与 remember

mutableStateOf 创建可观察状态,remember 在重组间保留状态。二者组合是 Compose 状态管理的基础。

关键理解:remember 的底层实现依赖 Slot Table。首次组合时,值被存入 Slot Table 的对应 group;后续重组时,运行时直接从 Slot Table 读取,跳过初始化 lambda 的执行。这就是为什么 remember 的初始化 lambda 只在首次组合时执行。

3.2 rememberSaveable

remember 保留重组间的状态,但无法跨配置更改(如 Android 屏幕旋转)或进程恢复。rememberSaveable 弥补了这一缺口。

rememberSaveable 通过 SaveableStateRegistry 将状态保存到平台特定的持久化机制中(Android 上是 Bundle,桌面端和 iOS 有各自的实现)。当界面重建时,状态自动恢复。

用法与 remember 几乎一致:

val userInput = rememberSaveable { mutableStateOf("") }

如果状态类型不是基本类型或 Parcelable,需要提供自定义 Saver:

val HolderSaver = Saver<Holder, Int>(
    save = { it.value },
    restore = { Holder(it) }
)
val holder = rememberSaveable(saver = HolderSaver) { Holder(0) }

3.3 derivedStateOf

derivedStateOf 从一个或多个状态中派生出一个新状态。只有当派生值发生实际变化时,才触发重组。

经典场景:你有一个高频变化的状态 value,但 UI 只关心它的正负。直接用 value 驱动 UI,每次变化都重组;用 derivedStateOf 派生出 isPositive,只有正负切换时才重组。

val isPositive by remember {
    derivedStateOf { value >= 0 }
}

注意:derivedStateOf 本身需要用 remember 包裹,否则每次重组都会创建新的派生状态实例。另外,derivedStateOf 的结果不能被 rememberSaveable 直接保存——它不能被序列化,需要配合自定义 Saver。

3.4 mutableStateListOf 与 mutableStateMapOf

mutableStateListOf 创建一个可观察的列表,mutableStateMapOf 创建一个可观察的映射。添加、删除、替换元素会自动触发读取该集合的 Composable 重组。

关键细节:mutableStateListOf 返回的是 SnapshotStateList,它对列表的读取和写入是细粒度的——读取 list[0] 只会追踪索引 0 的变化,而不是整个列表。这使得 LazyColumn 能够高效地只重组受影响的列表项。

3.5 状态应该放在哪里

这是 Compose 状态管理的核心设计问题。原则如下:

状态尽量放在靠近读取点的位置。 状态越靠近读取点,重组范围越小。如果状态放在顶层,任何读取该状态的层级都会受重组影响。

状态提升(State Hoisting) :当多个 Composable 需要共享状态,或状态需要跨组件传递时,将状态提升到最近的共同祖先。状态提升的标准模式是:父组件持有状态,子组件通过参数接收状态和回调。

状态容器(State Holder) :当状态逻辑复杂到一定程度(多个相关状态、需要校验、需要持久化),将状态和逻辑封装到一个普通的类中,用 remember 持有。这是 ViewModel 模式在 Compose 中的轻量替代。

class LoginStateHolder {
    var username by mutableStateOf("")
    var password by mutableStateOf("")
    val canSubmit: Boolean
        get() = username.isNotBlank() && password.isNotBlank()
}

@Composable
fun LoginScreen() {
    val holder = remember { LoginStateHolder() }
    // ...
}

一个常见的误解:状态提升不等于把状态放到最顶层。状态提升的目的是"让需要读取状态的组件能够读取到",而不是"把所有状态集中管理"。过度提升会导致顶层重组带动整棵树重组。正确做法是:状态放在最近的共同祖先,而不是最顶层。

四、Modifier 体系

4.1 Modifier 的本质

Modifier 是 Compose 的修饰符链。每个 Modifier 节点对应一个 Modifier.Element,不同类型的 Element 实现不同的能力:布局、绘制、指针输入、语义等。

Modifier 链在概念上是从左到右依次包装的。靠左的 Modifier 更靠近"外层",先接收父级传递的约束,影响后续 Modifier 的可用空间。

理解这个"洋葱模型"是掌握 Modifier 的关键。想象 Modifier 链是一层层包裹的洋葱,最左边是洋葱的最外层,最右边是最内层。每一层都可以改变传递给下一层的约束,也可以改变从内层返回的尺寸。

4.2 顺序决定行为

Modifier 的顺序直接影响布局、绘制和交互结果。几个关键对比:

padding 与 background 的顺序:

// 先 padding 后 background:背景覆盖 padding 区域,色块更大
Modifier.size(120.dp).padding(16.dp).background(Color.Blue)

// 先 background 后 padding:背景铺满整个尺寸,padding 只推内容
Modifier.size(120.dp).background(Color.Blue).padding(16.dp)

clickable 与 padding 的顺序:

// 先 clickable 再 padding:点击区域包含 padding 留白,更易点
Modifier.fillMaxWidth().clickable { }.padding(24.dp)

// 先 padding 再 clickable:点击区域被限制在 padding 内层
Modifier.fillMaxWidth().padding(24.dp).clickable { }

size 与 padding 的顺序:

// 先 size 后 padding:总尺寸 100dp,padding 在内部分配
Modifier.size(100.dp).padding(16.dp)

// 先 padding 后 size:内容 100dp,加上 padding 后总尺寸更大
Modifier.padding(16.dp).size(100.dp)

4.3 常用 Modifier 分类

类别示例说明
尺寸与约束fillMaxWidth()、size()、heightIn()、weight()weight 只在 Row/Column scope 可用
内边距padding()影响自身占位和子内容约束
绘制background()、border()按链顺序决定绘制层
交互clickable()、scrollable()命中区域受前后 Modifier 影响
滚动verticalScroll()、horizontalScroll()与 Lazy 组件不同,一次性组合所有子项
变换rotate()、scale()、alpha()影响绘制,不影响布局
裁剪clip()影响子内容的绘制边界

4.4 提取与复用

Modifier 链可以提取到变量或函数中复用,既提升可读性,也可能通过复用实例提升性能:

val cardModifier = Modifier
    .fillMaxWidth()
    .padding(8.dp)
    .clickable { onClick() }

注意:Modifier 是不可变的,每次调用 Modifier.fillMaxWidth() 都会创建新实例。在 @Composable 函数中提取 Modifier 到 remember 中,可以避免每次重组都创建新实例:

val cardModifier = remember {
    Modifier.fillMaxWidth().padding(8.dp)
}

但要小心:如果 Modifier 中引用了会变化的参数(如 onClick),不能无脑 remember,否则会捕获旧值。此时应使用 Modifier.composed { } 或在提取时显式传入参数。

五、副作用与生命周期

5.1 为什么需要副作用 API

Composable 函数应该是纯函数——根据输入产生 UI,不产生外部可见的副作用。但实际开发中,我们需要发起网络请求、注册监听器、读取 Flow、操作外部状态。这些操作需要专门的 API 在受控的时机执行。

"受控的时机"是关键词。Composable 函数会被频繁重新执行,如果把网络请求直接写在函数体中,每次重组都会发起新请求。副作用 API 的核心价值就是将副作用与 Composable 的生命周期对齐——进入组合时执行一次,离开组合时清理。

5.2 三个核心 API

LaunchedEffect:在 Composable 进入组合时启动一个协程,离开组合时自动取消。key 参数控制重启时机——key 不变则协程不重启,key 变化则取消旧协程、启动新协程。

LaunchedEffect(userId) {
    val data = repository.load(userId)
    state = data
}

DisposableEffect:用于需要清理的资源管理。onDispose 在 Composable 离开组合或 key 变化时调用,保证反注册逻辑一定执行。

DisposableEffect(listener) {
    eventBus.register(listener)
    onDispose { eventBus.unregister(listener) }
}

SideEffect:在每次组合成功应用到 UI 树之后执行。适合将 Compose 状态同步到非 Compose 世界,但不能挂起,也不应放重逻辑。

5.3 执行时机对比

API执行时机能否挂起自动取消
LaunchedEffect组合应用后启动协程能key 变化或离开组合时
DisposableEffect组合应用后执行,onDispose 在离开时否onDispose 保证调用
SideEffect每次组合应用后否无

5.4 rememberCoroutineScope

当需要从事件回调中启动协程(如按钮点击),使用 rememberCoroutineScope。它返回的协程作用域与当前 Composable 的生命周期绑定,离开组合时自动取消。

与 LaunchedEffect 的区别:LaunchedEffect 在进入组合时自动启动协程;rememberCoroutineScope 只提供作用域,由你决定何时 launch。

val scope = rememberCoroutineScope()
Button(onClick = {
    scope.launch {
        // 执行异步操作
    }
}) { Text("提交") }

5.5 跨平台注意事项

副作用 API 在 CMP 各平台上行为一致,但有一个跨平台细节需要注意:协程调度器。LaunchedEffect 默认使用 Dispatchers.Main。在 Android 上,Main 是 UI 线程;在桌面端,Main 是 AWT 事件调度线程;在 iOS 上,Main 是主线程。如果你在 LaunchedEffect 中执行阻塞操作,会阻塞 UI。正确做法是用 withContext(Dispatchers.Default) 切换到后台线程,或使用支持挂起的 API。

另一个跨平台细节:DisposableEffect 的清理时机在各平台上可能略有差异。在 Android 上,Activity 销毁时 Compositon 被 dispose;在桌面端,窗口关闭时 dispose;在 iOS 上,ComposeUIViewController 被释放时 dispose。如果你的清理逻辑依赖特定的平台生命周期,需要通过 expect/actual 做适配(第7课内容)。

六、习题与参考答案

本课习题分为三类:概念理解(1-6 题)、代码实践(7-12 题)、综合设计(13-15 题)。建议全部动手完成。

概念理解

习题 1:@Composable 的编译本质

题目:为什么普通函数不能调用 @Composable 函数?从编译器转换的角度解释。

参考答案:编译器为每个 @Composable 函数注入隐式的 Composer 参数。普通函数没有这个参数,无法在调用链中传递组合上下文。因此普通函数调用 @Composable 函数时,编译器会报错。

延伸理解:这个设计的巧妙之处在于,它把"组合上下文"的传递从运行时手动管理变成了编译期自动注入。React 需要开发者手动维护 hooks 调用顺序,而 Compose 通过编译器保证了正确性。

习题 2:重组作用域

题目:以下代码中,counter 变化时,哪些部分会重组?

@Composable
fun App() {
    var counter by remember { mutableStateOf(0) }
    Column {
        Text("Count: $counter")
        Text("Hello")
        Button(onClick = { counter++ }) { Text("Increment") }
    }
}

参考答案:Column 是内联函数,不形成独立重组作用域。实际重组范围是读取了 counter 的代码块。Text("Count: $counter") 读取了 counter,会重组。Text("Hello") 和 Button 没有读取 counter,如果参数稳定,会被跳过。但注意 Column 的 lambda 整体可能被标记为重组作用域,具体取决于编译器的分组优化。

延伸思考:这正是"状态读取点决定重组范围"的体现。如果把 counter 的读取移到更深层的子 Composable 中,重组范围会进一步缩小。

习题 3:remember 与 rememberSaveable

题目:什么场景下必须用 rememberSaveable 而不是 remember?

参考答案:当状态需要在配置更改(如 Android 屏幕旋转)或进程被系统回收后恢复时。remember 只保留重组间的状态,配置更改会导致整个 Composition 重建,remember 的值丢失。

延伸理解:rememberSaveable 的代价是序列化和反序列化开销。对于高频变化的大对象(如长列表),不适合用 rememberSaveable,应该只保存关键标识,重建时重新加载数据。

习题 4:derivedStateOf 的价值

题目:解释 derivedStateOf 为什么能减少重组次数。举一个具体例子。

参考答案:derivedStateOf 将"对原始状态的依赖"转换为"对派生值的依赖"。只有当派生值实际变化时才触发重组。例如:value 每秒变化 60 次,但 UI 只关心 value > 0。直接用 value 驱动 UI,每秒重组 60 次;用 derivedStateOf 派生 isPositive,只在正负切换时重组。

延伸理解:derivedStateOf 本质上是一种"状态投影"——把高频、细粒度的状态投影为低频、粗粒度的状态。它的适用场景是:原始状态变化频繁,但 UI 只关心其中一小部分特征。

习题 5:LaunchedEffect 的 key

题目:LaunchedEffect(Unit) 和 LaunchedEffect(userId) 在行为上有什么区别?

参考答案:LaunchedEffect(Unit) 的 key 永远不变,协程只启动一次,直到 Composable 离开组合才取消。LaunchedEffect(userId) 在 userId 变化时取消旧协程、启动新协程,适合需要响应参数变化的异步加载场景。

延伸思考:LaunchedEffect(Unit) 常用于"只在首次组合时执行一次"的场景,比如初始化、埋点上报。但要小心:如果 Composable 离开组合又重新进入,Unit 版本的协程会重新启动。如果确实需要"整个应用生命周期只执行一次",应该放在应用级的状态容器中。

习题 6:稳定性的性能影响

题目:为什么一个包含 var 属性的类会被 Compose 推断为不稳定?不稳定类型对性能有什么影响?

参考答案:var 属性意味着外部代码可以随时修改对象内容,而 Compose 无法感知这种修改,因此无法安全地比较新旧实例是否"相等"。不稳定类型导致 Composable 无法跳过重组——每次父组件重组时,即使传入的实例是同一个,Compose 也会重新执行子 Composable。

延伸理解:解决方式有三种:将 var 改为 val(推荐);用 @Immutable 注解手动标记(需要你保证不变性);用 MutableState 包装可变属性(让 Compose 能追踪变化)。

代码实践

习题 7:实现跳过重组

题目:创建两个 Composable:StableText 接收一个 String 参数,UnstableText 接收一个包含 var 属性的自定义类。观察哪个在父组件重组时会被跳过。

参考答案:

data class StableData(val name: String)
class UnstableData(var name: String)  // var 属性导致不稳定

@Composable
fun StableText(data: StableData) {
    Text(data.name)
}

@Composable
fun UnstableText(data: UnstableData) {
    Text(data.name)
}

@Composable
fun Parent() {
    var count by remember { mutableStateOf(0) }
    val stable = remember { StableData("A") }
    val unstable = remember { UnstableData("B") }
    Column {
        Button(onClick = { count++ }) { Text("$count") }
        StableText(stable)    // 可能被跳过
        UnstableText(unstable) // 不会被跳过
    }
}

StableText 的参数是稳定类型且 remember 保证实例不变,可跳过重组。UnstableText 的参数是不稳定类型,Compose 无法安全比较,每次父重组都会执行。

习题 8:derivedStateOf 实战

题目:实现一个搜索框:TextField 输入文本,一个 Text 显示"有输入"或"无输入"。要求输入内容变化时,只有"有输入/无输入"切换才触发 Text 重组。

参考答案:

@Composable
fun SearchBox() {
    var query by remember { mutableStateOf("") }
    val hasInput by remember {
        derivedStateOf { query.isNotBlank() }
    }
    Column {
        TextField(value = query, onValueChange = { query = it })
        Text(if (hasInput) "有输入" else "无输入")
    }
}

hasInput 只有在 query 从空变为非空或反之时才变化。Text 读取的是 hasInput,而非 query,因此输入过程中的每次字符变化不会触发 Text 重组。

习题 9:DisposableEffect 清理

题目:实现一个 Composable,进入组合时注册一个模拟的监听器,离开时自动取消。点击按钮手动触发"事件",观察日志。

参考答案:

@Composable
fun ListenerScreen() {
    var eventCount by remember { mutableStateOf(0) }
    DisposableEffect(Unit) {
        val listener = { eventCount++ }
        FakeEventBus.register(listener)
        onDispose {
            FakeEventBus.unregister(listener)
        }
    }
    Column {
        Button(onClick = { FakeEventBus.emit() }) {
            Text("触发事件 ($eventCount)")
        }
    }
}

DisposableEffect(Unit) 在进入组合时注册,离开时 onDispose 自动反注册。

习题 10:Modifier 顺序验证

题目:创建两个 Box,尺寸都是 100dp。第一个 padding(20.dp).background(Color.Red),第二个 background(Color.Red).padding(20.dp)。解释视觉差异。

参考答案:

Box(Modifier.size(100.dp).padding(20.dp).background(Color.Red))
Box(Modifier.size(100.dp).background(Color.Red).padding(20.dp))

第一个:先 padding 收缩内容区到 60dp,背景绘制在 60dp 区域,四周 20dp 透明。
第二个:先铺满 100dp 红色,padding 将内容推到 60dp,红色覆盖全部 100dp。

习题 11:rememberSaveable 自定义 Saver

题目:创建一个 CounterState 类持有 Int 值,用 rememberSaveable 配合自定义 Saver 保存它。

参考答案:

class CounterState(var value: Int)

val CounterStateSaver = Saver<CounterState, Int>(
    save = { it.value },
    restore = { CounterState(it) }
)

@Composable
fun Counter() {
    val state = rememberSaveable(saver = CounterStateSaver) {
        CounterState(0)
    }
    Button(onClick = { state.value++ }) {
        Text("Count: ${state.value}")
    }
}
习题 12:LaunchedEffect 数据加载

题目:实现一个 Composable,接收 userId: Int 参数,在 userId 变化时模拟加载用户数据,加载期间显示进度,加载完成显示名称。

参考答案:

@Composable
fun UserProfile(userId: Int) {
    var userName by remember { mutableStateOf<String?>(null) }
    LaunchedEffect(userId) {
        userName = null  // 重置
        delay(1000)      // 模拟网络请求
        userName = "User $userId"
    }
    if (userName == null) {
        CircularProgressIndicator()
    } else {
        Text(userName!!)
    }
}

LaunchedEffect(userId) 保证 userId 变化时取消旧请求,启动新请求。

综合设计

习题 13:状态提升重构

题目:将以下代码重构为状态提升模式,使 CounterDisplay 和 CounterButton 成为无状态组件。

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    Column {
        Text("Count: $count")
        Button(onClick = { count++ }) { Text("+1") }
    }
}

参考答案:

@Composable
fun Counter() {
    var count by remember { mutableStateOf(0) }
    CounterScreen(
        count = count,
        onIncrement = { count++ }
    )
}

@Composable
fun CounterScreen(count: Int, onIncrement: () -> Unit) {
    Column {
        CounterDisplay(count)
        CounterButton(onIncrement)
    }
}

@Composable
fun CounterDisplay(count: Int) {
    Text("Count: $count")
}

@Composable
fun CounterButton(onClick: () -> Unit) {
    Button(onClick = onClick) { Text("+1") }
}

延伸思考:状态提升的收益是 CounterDisplay 和 CounterButton 变成了无状态组件,可以独立预览、独立测试、独立复用。

习题 14:综合状态管理

题目:实现一个待办清单,要求:任务列表用 mutableStateListOf,输入框用 rememberSaveable,已完成计数用 derivedStateOf。

参考答案:

data class Task(val text: String, val done: Boolean = false)

@Composable
fun TodoList() {
    var input by rememberSaveable { mutableStateOf("") }
    val tasks = remember { mutableStateListOf<Task>() }
    val doneCount by remember {
        derivedStateOf { tasks.count { it.done } }
    }
    
    Column {
        Row {
            TextField(value = input, onValueChange = { input = it })
            Button(onClick = {
                if (input.isNotBlank()) {
                    tasks.add(Task(input))
                    input = ""
                }
            }) { Text("添加") }
        }
        Text("已完成: $doneCount / ${tasks.size}")
        LazyColumn {
            items(tasks) { task ->
                Row(Modifier.clickable {
                    val index = tasks.indexOf(task)
                    tasks[index] = task.copy(done = !task.done)
                }) {
                    Checkbox(checked = task.done, onCheckedChange = null)
                    Text(task.text)
                }
            }
        }
    }
}

延伸思考:Task 用 val done 保证不可变性,更新时用 copy() 替换,这样 Task 是稳定类型,LazyColumn 中的列表项可以正确跳过重组。

习题 15:状态容器模式

题目:将习题 14 的待办清单重构为状态容器模式:创建一个 TodoStateHolder 类封装所有状态和逻辑,Composable 只负责渲染。

参考答案:

class TodoStateHolder {
    var input by mutableStateOf("")
    val tasks = mutableStateListOf<Task>()
    val doneCount: Int
        get() = tasks.count { it.done }
    
    fun addTask() {
        if (input.isNotBlank()) {
            tasks.add(Task(input))
            input = ""
        }
    }
    
    fun toggleTask(task: Task) {
        val index = tasks.indexOf(task)
        if (index >= 0) {
            tasks[index] = task.copy(done = !task.done)
        }
    }
}

@Composable
fun TodoList() {
    val holder = remember { TodoStateHolder() }
    
    Column {
        Row {
            TextField(
                value = holder.input,
                onValueChange = { holder.input = it }
            )
            Button(onClick = { holder.addTask() }) { Text("添加") }
        }
        Text("已完成: ${holder.doneCount} / ${holder.tasks.size}")
        LazyColumn {
            items(holder.tasks) { task ->
                Row(Modifier.clickable { holder.toggleTask(task) }) {
                    Checkbox(checked = task.done, onCheckedChange = null)
                    Text(task.text)
                }
            }
        }
    }
}

延伸思考:状态容器模式是 ViewModel 模式的轻量替代。当状态逻辑复杂到需要跨 Composable 共享、需要协程操作、需要依赖注入时,就应该升级到真正的 ViewModel(第6课内容)。

七、本课小结

@Composable 的本质:编译器插件注入 Composer、生成分组信息、支持跳跃判断。理解这些转换,才能理解调用规则和限制。稳定性是跳过重组的前提,不稳定类型是性能问题的常见根源。

重组机制:只有读取了变化状态的 Composable 才会被重新执行。重组范围由状态读取点决定,而非状态声明位置。跳跃机制保证性能,但需要稳定类型配合。

状态管理:mutableStateOf 创建可观察状态,remember 保留重组间状态,rememberSaveable 跨配置保留状态。derivedStateOf 将高频状态投影为低频状态,减少重组。状态应该尽量靠近读取点,复杂逻辑用状态容器封装。

Modifier 链:顺序决定行为。洋葱模型是理解顺序影响的关键。padding、background、clickable、size 的先后关系直接影响布局、

Logo

一站式 AI 云服务平台

更多推荐