Compose 入门篇

127 阅读14分钟

Compose 是什么

Jetpack Compose 是 Google 2019 年 I/O 大会上公布的声明式 UI 框架,2021 年 7 月发布 1.0 正式版,2024 年 Compose Multiplatform 1.6+ 已经把桌面端稳定支持了。截至 2025 年,Google Play 上 Top 1000 应用里有超过 40% 接入了 Compose——Gmail、Play Store、Twitter、Airbnb 全在用了。

声明式 UI 跟命令式 UI 的区别

传统的 XML + View 体系是命令式的写法:

// 先找到 View
val textView = findViewById<TextView>(R.id.title)

// 当数据变了,手动更新 View
textView.text = newTitle

开发者需要手动维护"数据状态"和"View 显示"之间的同步关系。数据有 10 个地方可能变,就有 10 处 setText()。忘掉一处,View 显示就落后于实际数据了。Bug 难查,状态散落各处。

Compose 是声明式的写法:

// 把 UI 描述成一个函数,输入是数据,输出是 UI
@Composable
fun Greeting(name: String) {
    Text("Hello $name")
}

数据变了——Compose 框架自动检测、自动重绘变化的那些部分。不需要 findViewById,不需要手动 setText,不需要维护数据→View 的同步逻辑。框架替开发者做了这件事。

简而言之:XML 是"找到控件,然后改它",Compose 是"描述 UI 应该长什么样,框架来保证它跟数据一致"

为什么 Google 要推 Compose

View 体系服役了 15 年,问题也在积累:视图层级深,嵌套 LinearLayout 五六层家常便饭,measure/layout 走一遍开销大;AdapterView 的复用机制是个黑盒,ViewHolder 的 getView() 逻辑跟业务代码耦合严重;更致命的是——View 体系是 Java 时代的遗产,对 Kotlin 的协程、Flow、空安全没有感知。

Compose 从零设计:原生 Kotlin、跟协程/Flow 一等集成、没有 View 层级(直接在 Canvas 上画)、重组粒度到单个 Composable 函数。从 API 设计到运行时机制,都是为 Kotlin 生态定制的。

接入项目

基础配置

需要两步:启用 Compose 编译 + 加依赖。Android Studio Hedgehog(2023.1.1)之后,新建项目自带 Compose 模板,改一下现成的模板就行。

第一步:build.gradle.kts(模块级)

android {
    buildFeatures {
        compose = true
    }
    
    composeOptions {
        kotlinCompilerExtensionVersion = "1.5.14"  // 跟 Kotlin 版本绑定
    }
}

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2024.12.01")
    implementation(composeBom)
    
    // 核心
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-graphics")
    implementation("androidx.compose.ui:ui-tooling-preview")
    implementation("androidx.compose.material3:material3")
    
    // 运行时
    implementation("androidx.compose.runtime:runtime")
    implementation("androidx.compose.runtime:runtime-livedata") // 如需
    
    // Activity 集成
    implementation("androidx.activity:activity-compose:1.9.3")
    
    // 导航
    implementation("androidx.navigation:navigation-compose:2.8.5")
    
    // 调试
    debugImplementation("androidx.compose.ui:ui-tooling")
    debugImplementation("androidx.compose.ui:ui-test-manifest")
}

Groovy 写法:

android {
    buildFeatures {
        compose true
    }
    composeOptions {
        kotlinCompilerExtensionVersion "1.5.14"
    }
}

dependencies {
    implementation platform("androidx.compose:compose-bom:2024.12.01")
    implementation "androidx.compose.ui:ui"
    implementation "androidx.compose.ui:ui-graphics"
    implementation "androidx.compose.ui:ui-tooling-preview"
    implementation "androidx.compose.material3:material3"
    implementation "androidx.activity:activity-compose:1.9.3"
    implementation "androidx.navigation:navigation-compose:2.8.5"
    debugImplementation "androidx.compose.ui:ui-tooling"
}

BOM 的作用compose-bom 统一管理 Compose 所有库的版本号。不用 BOM 的话 ui / material3 / foundation 各配版本号,一旦不匹配就崩。跟 Coil、OkHttp 那些 BOM 用法一样。

Compose 版本 与 Kotlin 版本的对应关系

