4. 可组合函数与布局基础:Column、Row、Box

说实话,刚接触 Compose 的时候,我最大的困惑就是:一个函数怎么就能画 UI 了? 传统的 XML 写惯了,突然让我用 Kotlin 函数描述界面,总觉得心里没底。但用了一段时间后,我反而觉得这种方式更符合直觉。

这一章我们就把 Compose 最核心的三个布局摸透:Column、Row、Box。它们是搭界面的「三块积木」,几乎所有页面都能用它们拼出来。

4.1 可组合函数:UI 就是函数

先看一段最基础的代码:

@Composable
fun Greeting(name: String) {
    Text(text = "你好,$name")
}

这个 @Composable 注解告诉编译器:这个函数是用来生成 UI 的。我在早期项目里经常犯一个错误——以为可组合函数里只能放 UI 代码。其实你可以在里面写任何 Kotlin 逻辑,比如循环、判断,甚至调用网络请求(虽然不推荐在 UI 层直接做网络调用)。

核心理解:可组合函数不是「画」UI,而是描述 UI 状态。当状态变化了,Compose 会智能地重新执行这个函数,只更新变化的部分。

嗯,这里要注意:可组合函数不应该有副作用。比如在里面写 SharedPreferences 的写入操作,就会导致每次重组都写一次,性能直接崩掉。我之前有个同事就踩过这个坑,日志里全是重复写入记录……

4.2 Column:纵向排列,像流水线

Column 是最常用的布局之一。它把子元素从上到下排成一列,类似传统 Android 的 LinearLayout 垂直模式。

@Composable
fun ColumnExample() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        Text("第一行", fontSize = 20.sp)
        Text("第二行", fontSize = 16.sp)
        Button(onClick = { /* 点击事件 */ }) {
            Text("按钮")
        }
    }
}

我个人习惯在 Column 里总是显式指定 horizontalAlignment,哪怕默认是 Start。为什么?因为后期你可能随时调整对齐方式,写着名能省去很多 debug 时间。

避坑指南:我曾经在 Column 里嵌套了一个 LazyColumn,结果一运行就报错「Vertically scrollable component ...」。这是因为 Column 无法测量无限高度的子元素。解决办法是用 Modifier.weight() 限制高度,或者改用 Box 配合 ScrollState。

4.3 Row:横着干活,并排显示

Row 和 Column 对称,只是排列方向变成了水平。我最常用它来做顶部导航栏和列表项里的图标+文字组合。

@Composable
fun RowExample() {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 12.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Icon(
            imageVector = Icons.Default.Search,
            contentDescription = "搜索"
        )
        Spacer(modifier = Modifier.width(8.dp))
        Text("搜索框占位", color = Color.Gray)
    }
}

你看,Row 里用 Spacer 控制间距,比传统 margin 语义更清晰。不过要注意:Spacer 本身不占视觉空间,只占布局空间,千万别往它上面设背景色,看不到的。

布局 排列方向 常用场景 易犯错误
Column 垂直 列表、表单、纵向信息流 嵌套可滚动组件
Row 水平 导航栏、图标+文字、并排按钮 宽度溢出屏幕
Box 层叠 叠放、遮罩、装饰元素 子元素尺寸未指定

4.4 Box:堆叠的艺术

Box 允许子元素在 Z 轴上重叠。我第一次用 Box 是在做一个「头像+在线状态绿点」的组件。想想看,用传统 XML 你得用 FrameLayout 或 RelativeLayout,而 Compose 里一个 Box 就搞定了:

@Composable
fun AvatarWithBadge() {
    Box(
        modifier = Modifier.size(64.dp)
    ) {
        // 背景头像
        Image(
            painter = painterResource(id = R.drawable.avatar),
            contentDescription = "头像",
            modifier = Modifier.fillMaxSize()
        )
        // 右上角薄点
        Box(
            modifier = Modifier
                .align(Alignment.TopEnd)
                .size(16.dp)
                .background(Color.Green, shape = CircleShape)
        )
    }
}

注意 align 修饰符——它决定了子元素在 Box 内部的相对位置。Box 默认所有子元素都从左上角开始堆叠,但用 Modifier.align(Alignment.Center) 就能调整。我记得有次写界面,忘了加 align,结果所有文字都挤在左上角,调试了半天才发现。

