【Compose Multiplatform 跨端开发学与练】第2课 Compose 基础语法
本课目标:从编译器视角理解 @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 的先后关系直接影响布局、
更多推荐




所有评论(0)