Compose Compiler对应 Kotlin 版本
1.5.101.9.22
1.5.121.9.23
1.5.141.9.24

从 Kotlin 2.0 开始 Compose 编译器以 Kotlin 编译器插件形式存在,版本号跟着 Kotlin 走。如果项目用的是 Kotlin 2.0+,kotlinCompilerExtensionVersion 不需要再单独配了——直接在 plugins 里声明:

plugins {
    id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
}

Kotlin 1.9.x 的项目才需要 composeOptions { kotlinCompilerExtensionVersion }

版本管理抽到 libs.versions.toml

[versions]
compose-bom = "2024.12.01"
compose-compiler = "1.5.14"
activity-compose = "1.9.3"
navigation-compose = "2.8.5"

[libraries]
compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
compose-ui = { group = "androidx.compose.ui", name = "ui" }
compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
compose-material3 = { group = "androidx.compose.material3", name = "material3" }
compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activity-compose" }
navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigation-compose" }

然后在模块里:

implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3)
implementation(libs.activity.compose)
debugImplementation(libs.compose.ui.tooling)

默认代码

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent {
            ComposeDemoTheme {
                Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
                    Greeting(
                        name = "Android",
                        modifier = Modifier.padding(innerPadding)
                    )
                }
            }
        }
    }
}

@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
    Text(
        text = "Hello $name!",
        modifier = modifier
    )
}

@Preview(showBackground = true)
@Composable
fun GreetingPreview() {
    ComposeDemoTheme {
        Greeting("Android")
    }
}

setContent {} 替代了 setContentView(R.layout.xxx)setContent 里所有标了 @Composable 的函数组成 UI 树。没有 findViewById、没有 LayoutInflater—— 就这么运行起来了。

常用控件对比:XML vs Compose

Modifier 它贯穿所有 Compose 控件,放在控件参数列表的第一个位置。可以理解为"对当前控件的修饰描述",串联了 XML 体系里分散在多个属性中的配置:paddingmargin宽高背景点击事件圆角滚动行为。多个 Modifier 用 . 链在一起,顺序会影响最终效果——paddingbackground 顺序修改,效果不同。

TextView → Text

XML

<TextView
    android:id="@+id/title"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="Hello World"
    android:textSize="18sp"
    android:textColor="#333333"
    android:textStyle="bold"
    android:maxLines="2"
    android:ellipsize="end" />

然后在代码里:

val title = findViewById<TextView>(R.id.title)
title.text = "新标题"

Compose

@Composable
fun Title(text: String) {
    Text(
        text = text,
        fontSize = 18.sp,
        color = Color(0xFF333333),
        fontWeight = FontWeight.Bold,
        maxLines = 2,
        overflow = TextOverflow.Ellipsis
    )
}

调用时不需要找 Id——直接传参数:

Title(text = "新标题")

Compose 里 Text 的布局默认 wrap_content。填满父容器加 Modifier.fillMaxWidth()。没有 gravitylayout_gravity 这些属性,用 Modifier + Alignment 控制。

sp 是 Compose 里的单位扩展属性——18.sp 相当于 textSize="18sp",但类型安全。

Button

XML

<Button
    android:id="@+id/submitBtn"
    android:layout_width="match_parent"
    android:layout_height="48dp"
    android:text="提交"
    android:enabled="false"
    android:onClick="onSubmitClick" />

然后在 Activity 或 Fragment 里处理点击——XML 有 android:onClick 属性,也可以代码里绑:

// 方式一:android:onClick 在 Activity 里声明同名方法
fun onSubmitClick(view: View) {
    // 处理提交
}

// 方式二:代码里动态绑定
val submitBtn = findViewById<Button>(R.id.submitBtn)
submitBtn.isEnabled = false
submitBtn.setOnClickListener {
    // 处理点击
}

Compose

Button(
    onClick = { /* 点击 */ },
    enabled = false,
    modifier = Modifier
        .fillMaxWidth()
        .height(48.dp)
) {
    Text("提交")
}

Compose 的 Button onClick 是必填参数。Button 内容用 lambda(Text(...) 放在最后),可以塞文字、图标、甚至一排控件——跟 XML 里 Button 只能放一个 text 不一样。

EditText → TextField / OutlinedTextField

XML

<EditText
    android:id="@+id/input"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:hint="请输入用户名"
    android:inputType="text"
    android:maxLength="20" />

在代码里取文本和监听输入变化:

val input = findViewById<EditText>(R.id.input)

// 取文本
val text = input.text.toString()

// 监听输入变化
input.addTextChangedListener(object : TextWatcher {
    override fun afterTextChanged(s: Editable?) {
        val current = s?.toString() ?: ""
        if (current.length > 20) {
            input.error = "超出20个字符"
        }
    }
    override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
    override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
})

XML 体系下 EditText 自己持有文本状态——调用 input.text 取出当前值,调用 addTextChangedListener 监听变化。一个页面上多个输入框时,监听器代码会遍布各处,状态跟 View 耦合。

Compose

var text by remember { mutableStateOf("") }

OutlinedTextField(
    value = text,
    onValueChange = { text = it },
    label = { Text("请输入用户名") },
    singleLine = true,
    modifier = Modifier.fillMaxWidth()
)

关键区别:Compose 的 TextField 不持有状态——状态托管在 var text by remember { mutableStateOf("") } 里,开发者完全控制。这个"状态提升"(State Hoisting)是 Compose 的核心设计,底层原理篇会详细拆解。

Material3 提供了几种输入框:

// 密码
OutlinedTextField(
    value = password,
    onValueChange = { password = it },
    label = { Text("密码") },
    visualTransformation = PasswordVisualTransformation()
)

// 带错误提示
OutlinedTextField(
    value = text,
    onValueChange = { text = it },
    isError = text.length > 20,
    supportingText = {
        if (text.length > 20) Text("超出20个字符")
    }
)

EditText.addTextChangedListenerInputFilter 这套在 Compose 里全被 onValueChange + remember 替代——不需要写 listener,直接在 lambda 里处理。

ImageView → Image

XML

<ImageView
    android:id="@+id/avatar"
    android:layout_width="48dp"
    android:layout_height="48dp"
    android:scaleType="centerCrop"
    android:src="@drawable/ic_avatar" />

代码里加载本资源和网络图片:

val avatar = findViewById<ImageView>(R.id.avatar)

// 加载本地图片
avatar.setImageResource(R.drawable.ic_avatar)

// 用 Glide 加载网络图片
Glide.with(this)
    .load("https://example.com/avatar.jpg")
    .placeholder(R.drawable.ic_placeholder)
    .error(R.drawable.ic_error)
    .into(avatar)

// 或者用 Coil
avatar.load("https://example.com/avatar.jpg")

Compose

// 加载本地资源
Image(
    painter = painterResource(id = R.drawable.ic_avatar),
    contentDescription = "头像",
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(48.dp)
)

// 加载网络图片(需要 Coil)
AsyncImage(
    model = "https://example.com/avatar.jpg",
    contentDescription = "头像",
    contentScale = ContentScale.Crop,
    modifier = Modifier.size(48.dp)
)

contentDescription 在 ImageView 里是可选的,Compose 里却是必填——Google 用这个强制做无障碍。

LinearLayout → Column / Row

XML:垂直排列

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="vertical"
    android:padding="16dp">

    <TextView android:text="标题" ... />
    <TextView android:text="描述" ... />
    <Button android:text="操作" ... />
</LinearLayout>

Compose:垂直 → Column

Column(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    Text("标题")
    Text("描述")
    Button(onClick = {}) { Text("操作") }
}

XML:水平排列

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

    <TextView android:text="左" ... />
    <TextView android:text="右" ... />
</LinearLayout>

Compose:水平 → Row

Row(
    modifier = Modifier.fillMaxWidth()
) {
    Text("左")
    Spacer(modifier = Modifier.weight(1f)) // 把左右顶开
    Text("右")
}

LinearLayoutlayout_weight 在 Compose 里是 Modifier.weight()——要配合 ColumnRow 使用。Spacer + weight 替代了 XML 里写死 layout_weight=1 的占位 View。

FrameLayout → Box

XML

<FrameLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <ImageView android:src="@drawable/bg" android:scaleType="centerCrop" ... />
    <TextView android:text="居中文字" android:layout_gravity="center" ... />
</FrameLayout>

Compose

Box(
    modifier = Modifier.fillMaxSize()
) {
    Image(
        painter = painterResource(R.drawable.bg),
        contentDescription = null,
        contentScale = ContentScale.Crop,
        modifier = Modifier.fillMaxSize()
    )
    Text(
        "居中文字",
        modifier = Modifier.align(Alignment.Center)
    )
}

Box 相当于 FrameLayout——子控件叠加。Modifier.align(Alignment.Center) 取代了 layout_gravity="center"

RecyclerView → LazyColumn / LazyRow

XML:RecyclerView + Adapter

// 1. 定义 item 布局 item_user.xml
// 2. 写 Adapter
class UserAdapter : RecyclerView.Adapter<UserAdapter.VH>() {
    var users = listOf<User>()
        set(value) { field = value; notifyDataSetChanged() }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH { ... }
    override fun onBindViewHolder(holder: VH, position: Int) { ... }
    override fun getItemCount() = users.size

    inner class VH(view: View) : RecyclerView.ViewHolder(view) {
        val name = view.findViewById<TextView>(R.id.name)
    }
}

// 3. 在 Activity 中
val adapter = UserAdapter()
recyclerView.layoutManager = LinearLayoutManager(this)
recyclerView.adapter = adapter
adapter.users = userList

Compose:LazyColumn

LazyColumn {
    items(users) { user ->
        UserItem(user)
    }
}

@Composable
fun UserItem(user: User) {
    Row(modifier = Modifier.padding(12.dp)) {
        AsyncImage(
            model = user.avatar,
            contentDescription = null,
            modifier = Modifier.size(40.dp)
        )
        Column(modifier = Modifier.padding(start = 12.dp)) {
            Text(user.name, fontWeight = FontWeight.Bold)
            Text(user.desc, color = Color.Gray)
        }
    }
}

不需要 Adapter、不需要 ViewHolder、不需要 notifyDataSetChanged()、不需要 LayoutManager。数据变更直接更新 users 列表——Compose 自动算出哪些 item 需要重组。

LazyColumn 默认是垂直列表,LazyRow 是水平列表。items() 接受任意 List<T>itemsIndexed() 带下标。

带 key 防止错位:

LazyColumn {
    items(users, key = { it.id }) { user ->  // key 保证不串位
        UserItem(user)
    }
}

给 key 后,列表项的 compose state 跟 user.id 绑定。挪位置、增删 item 时不会出现"动画在别人的 item 上"那种问题。

带 Header / Footer:

LazyColumn {
    item { Text("头部", style = MaterialTheme.typography.titleLarge) }
    items(posts) { post -> PostItem(post) }
    item { Text("没有更多了", color = Color.Gray) }
}

ProgressBar → CircularProgressIndicator / LinearProgressIndicator

XML

<!-- 圆形,不确定进度 -->
<ProgressBar
    android:id="@+id/loading"
    android:layout_width="48dp"
    android:layout_height="48dp" />

<!-- 水平,确定进度 -->
<ProgressBar
    android:id="@+id/progress"
    style="@style/Widget.AppCompat.ProgressBar.Horizontal"
    android:layout_width="match_parent"
    android:layout_height="4dp"
    android:max="100"
    android:progress="60" />

代码里控制:

val loading = findViewById<ProgressBar>(R.id.loading)
loading.visibility = View.VISIBLE  // 开始转
loading.visibility = View.GONE     // 停

val progress = findViewById<ProgressBar>(R.id.progress)
progress.progress = 80             // 更新进度

Compose

// 圆形不确定——转圈
CircularProgressIndicator(
    modifier = Modifier.size(48.dp)
)

// 圆形确定进度
var progress by remember { mutableFloatStateOf(0.6f) }
CircularProgressIndicator(
    progress = { progress },       // 0f ~ 1f
    modifier = Modifier.size(48.dp)
)

// 水平不确定
LinearProgressIndicator(
    modifier = Modifier.fillMaxWidth()
)

// 水平确定进度
LinearProgressIndicator(
    progress = { progress },
    modifier = Modifier.fillMaxWidth()
)

XML 的 ProgressBarstyle 区分圆形/水平,靠 visibility 控制显示,靠 progress 值控制进度。Compose 显式拆成了两个控件——CircularProgressIndicatorLinearProgressIndicator——不用通过 style 区分。进度值用的 Float(0f ~ 1f),比 XML 的 Int(0 ~ max)更直接。

显示隐藏也不靠 visibility 属性——if (loading) { CircularProgressIndicator() } 节点直接存在或移除。XML 通过 visibility 隐藏时 ProgressBar 仍然在视图树上消耗 measure 开销;Compose 里节点不存在开销为零。

ScrollView → VerticalScroll

XML

<ScrollView android:layout_width="match_parent" android:layout_height="match_parent">
    <LinearLayout ...>
        <!-- 内容 -->
    </LinearLayout>
</ScrollView>

Compose

Column(
    modifier = Modifier
        .fillMaxSize()
        .verticalScroll(rememberScrollState())
) {
    // 内容
}

horizontalScroll() 对应水平滚动。rememberScrollState() 保存滚动位置。

CheckBox / Switch / RadioButton

XML

<CheckBox android:id="@+id/agree" android:text="同意协议" android:checked="false" />
<Switch android:id="@+id/toggle" android:checked="true" />
<RadioButton android:id="@+id/option1" android:text="选项1" />

代码里读取状态和监听变化:

// CheckBox
val agreeBox = findViewById<CheckBox>(R.id.agree)
agreeBox.setOnCheckedChangeListener { _, isChecked ->
    // isChecked 是当前勾选状态
}
// 读取
val agreed = agreeBox.isChecked

// Switch
val toggle = findViewById<Switch>(R.id.toggle)
toggle.setOnCheckedChangeListener { _, isChecked ->
    // 处理开关状态
}
val enabled = toggle.isChecked

// RadioButton
val option1 = findViewById<RadioButton>(R.id.option1)
option1.setOnCheckedChangeListener { _, isChecked ->
    if (isChecked) { /* 选中了选项1 */ }
}

XML 体系下每个开关控件自己持有勾选状态——isChecked 取值,setOnCheckedChangeListener 监听变化。多个 RadioButton 同组时需要手动互斥——比如存一个 selectedOption 变量,在一个 Listener 里改其他 RadioButton 的 isChecked。Compose 的 selected == "A" 表达式直接描述了这种互斥关系。

Compose

var agreed by remember { mutableStateOf(false) }

// CheckBox
Row(verticalAlignment = Alignment.CenterVertically) {
    Checkbox(checked = agreed, onCheckedChange = { agreed = it })
    Text("同意协议")
}

// Switch
var enabled by remember { mutableStateOf(true) }
Switch(checked = enabled, onCheckedChange = { enabled = it })

// RadioButton
var selected by remember { mutableStateOf("A") }
Row(verticalAlignment = Alignment.CenterVertically) {
    RadioButton(selected = selected == "A", onClick = { selected = "A" })
    Text("A")
    RadioButton(selected = selected == "B", onClick = { selected = "B" })
    Text("B")
}

所有开关型控件都由状态驱动——checked = agreed,onChange 里更新状态。XML 里 isChecked 取当前值,Compose 里值永远在外面的 remember 里,控件本身不存状态。

样式和 Theme

XML:styles.xml 和 multiple themes

<!-- styles.xml -->
<style name="TextAppearance.Title" parent="TextAppearance.MaterialComponents.Headline6">
    <item name="android:textColor">#333333</item>
    <item name="android:textSize">18sp</item>
</style>

<!-- 引用 -->
<TextView style="@style/TextAppearance.Title" ... />

Compose:MaterialTheme + Typography

MaterialTheme(
    colorScheme = lightColorScheme(
        primary = Color(0xFF6200EE),
        secondary = Color(0xFF03DAC6),
        background = Color.White,
        surface = Color.White,
        error = Color(0xFFB00020)
    ),
    typography = Typography(
        titleLarge = TextStyle(fontSize = 22.sp, fontWeight = FontWeight.Bold),
        titleMedium = TextStyle(fontSize = 16.sp, fontWeight = FontWeight.Medium),
        bodyLarge = TextStyle(fontSize = 16.sp),
        bodyMedium = TextStyle(fontSize = 14.sp),
        labelSmall = TextStyle(fontSize = 11.sp)
    )
) {
    // 内容
}

使用时:

Text("标题", style = MaterialTheme.typography.titleLarge)
Text("正文", style = MaterialTheme.typography.bodyMedium)