警告:Box 的子元素如果没有明确尺寸,可能会以 0 尺寸显示。比如上面那个绿点,如果没写 .size(16.dp),它就是一个不可见的点。你想想看,找 bug 得多辛苦?

4.5 修饰符:布局的灵魂

没有修饰符(Modifier)的布局就像没有调料的菜——能吃但没味道。Column、Row、Box 都需要配合 Modifier 控制尺寸、边距、对齐、权重等。

Column(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp)
        .background(Color(0xFFF5F5F5))
) {
    Text("内容", modifier = Modifier
        .fillMaxWidth()
        .padding(vertical = 8.dp))
}

这里有一个顺序问题:Modifier 链的顺序会影响最终效果。比如 .padding().background() 和 .background().padding() 完全不同:前者是在内边距内填背景,后者是背景从外边开始。我在项目里见过因为顺序反了,导致背景色范围比预期大出一圈的情况。

4.6 权重:像 flex 一样分配空间

Row 和 Column 都支持 Modifier.weight(),用来按比例分配剩余空间。这比传统 Android 的 layout_weight 更简洁,因为它直接在子元素上声明:

Row(modifier = Modifier.fillMaxWidth()) {
    Text("左", modifier = Modifier.weight(1f))
    Text("中", modifier = Modifier.weight(2f))
    Text("右", modifier = Modifier.weight(1f))
}

上面这段代码会让「中」占据两倍于「左」和「右」的空间。我个人建议在需要等分布局时,直接给每个子元素 weight(1f),简单粗暴。不过注意:weight 只在未指定固定宽度的子元素上生效,如果某个子元素写了 .width(100.dp),那它就不会参与权重分配。

4.7 组合使用:一个真实案例

我们来做一个「商品卡片」的布局,综合运用 Column、Row、Box:

@Composable
fun ProductCard(productName: String, price: String, imageUrl: String) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp),
        elevation = 4.dp
    ) {
        Row(modifier = Modifier.padding(12.dp)) {
            // 商品图片(用 Box 包一层,方便后期加角标)
            Box(modifier = Modifier.size(80.dp)) {
                Image(painter = painterResource(id = R.drawable.product), contentDescription = null)
                // 折扣角标
                Text(
                    text = "5折",
                    color = Color.White,
                    fontSize = 12.sp,
                    modifier = Modifier
                        .align(Alignment.TopStart)
                        .background(Color.Red, shape = RoundedCornerShape(4.dp))
                        .padding(horizontal = 4.dp, vertical = 2.dp)
                )
            }
            Spacer(modifier = Modifier.width(12.dp))
            // 右侧文字信息
            Column(modifier = Modifier.weight(1f)) {
                Text(text = productName, fontWeight = FontWeight.Bold)
                Spacer(modifier = Modifier.height(4.dp))
                Text(text = price, color = Color(0xFFE91E63))
                Spacer(modifier = Modifier.height(8.dp))
                Button(onClick = { /* 加入购物车 */ }) {
                    Text("加入购物车")
                }
            }
        }
    }
}

你看,一个复杂卡片就由 Row、Column、Box 嵌套组合而成。真做项目时,我通常会先把布局结构在脑子里画一遍,确定每层用什么容器,再写代码——这样不容易乱。

小技巧:开发时可以在每个容器上临时加 .border(1.dp, Color.Red) 来可视化布局边界,排查完再删掉。我在团队里推过这个习惯,新手 debug 效率至少提升 30%。

4.8 本章知识结构

下面这张图总结了 Column、Row、Box 的核心关系和应用场景:

Compose 布局三兄弟 Column 垂直排列(上→下) Row 水平排列(左→右) Box 层叠排列(Z轴) 典型应用场景 列表 / 表单 导航栏 / 并排 叠层 / 角标 卡片 💡 说明: 三兄弟可无限嵌套组合,配合 Modifier 实现任意复杂布局。 注意可滚动组件的嵌套限制(如 Column 内不能直接放 LazyColumn)。

其实说白了,Column、Row、Box 对应三维空间的三个方向:Y、X、Z。把这三个方向掌握好,再复杂的界面都能拆解成它们的组合。我刚开始带新人时,就让他们用这三种布局自己画一个「设置页」,效果比直接给代码好得多。