Jetpack Compose 入门系列(二):布局到 State 的使用

6 阅读8分钟

Jetpack Compose 入门系列(二):布局到 State 的使用

一、前言

第一篇文章中,我们从零搭建了 Compose 环境并学习了基础控件。但实际开发中,我们需要的不仅仅是一个个独立控件,而是如何组织和布局这些控件,以及如何让界面响应数据的变化

本篇文章将重点解决两个问题:

  1. 布局:如何像 LinearLayout、RelativeLayout、FrameLayout 那样排列控件
  2. State(状态):当数据变化时,如何让界面自动更新

这两点是搭建复杂界面的基石。


二、布局系统

Compose 提供了三种核心布局容器:ColumnRowBox,分别对应传统 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:权重分配

ColumnRow 中,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 / RelativeLayoutBox
权重layout_weightModifier.weight()
对齐android:gravitycontentAlignment 参数
单独对齐android:layout_gravityModifier.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")
    }
}

逐层拆解:

  1. mutableStateOf(0):创建一个初始值为 0 的 MutableState<Int> 对象。这个对象被 Compose 的"观察者系统"跟踪
  2. remember { }:在首次组合(Composition)时执行 lambda,返回的值被"记住"。当重组发生时,remember 返回之前记住的值,而不是重新创建
  3. 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")
    }
}

状态提升的好处:

  • 单一数据源:状态只在一个地方维护,避免了多个副本不一致的问题
  • 可复用性TitleWithCounterIncrementButton 变成了纯 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 发出新值时,它会更新内部的 Compose State,从而触发重组。


八、综合实战: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 中不推荐直接修改列表)

九、总结

本篇文章我们学习了:

  1. 三大布局容器:Column(垂直)、Row(水平)、Box(层叠)及其参数含义
  2. 列表布局:LazyColumn / LazyRow 处理大量数据
  3. State 核心mutableStateOf 创建可观察状态,remember 记住状态
  4. 状态提升:"数据向下传递,事件向上传递"的设计模式
  5. rememberSaveable:应对 Activity 重建时的状态保留
  6. 派生状态与 ViewModelderivedStateOfcollectAsState()

学完这两篇文章,你应该已经具备了使用 Compose 搭建一个完整页面的能力。下一步可以继续学习 Compose 动画、自定义布局、Navigation 导航等进阶内容。


如果你在学习过程中有任何疑问,欢迎在评论区留言,我会尽可能回复。

系列文章: