Jetpack Compose 入门系列(二):布局到 State 的使用
一、前言
在第一篇文章中,我们从零搭建了 Compose 环境并学习了基础控件。但实际开发中,我们需要的不仅仅是一个个独立控件,而是如何组织和布局这些控件,以及如何让界面响应数据的变化。
本篇文章将重点解决两个问题:
- 布局:如何像 LinearLayout、RelativeLayout、FrameLayout 那样排列控件
- State(状态):当数据变化时,如何让界面自动更新
这两点是搭建复杂界面的基石。
二、布局系统
Compose 提供了三种核心布局容器:Column、Row 和 Box,分别对应传统 XML 中的 LinearLayout(vertical)、LinearLayout(horizontal) 和 FrameLayout。
flowchart TB
subgraph Column_垂直
direction TB
C1[Item 1]
C2[Item 2]
C3[Item 3]
end
subgraph Row_水平
direction LR
R1[Item 1]
R2[Item 2]
R3[Item 3]
end
subgraph Box_层叠
direction TB
B0["底: 红色背景<br/>中: 绿色背景<br/>顶: 文字"]
end
Column_垂直 --- Row_水平 --- Box_层叠
2.1 Column:垂直排列
Column 将内容从上到下依次排列,相当于 LinearLayout 的垂直方向:
@Composable
fun ColumnExample() {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
// 水平对齐方式:Start(左对齐)、CenterHorizontally(居中)、End(右对齐)
horizontalAlignment = Alignment.CenterHorizontally,
// 垂直排列方式:Top(顶部开始)、Center(垂直居中)、Bottom(底部)、SpaceBetween(均匀分布)
verticalArrangement = Arrangement.Top
) {
Text(text = "第一行", modifier = Modifier.background(Color.Red))
Text(text = "第二行", modifier = Modifier.background(Color.Green))
Text(text = "第三行", modifier = Modifier.background(Color.Blue))
}
}
两个关键参数:
horizontalAlignment:所有子元素在水平方向上的对齐方式,可以理解为"每一个 item 对自己这一行怎么对齐"verticalArrangement:子元素在垂直方向上的排列策略,可以理解为"整体布局的排列规则"
Arrangement 的可选值及效果:
| 值 | 效果 |
|---|---|
Arrangement.Top | 从顶部开始排列 |
Arrangement.Center | 垂直居中 |
Arrangement.Bottom | 从底部开始排列 |
Arrangement.SpaceEvenly | 均匀分布,item 之间和两端的间距相等 |
Arrangement.SpaceBetween | 均匀分布,两端不留间距 |
Arrangement.SpaceAround | 均匀分布,两端间距是 item 间距的一半 |
对应 XML 的理解:
XML: android:gravity="center" → Compose: horizontalAlignment = Alignment.CenterHorizontally
XML: android:layout_gravity → Compose: Modifier.align()
2.2 Row:水平排列
Row 将内容从左到右水平排列,相当于 LinearLayout 的水平方向:
@Composable
fun RowExample() {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Text(text = "左")
Text(text = "中")
Text(text = "右")
}
}
Row 的参数与 Column 对称:
verticalAlignment→ 对应 Column 的horizontalAlignment,控制子元素在垂直方向的对齐horizontalArrangement→ 对应 Column 的verticalArrangement,控制水平方向的排列策略
2.3 weight:权重分配
在 Column 或 Row 中,Modifier.weight() 类似于 LinearLayout 的 layout_weight,用于按比例分配剩余空间:
@Composable
fun WeightExample() {
Row(modifier = Modifier.fillMaxWidth()) {
Text(
text = "左侧",
modifier = Modifier
.weight(1f) // 占 1 份
.background(Color.Red)
.padding(8.dp)
)
Text(
text = "右侧",
modifier = Modifier
.weight(2f) // 占 2 份(宽度是左侧的 2 倍)
.background(Color.Green)
.padding(8.dp)
)
}
}
上面这段代码的效果是:整个 Row 的宽度分为 3 份,左侧占 1 份,右侧占 2 份。
2.4 Box:层叠布局
Box 将内容按顺序层叠在一起,后面的内容覆盖在前面的内容之上,相当于 FrameLayout:
@Composable
fun BoxExample() {
Box(
modifier = Modifier
.size(200.dp)
.background(Color.LightGray),
// 所有子元素在 Box 内的默认对齐方式
contentAlignment = Alignment.Center
) {
// 底层:红色方块
Box(
modifier = Modifier
.size(150.dp)
.background(Color.Red)
)
// 上层:绿色方块
Box(
modifier = Modifier
.size(80.dp)
.background(Color.Green)
)
// 最上层:文字
Text(text = "Hello", color = Color.White)
}
}
运行效果:灰色大背景 → 中间红色方块 → 中间偏上绿色方块 → 最上层白色文字。
每个子元素也可以通过 Modifier.align() 单独指定对齐方式,覆盖 Box 的默认设置:
Box(modifier = Modifier.fillMaxSize()) {
Text(
text = "左上角",
modifier = Modifier.align(Alignment.TopStart)
)
Text(
text = "右下角",
modifier = Modifier.align(Alignment.BottomEnd)
)
}
2.5 对比总结:Compose 布局 ↔ XML 布局
| 用途 | XML 布局 | Compose 布局 |
|---|---|---|
| 垂直排列 | LinearLayout(vertical) | Column |
| 水平排列 | LinearLayout(horizontal) | Row |
| 层叠布局 | FrameLayout / RelativeLayout | Box |
| 权重 | layout_weight | Modifier.weight() |
| 对齐 | android:gravity | contentAlignment 参数 |
| 单独对齐 | android:layout_gravity | Modifier.align() |
2.6 嵌套布局示例:个人信息卡片
@Composable
fun ProfileCard() {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 头像占位(圆形)
Box(
modifier = Modifier
.size(60.dp)
.clip(CircleShape)
.background(Color.Gray),
contentAlignment = Alignment.Center
) {
Text(text = "👤", fontSize = 28.sp)
}
Spacer(modifier = Modifier.width(16.dp))
// 右侧文本信息
Column(modifier = Modifier.weight(1f)) {
Text(
text = "张三",
fontWeight = FontWeight.Bold,
fontSize = 18.sp
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "Android 开发者 | Compose 学习者",
color = Color.Gray,
fontSize = 14.sp
)
}
}
}
}
三、列表布局:LazyColumn / LazyRow
当需要展示大量数据时,用 Column 包裹所有 item 会导致所有 item 同时被创建和测量(即使不可见),严重影响性能。这时应该使用 LazyColumn(垂直列表)或 LazyRow(水平列表),它们只渲染可见区域的项目,类似传统 Android 中的 RecyclerView。
3.1 基本用法
@Composable
fun SimpleLazyColumn() {
val list = (1..100).toList() // 100 条数据
LazyColumn(modifier = Modifier.fillMaxSize()) {
// items 是 LazyColumn 的扩展函数,接收一个列表
items(list) { item ->
ListItem(number = item)
}
}
}
@Composable
fun ListItem(number: Int) {
Text(
text = "第 $number 项",
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
)
}
3.2 不同类型 Item
LazyColumn {
// 头部
item { Text("头部", modifier = Modifier.padding(16.dp)) }
// 数据列表
items(100) { index ->
Text("Item $index", modifier = Modifier.padding(16.dp))
}
// 尾部
item { Text("没有更多了", modifier = Modifier.padding(16.dp)) }
}
item()添加单个项目,items()添加列表数据,两者可以混合使用。
3.3 LazyColumn vs Column 的选择
| 场景 | 推荐用 | 原因 |
|---|---|---|
| 少于 10 个 item,且数量固定 | Column | 简单、轻量 |
| 大量数据或数量不确定 | LazyColumn | 只渲染可见项,节省内存 |
| 需要 scroll 效果,但 item 很少 | 两者均可 | Column + verticalScroll() |
不要在
LazyColumn中嵌套同样可滚动的Column或另一个LazyColumn——这会导致滚动冲突。
flowchart LR
subgraph Column_全量渲染
direction TB
COL1["Item 1 ✅ 创建"]
COL2["Item 2 ✅ 创建"]
COL3["Item 3 ✅ 创建"]
COL4["Item 4 ✅ 创建"]
COL5["Item 5 ✅ 创建"]
COL6["...全部创建<br/>100 个 item 全部测量布局<br/>不可见的也创建"]
end
subgraph LazyColumn_按需渲染
direction TB
LAZY1["Item 1 ✅ 可见"]
LAZY2["Item 2 ✅ 可见"]
LAZY3["Item 3 ✅ 可见"]
LAZY4["Item 4 ❌ 不可见<br/>暂不创建"]
LAZY5["Item 5 ❌ 不可见<br/>暂不创建"]
LAZY6["...滚动时动态创建<br/>/ 回收"]
end
Column_全量渲染 -->|"大量数据时内存暴涨"| 问题
LazyColumn_按需渲染 -->|"只渲染可见区域"| 优势
四、State:状态管理
State(状态)是 Compose 中最核心、也最容易让人困惑的概念。我们先从一个问题出发。
4.1 为什么需要 State?
看下面这段代码:
// ❌ 这样写不会生效
@Composable
fun BrokenCounter() {
var count = 0
Button(onClick = { count++ }) {
Text("点击次数:$count")
}
}
这段代码不会正常工作——点击按钮后 count 的值确实变了,但界面上显示的数字不会更新。为什么?
因为 Compose 不会自动重新执行函数来刷新界面。Compose 只在它的"可观察对象"发生变化时才会触发重组(Recompose)。count 只是一个普通的 Int 变量,Compose 注意不到它的变化。
4.2 mutableStateOf + remember
要让界面响应数据变化,需要用 mutableStateOf 创建可观察的状态,并用 remember 记住这个状态,防止重组时被重置:
// ✅ 正确的写法
@Composable
fun WorkingCounter() {
var count by remember { mutableStateOf(0) }
// ^^^^^^^^ ^^^^^^^^^^^^^^^^
// 记住状态 创建可观察的状态
Button(onClick = { count++ }) {
Text("点击次数:$count")
}
}
逐层拆解:
mutableStateOf(0):创建一个初始值为 0 的MutableState<Int>对象。这个对象被 Compose 的"观察者系统"跟踪remember { }:在首次组合(Composition)时执行 lambda,返回的值被"记住"。当重组发生时,remember返回之前记住的值,而不是重新创建by:Kotlin 的委托属性语法,等价于count = mutableState.value/mutableState.value = count,简化了读写操作。如果不写by则需要写成count.value++
如果用 by 不习惯,等价写法:
@Composable
fun CounterWithoutDelegate() {
val count = remember { mutableStateOf(0) }
Button(onClick = { count.value++ }) {
Text("点击次数:${count.value}")
}
}
两者的效果完全一样,by 只是语法糖。
4.3 remember 的工作原理
理解 remember 的关键在于理解 Compose 的组合(Composition)和重组(Recompose):
flowchart TB
Start["首次进入界面"] --> Composition["Composition(组合)"]
Composition --> RememberExec["remember { mutableStateOf(0) }<br>→ 执行 lambda,创建状态对象"]
RememberExec --> Bind["记住该状态对象<br>与当前 Composable 位置绑定"]
Bind --> Wait["等待用户操作"]
Wait --> Click["用户点击按钮 → count++"]
Click --> Trigger["状态值变化"]
Trigger --> Recompose["Recompose(重组)"]
Recompose --> RememberReturn["remember { mutableStateOf(0) }<br>→ 返回之前记住的对象"]
RememberReturn --> Display["count 的值已经是 1<br>→ UI 显示 1"]
Display --> Wait
style Start fill:#e3f2fd
style Composition fill:#fff3e0
style Recompose fill:#fce4ec
style Click fill:#e8f5e9
核心理解:
remember的 lambda 只在初次组合时执行一次。重组时remember直接返回之前记住的值。mutableStateOf创建的对象一旦值改变,会通知 Compose 对读取了这个状态的 Composable 函数进行重组。
4.4 remember 的 key 参数
remember 还有一个可选的 key 参数:
@Composable
fun KeyExample(input: String) {
// 当 input 变化时,remember 的 lambda 会重新执行
val result = remember(input) {
heavyComputation(input) // 耗时计算
}
Text(text = result)
}
用法:当 key 发生变化时,remember 会重新执行 lambda 创建一个新值。这个功能在缓存计算结果时非常有用。
五、状态提升(State Hoisting)
5.1 有状态 vs 无状态
在 Compose 中,有两种 Composable:
有状态(Stateful): 组件自己管理状态,外部无法直接控制其状态变化。
@Composable
fun StatefulCounter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) {
Text("$count")
}
}
无状态(Stateless): 状态由外部传入,组件只负责展示和通知。
@Composable
fun StatelessCounter(
count: Int,
onIncrement: () -> Unit
) {
Button(onClick = onIncrement) {
Text("$count")
}
}
5.2 什么是状态提升?
状态提升(State Hoisting) 是指把状态从一个 Composable 中"抽离"到它的调用方,让调用方来管理状态。其核心模式是:
数据向下传递,事件向上传递。
- 数据(state)通过参数传给子组件
- 事件(event)通过回调由子组件通知父组件
flowchart TB
subgraph 父组件_Parent
State["State<br/>count: Int"] --> |"数据向下<br/>count = count"| ChildA
State --> |"数据向下<br/>count = count"| ChildB
end
subgraph 子组件_A
ChildA["TitleWithCounter<br/>(count: Int)<br/>只展示,不修改"]
end
subgraph 子组件_B
ChildB["IncrementButton<br/>(onIncrement: () → Unit)<br/>只通知,不管理"]
end
ChildB --> |"事件向上<br/>onIncrement()"| State
linkStyle 0 stroke:#4caf50
linkStyle 1 stroke:#4caf50
linkStyle 2 stroke:#f44336
图中绿色箭头为数据流(从上到下),红色箭头为事件流(从下到上)。父组件持有状态,子组件只负责展示和通知。
5.3 为什么要提升?
// 场景:有一个标题和一个按钮,点按钮标题数字 +1
// 如果不做状态提升,标题和按钮各管各的:
@Composable
fun BadExample() {
Column {
TitleWithCounter() // 标题组件自己管理 count
IncrementButton() // 按钮组件自己管理另一个 count —— 两者互不感知!
}
}
// 状态提升后:
@Composable
fun GoodExample() {
var count by remember { mutableStateOf(0) }
Column {
TitleWithCounter(count = count) // 把 count 传进去
IncrementButton(onIncrement = { count++ }) // 按钮只管通知
}
}
@Composable
fun TitleWithCounter(count: Int) {
Text(text = "当前计数:$count")
}
@Composable
fun IncrementButton(onIncrement: () -> Unit) {
Button(onClick = onIncrement) {
Text("点击 +1")
}
}
状态提升的好处:
- 单一数据源:状态只在一个地方维护,避免了多个副本不一致的问题
- 可复用性:
TitleWithCounter和IncrementButton变成了纯 UI 组件,可以放在任何地方使用 - 可测试性:无状态组件更容易测试,因为你只需要验证传入参数和捕获回调事件
5.4 何时提升?
一个简单的判断标准:当一个状态被多个 Composable 共享或需要在父层控制时,就应该提升到它们的共同父级。
六、rememberSaveable:持久化状态
remember 存的东西只在内存里——Activity 一销毁(比如屏幕旋转、手机切后台被系统回收),remember 记住的内容就没了,一切回到初始值。
做个实验:在模拟器里运行这个界面,输入一些文字,然后旋转屏幕(按 Ctrl+F11),看看发生了什么:
@Composable
fun LostOnRotation() {
var input by remember { mutableStateOf("") }
// 屏幕旋转后 → input 恢复为 ""
}
输入框会变成空白。这是因为屏幕旋转导致 Activity 重建,remember 的存储跟着旧 Activity 一起被释放了。
解决办法是把 remember 换成 rememberSaveable:
@Composable
fun SurviveRotation() {
var input by rememberSaveable { mutableStateOf("") }
// 屏幕旋转后 → input 保留之前输入的内容
}
rememberSaveable 内部走的是 Bundle 机制——和以前写 onSaveInstanceState 是同一套东西。它能保存的类型和 Bundle 一致:基本类型、String、实现了 Parcelable 或 Serializable 的对象。
实用建议: 不用纠结"该用哪个",除非你明确知道这个状态不需要跨重建存活(比如某种动画的临时进度),否则默认用
rememberSaveable就对了。多花一行字的事,能省一堆 bug。
七、更多 State 工具
7.1 derivedStateOf:派生状态
当一个状态的值依赖于其他状态时,可以用 derivedStateOf 自动计算派生值:
@Composable
fun DerivedStateExample() {
val todoList = remember { mutableStateOf(listOf("写文章", "买菜", "跑步")) }
// 派生状态:列表长度变化时自动重新计算
val itemCount by remember { derivedStateOf { todoList.value.size } }
Column(modifier = Modifier.padding(16.dp)) {
Text(text = "待办事项(共 $itemCount 项)")
// ... 列表内容
}
}
derivedStateOf不会在每次重组时重新计算,只在其依赖的状态发生变化时才重新计算,适合做计算开销较大的转换。
7.2 StateFlow 收集(结合 ViewModel)
在实际的 MVI/MVVM 架构中,状态通常在 ViewModel 中以 StateFlow 的形式暴露,Compose 通过 collectAsState() 收集:
class MainViewModel : ViewModel() {
// 使用 StateFlow 暴露状态
private val _uiState = MutableStateFlow("初始内容")
val uiState: StateFlow<String> = _uiState.asStateFlow()
fun updateContent(text: String) {
_uiState.value = text
}
}
@Composable
fun ViewModelExample(viewModel: MainViewModel = viewModel()) {
// 将 StateFlow 转换为 Compose 的 State
val content by viewModel.uiState.collectAsState()
Column(modifier = Modifier.padding(16.dp)) {
Text(text = content)
Button(onClick = { viewModel.updateContent("更新了") }) {
Text("更新")
}
}
}
collectAsState()是一个桥梁,它订阅StateFlow,每当StateFlow发出新值时,它会更新内部的 ComposeState,从而触发重组。
八、综合实战:TODO 待办清单
将布局和状态知识结合起来,写一个完整的 TODO 应用:
// 数据层
data class TodoItem(
val id: Int,
val text: String,
val isCompleted: Boolean = false
)
// 主界面
@Composable
fun TodoScreen() {
var todos by rememberSaveable { mutableStateOf(listOf<TodoItem>()) }
var inputText by rememberSaveable { mutableStateOf("") }
var nextId by rememberSaveable { mutableStateOf(0) }
// 派生状态:统计完成数量
val completedCount by remember {
derivedStateOf { todos.count { it.isCompleted } }
}
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// 标题区
Text(
text = "我的待办",
fontSize = 24.sp,
fontWeight = FontWeight.Bold
)
Text(
text = "已完成:$completedCount / ${todos.size}",
color = Color.Gray
)
Spacer(modifier = Modifier.height(16.dp))
// 输入区
Row(modifier = Modifier.fillMaxWidth()) {
OutlinedTextField(
value = inputText,
onValueChange = { inputText = it },
placeholder = { Text("输入待办事项") },
modifier = Modifier.weight(1f),
singleLine = true
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = {
if (inputText.isNotBlank()) {
todos = todos + TodoItem(id = nextId++, text = inputText)
inputText = ""
}
}
) {
Text("添加")
}
}
Spacer(modifier = Modifier.height(16.dp))
// 列表区
LazyColumn(modifier = Modifier.weight(1f)) {
items(todos, key = { it.id }) { todo ->
TodoItemRow(
todo = todo,
onToggle = {
todos = todos.map {
if (it.id == todo.id) it.copy(isCompleted = !it.isCompleted)
else it
}
},
onDelete = {
todos = todos.filter { it.id != todo.id }
}
)
}
}
}
}
// 单条待办
@Composable
fun TodoItemRow(
todo: TodoItem,
onToggle: () -> Unit,
onDelete: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = todo.isCompleted,
onCheckedChange = { onToggle() }
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = todo.text,
modifier = Modifier.weight(1f),
textDecoration = if (todo.isCompleted) TextDecoration.LineThrough else TextDecoration.None,
color = if (todo.isCompleted) Color.Gray else Color.Unspecified
)
IconButton(onClick = onDelete) {
Icon(Icons.Default.Delete, contentDescription = "删除")
}
}
}
这个例子覆盖了本文的哪些知识点?
| 代码片段 | 涉及知识点 |
|---|---|
var todos by rememberSaveable { ... } | rememberSaveable 持久化状态 |
completedCount by remember { derivedStateOf { ... } } | derivedStateOf 派生状态 |
LazyColumn + items(todos, key = { ... }) | LazyColumn 列表 + key 优化 |
TodoItemRow 接收回调参数 | 无状态组件(Stateless) |
todos = todos + ... / todos = todos.map { } | 不可变数据更新(Compose 中不推荐直接修改列表) |
九、总结
本篇文章我们学习了:
- 三大布局容器:Column(垂直)、Row(水平)、Box(层叠)及其参数含义
- 列表布局:LazyColumn / LazyRow 处理大量数据
- State 核心:
mutableStateOf创建可观察状态,remember记住状态 - 状态提升:"数据向下传递,事件向上传递"的设计模式
- rememberSaveable:应对 Activity 重建时的状态保留
- 派生状态与 ViewModel:
derivedStateOf和collectAsState()
学完这两篇文章,你应该已经具备了使用 Compose 搭建一个完整页面的能力。下一步可以继续学习 Compose 动画、自定义布局、Navigation 导航等进阶内容。
如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。
系列文章:
- Jetpack Compose 入门系列(一):从零搭建到基础控件使用
- Jetpack Compose 入门系列(二):布局到 State 的使用(本文)
- Jetpack Compose 入门系列(三):MVVM + 协程 实现网络请求列表