Compose 没有 XML 的 style 继承链,改用 MaterialTheme.typography 的预设 TextStyle。按钮颜色、卡片圆角、输入框描边色,全部由 MaterialTheme.colorSchemeShapes 集中控制。

不在每个控件上写颜色、字号、圆角——是声明式 UI 的风格约束:把主题变量提到顶层,以下是子节点消费这些变量。非不得已,不写死值。

View 的显示与隐藏

XML

<TextView
    android:id="@+id/status"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:text="加载中..."
    android:visibility="visible" />

三种 visibility 值——visible(显示并参与布局)、invisible(隐藏但占位)、gone(隐藏且不占位)。代码里动态切换:

val statusView = findViewById<TextView>(R.id.status)

// 显示
statusView.visibility = View.VISIBLE

// 隐藏
statusView.visibility = View.GONE

Compose

var loading by remember { mutableStateOf(true) }

// 简单条件
if (loading) {
    Text("加载中...")
}

// 带动画的显示/隐藏
AnimatedVisibility(visible = loading) {
    Text("加载中...")
}

两者最本质的区别:XML 是修改已有 View 的属性——同一个 View 实例,改它的 visibility 字段。Compose 是重组 UI 树——loading 为 true 时 Text 节点存在于 Composition 中;false 时这个节点被彻底移除,不占 Composition 空间。

XML 的 visibility 三种状态各有用途:gone 常用于条件布局——某个区域不满足条件时直接消失,后面的 View 自动补位。Compose 里没有 gone 的等价概念——节点从 Composition 移除后天然不影响布局,不需要额外声明"占不占位"。invisible 的等价是 Modifier.alpha(0f)——控件透明但仍存在于 Composition,保持布局占位。

// Compose 里模拟 invisible——透明但占位
Text(
    "加载中...",
    modifier = Modifier.alpha(if (loading) 1f else 0f)
)

ConstraintLayout 和 RelativeLayout 在 Compose 中

RelativeLayout → Box + Modifier.align

RelativeLayout 的核心是子 View 之间相对定位——"A 在 B 的右边"、"C 对齐父容器底部"。Compose 里没有独立的 RelativeLayout 控件,用 Box + Modifier.align() 覆盖大部分场景。

XML:RelativeLayout

<RelativeLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp">

    <ImageView
        android:id="@+id/avatar"
        android:layout_width="48dp"
        android:layout_height="48dp"
        android:layout_alignParentTop="true"
        android:layout_alignParentStart="true" />

    <TextView
        android:id="@+id/name"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_toEndOf="@id/avatar"
        android:layout_alignTop="@id/avatar"
        android:layout_marginStart="12dp"
        android:text="用户名" />

    <TextView
        android:id="@+id/time"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_alignParentEnd="true"
        android:layout_alignTop="@id/avatar"
        android:text="3分钟前" />
</RelativeLayout>

Compose:Box

Box(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    // 左上角的头像
    AsyncImage(
        model = user.avatar,
        contentDescription = null,
        modifier = Modifier
            .size(48.dp)
            .align(Alignment.TopStart)
    )

    // 头像右侧、顶对齐的用户名
    Text(
        user.name,
        modifier = Modifier
            .align(Alignment.TopStart)
            .padding(start = 60.dp)  // 48dp 头像 + 12dp 间距
    )

    // 右上角的时间
    Text(
        "3分钟前",
        modifier = Modifier.align(Alignment.TopEnd)
    )
}

多个元素对齐同一位置场景下,Box 的子元素按顺序叠加——后写的在上层。对齐方式:Alignment.TopStartAlignment.CenterAlignment.BottomEndAlignment.CenterStart 等。

复杂的相对布局("A 的底部对齐 B 的顶部,C 在 A 右侧 20dp"),Box 一层不够时可以用嵌套 Column/Row 拆解——性能开销比 XML 里嵌套 RelativeLayout 小很多。

ConstraintLayout → Compose ConstraintLayout

Compose 里有专门的 ConstraintLayout,API 跟 XML 版接近但语法用 Kotlin DSL。需要额外依赖:

implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")

XML:ConstraintLayout

<androidx.constraintlayout.widget.ConstraintLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="16dp">

    <ImageView
        android:id="@+id/avatar"
        android:layout_width="48dp"
        android:layout_height="48dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent" />

    <TextView
        android:id="@+id/name"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintTop_toTopOf="@id/avatar"
        app:layout_constraintStart_toEndOf="@id/avatar"
        app:layout_constraintEnd_toStartOf="@id/badge"
        android:layout_marginStart="12dp"
        android:text="用户名" />

    <ImageView
        android:id="@+id/badge"
        android:layout_width="20dp"
        android:layout_height="20dp"
        app:layout_constraintTop_toTopOf="@id/avatar"
        app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

Compose:ConstraintLayout

ConstraintLayout(
    modifier = Modifier
        .fillMaxWidth()
        .padding(16.dp)
) {
    val (avatar, name, badge) = createRefs()

    AsyncImage(
        model = user.avatar,
        contentDescription = null,
        modifier = Modifier
            .size(48.dp)
            .constrainAs(avatar) {
                top.linkTo(parent.top)
                start.linkTo(parent.start)
            }
    )

    Text(
        user.name,
        modifier = Modifier.constrainAs(name) {
            top.linkTo(avatar.top)
            start.linkTo(avatar.end, 12.dp)
            end.linkTo(badge.start, 8.dp)
            width = Dimension.fillToConstraints  // 等价于 0dp
        }
    )

    Icon(
        imageVector = Icons.Default.Verified,
        contentDescription = "认证",
        modifier = Modifier
            .size(20.dp)
            .constrainAs(badge) {
                top.linkTo(avatar.top)
                end.linkTo(parent.end)
            }
    )
}

createRefs() 用解构声明创建多个引用,constrainAs() 把 Modifier 跟约束绑定。Dimension.fillToConstraints 等价于 XML 的 0dp(填满约束空间)。

使用建议:Compose 的 ConstraintLayout 是从 View 体系桥接过来的——底层的 measure/layout 没有 Column/Row 高效。能用 Column/Row/Box 的组合解决就别上 ConstraintLayout。XML 时代必须用 ConstraintLayout 是因为嵌套 LinearLayout 成本高;Compose 里 Column 嵌套 Row 的开销主要来自重组,不是 measure 层,约束布局不再是刚需。

Compose 特有控件:Scaffold 和 Surface

Surface

Surface 是最基础的容器之一——一块带背景、阴影、圆角的可点击平面:

Surface(
    onClick = { /* 点击 */ },
    shape = RoundedCornerShape(12.dp),
    shadowElevation = 4.dp,
    color = MaterialTheme.colorScheme.surface,
    modifier = Modifier.fillMaxWidth()
) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text("标题")
        Text("内容")
    }
}

XML 里类似效果得靠 CardView 或者写 shape drawable + elevation:

<com.google.android.material.card.MaterialCardView
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:cardCornerRadius="12dp"
    app:cardElevation="4dp">
    <LinearLayout ...>
        <TextView android:text="标题" />
        <TextView android:text="内容" />
    </LinearLayout>
</com.google.android.material.card.MaterialCardView>

Surface 跟 CardView 的核心差异:Surface 的 onClick 自带水波纹(Ripple),CardView 还得套 foreground="?selectableItemBackground"

Scaffold

Scaffold 是 Material Design 的页面骨架——把顶栏、底栏、悬浮按钮、侧边抽屉、内容区整合在一个控件里。XML 体系下这些部件分别写在不同位置(Toolbar 在 include 里、BottomNavigation 在布局底、FloatingActionButton 单独写),状态同步靠代码协调。Scaffold 把它们统一管在一个 API 里:

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text("首页") },
            navigationIcon = {
                IconButton(onClick = { /* 打开抽屉 */ }) {
                    Icon(Icons.Default.Menu, contentDescription = "菜单")
                }
            },
            actions = {
                IconButton(onClick = { /* 搜索 */ }) {
                    Icon(Icons.Default.Search, contentDescription = "搜索")
                }
            }
        )
    },
    bottomBar = {
        NavigationBar {
            NavigationBarItem(
                icon = { Icon(Icons.Default.Home, contentDescription = "首页") },
                label = { Text("首页") },
                selected = true,
                onClick = { }
            )
            NavigationBarItem(
                icon = { Icon(Icons.Default.Person, contentDescription = "我的") },
                label = { Text("我的") },
                selected = false,
                onClick = { }
            )
        }
    },
    floatingActionButton = {
        FloatingActionButton(onClick = { }) {
            Icon(Icons.Default.Add, contentDescription = "新建")
        }
    },
    drawerContent = {
        Text("侧边抽屉内容")
    }
) { innerPadding ->
    // innerPadding 是 Scaffold 内部已占空间(顶栏、底栏等)的 padding 值
    // 内容区拿这个值设置 padding,确保内容不会被挡
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(innerPadding)
    ) {
        Text("主内容区域")
    }
}

Scaffold 内部有一套叫 ScaffoldLayout 的布局逻辑——不是公开 API,是内部实现:

  • 内容区填满整个 Scaffold 空间
  • 顶栏贴顶部、底栏贴底部、FAB 在右下角
  • innerPadding 自动计算顶栏 + 底栏的高度,传给内容区做 padding
  • 软键盘弹起时底栏自动上移

XML 体系下这些行为要分别处理:android:windowSoftInputMode="adjustResize"、给内容区加 layout_marginBottom 避开 BottomNavigation、手动控制 FAB 的位置和动画。Scaffold 一个控件把这些全包了。

嵌套滚动联动:顶栏跟随列表滚动收起——XML 里需要 CoordinatorLayout + AppBarLayout + CollapsingToolbarLayout 三个控件配合。Compose 里 Scaffold 不直接支持,需要用 TopAppBarScrollBehavior

val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text("首页") },
            scrollBehavior = scrollBehavior
        )
    }
) { innerPadding ->
    LazyColumn(
        modifier = Modifier.fillMaxSize().padding(innerPadding),
        flingBehavior = scrollBehavior.flingBehavior
    ) {
        items(100) { Text("Item $it") }
    }
}

顶栏会随列表下滑缩小/收起,上滑展开——跟 CoordinatorLayout 的 collapsing toolbar 效果一样,但代码量减少了一个数量级。

对比总结表

功能XML ViewCompose
文本TextViewText()
按钮ButtonButton(onClick) { Text() }
输入框EditTextTextField / OutlinedTextField
图片ImageViewImage()
网络图片Glide/Coil 加载AsyncImage()
垂直布局LinearLayout(vertical)Column {}
水平布局LinearLayout(horizontal)Row {}
叠加布局FrameLayoutBox {}
约束布局ConstraintLayoutConstraintLayout {}(不建议优先用)
列表RecyclerView + AdapterLazyColumn {}
滚动ScrollView.verticalScroll(rememberScrollState())
勾选框CheckBoxCheckbox()
开关SwitchSwitch()
样式styles.xml 继承MaterialTheme.typography
颜色@color/xxxMaterialTheme.colorScheme.primary
间距margin / padding dpModifier.padding()
圆角shape drawableModifier.clip(RoundedCornerShape())
显示/隐藏visibilityif / AnimatedVisibility
点击事件setOnClickListenerModifier.clickable {} / onClick 参数
进度指示器ProgressBarCircularProgressIndicator / LinearProgressIndicator
相对布局RelativeLayoutBox + Modifier.align()
卡片容器MaterialCardViewSurface / Card
页面骨架Toolbar + BottomNav 分散写Scaffold

XML 体系的控件是状态自持的——EditText 存着自己的文本、CheckBox 存着自己的勾选状态、RecyclerView 存着滚动位置。Compose 的控件是无状态的——所有状态由开发者通过 remember / StateFlow 托管,控件只是当前状态的渲染函数。

这一点带来的感受是:从 XML 过来,头一两天会有些不适应——为什么每个 TextField 都要手动传 value?使用一段时间后就习惯了,反而发现状态统一管理后排查 bug 容易一些——状态变更是单向的,集中在 ViewModel 或者 Composable 函数的参数入口。

最后

这篇主要把 Compose 的背景和控件用法大概介绍了一下。XML 到 Compose 的迁移,控件本身的 API 是第一关,状态管理是第二关。前者的区别在于:所有状态都托管在外面;后者的区别是:不用 find 控件、不用 notify 变更、数据变了 UI 自动跟新。

声明式 UI 的思维方式跟命令式差别确实大。多动手写几个界面,尤其把 LazyColumn 和 TextField 这两个使用掌握——一个是数据列表自动刷新,一个是输入必接状态提升——这两个理解了,基础使用慢慢就掌握了。