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 体系是命令式的写法:
kotlin
// 先找到 View
val textView = findViewById<TextView>(R.id.title)
// 当数据变了,手动更新 View
textView.text = newTitle
开发者需要手动维护"数据状态"和"View 显示"之间的同步关系。数据有 10 个地方可能变,就有 10 处 setText()。忘掉一处,View 显示就落后于实际数据了。Bug 难查,状态散落各处。
Compose 是声明式的写法:
kotlin
// 把 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(模块级)
kotlin
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 写法:
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.10 | 1.9.22 |
| 1.5.12 | 1.9.23 |
| 1.5.14 | 1.9.24 |
从 Kotlin 2.0 开始 Compose 编译器以 Kotlin 编译器插件形式存在,版本号跟着 Kotlin 走。如果项目用的是 Kotlin 2.0+,kotlinCompilerExtensionVersion 不需要再单独配了------直接在 plugins 里声明:
kotlin
plugins {
id("org.jetbrains.kotlin.plugin.compose") version "2.0.0"
}
Kotlin 1.9.x 的项目才需要 composeOptions { kotlinCompilerExtensionVersion }。
版本管理抽到 libs.versions.toml
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" }
然后在模块里:
kotlin
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)
默认代码
kotlin
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 体系里分散在多个属性中的配置:
padding、margin、宽高、背景、点击事件、圆角、滚动行为。多个 Modifier 用.链在一起,顺序会影响最终效果------padding和background顺序修改,效果不同。
TextView → Text
XML
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" />
然后在代码里:
kotlin
val title = findViewById<TextView>(R.id.title)
title.text = "新标题"
Compose
kotlin
@Composable
fun Title(text: String) {
Text(
text = text,
fontSize = 18.sp,
color = Color(0xFF333333),
fontWeight = FontWeight.Bold,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
调用时不需要找 Id------直接传参数:
kotlin
Title(text = "新标题")
Compose 里 Text 的布局默认 wrap_content。填满父容器加 Modifier.fillMaxWidth()。没有 gravity 和 layout_gravity 这些属性,用 Modifier + Alignment 控制。
sp 是 Compose 里的单位扩展属性------18.sp 相当于 textSize="18sp",但类型安全。
Button
XML
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 属性,也可以代码里绑:
kotlin
// 方式一:android:onClick 在 Activity 里声明同名方法
fun onSubmitClick(view: View) {
// 处理提交
}
// 方式二:代码里动态绑定
val submitBtn = findViewById<Button>(R.id.submitBtn)
submitBtn.isEnabled = false
submitBtn.setOnClickListener {
// 处理点击
}
Compose
kotlin
Button(
onClick = { /* 点击 */ },
enabled = false,
modifier = Modifier
.fillMaxWidth()
.height(48.dp)
) {
Text("提交")
}
Compose 的 Button onClick 是必填参数。Button 内容用 lambda(Text(...) 放在最后),可以塞文字、图标、甚至一排控件------跟 XML 里 Button 只能放一个 text 不一样。
EditText → TextField / OutlinedTextField
XML
xml
<EditText
android:id="@+id/input"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="请输入用户名"
android:inputType="text"
android:maxLength="20" />
在代码里取文本和监听输入变化:
kotlin
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
kotlin
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 提供了几种输入框:
kotlin
// 密码
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.addTextChangedListener 和 InputFilter 这套在 Compose 里全被 onValueChange + remember 替代------不需要写 listener,直接在 lambda 里处理。
ImageView → Image
XML
xml
<ImageView
android:id="@+id/avatar"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerCrop"
android:src="@drawable/ic_avatar" />
代码里加载本资源和网络图片:
kotlin
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
kotlin
// 加载本地资源
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:垂直排列
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
kotlin
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text("标题")
Text("描述")
Button(onClick = {}) { Text("操作") }
}
XML:水平排列
xml
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<TextView android:text="左" ... />
<TextView android:text="右" ... />
</LinearLayout>
Compose:水平 → Row
kotlin
Row(
modifier = Modifier.fillMaxWidth()
) {
Text("左")
Spacer(modifier = Modifier.weight(1f)) // 把左右顶开
Text("右")
}
LinearLayout 的 layout_weight 在 Compose 里是 Modifier.weight()------要配合 Column 或 Row 使用。Spacer + weight 替代了 XML 里写死 layout_weight=1 的占位 View。
FrameLayout → Box
XML
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
kotlin
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
kotlin
// 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
kotlin
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 防止错位:
kotlin
LazyColumn {
items(users, key = { it.id }) { user -> // key 保证不串位
UserItem(user)
}
}
给 key 后,列表项的 compose state 跟 user.id 绑定。挪位置、增删 item 时不会出现"动画在别人的 item 上"那种问题。
带 Header / Footer:
kotlin
LazyColumn {
item { Text("头部", style = MaterialTheme.typography.titleLarge) }
items(posts) { post -> PostItem(post) }
item { Text("没有更多了", color = Color.Gray) }
}
ProgressBar → CircularProgressIndicator / LinearProgressIndicator
XML
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" />
代码里控制:
kotlin
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
kotlin
// 圆形不确定------转圈
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 的 ProgressBar 靠 style 区分圆形/水平,靠 visibility 控制显示,靠 progress 值控制进度。Compose 显式拆成了两个控件------CircularProgressIndicator 和 LinearProgressIndicator------不用通过 style 区分。进度值用的 Float(0f ~ 1f),比 XML 的 Int(0 ~ max)更直接。
显示隐藏也不靠 visibility 属性------if (loading) { CircularProgressIndicator() } 节点直接存在或移除。XML 通过 visibility 隐藏时 ProgressBar 仍然在视图树上消耗 measure 开销;Compose 里节点不存在开销为零。
ScrollView → VerticalScroll
XML
xml
<ScrollView android:layout_width="match_parent" android:layout_height="match_parent">
<LinearLayout ...>
<!-- 内容 -->
</LinearLayout>
</ScrollView>
Compose
kotlin
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
) {
// 内容
}
horizontalScroll() 对应水平滚动。rememberScrollState() 保存滚动位置。
CheckBox / Switch / RadioButton
XML
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" />
代码里读取状态和监听变化:
kotlin
// 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
kotlin
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
xml
<!-- 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
kotlin
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)
)
) {
// 内容
}
使用时:
kotlin
Text("标题", style = MaterialTheme.typography.titleLarge)
Text("正文", style = MaterialTheme.typography.bodyMedium)
Compose 没有 XML 的 style 继承链,改用 MaterialTheme.typography 的预设 TextStyle。按钮颜色、卡片圆角、输入框描边色,全部由 MaterialTheme.colorScheme 和 Shapes 集中控制。
不在每个控件上写颜色、字号、圆角------是声明式 UI 的风格约束:把主题变量提到顶层,以下是子节点消费这些变量。非不得已,不写死值。
View 的显示与隐藏
XML
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(隐藏且不占位)。代码里动态切换:
kotlin
val statusView = findViewById<TextView>(R.id.status)
// 显示
statusView.visibility = View.VISIBLE
// 隐藏
statusView.visibility = View.GONE
Compose
kotlin
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,保持布局占位。
kotlin
// 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
xml
<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
kotlin
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.TopStart、Alignment.Center、Alignment.BottomEnd、Alignment.CenterStart 等。
复杂的相对布局("A 的底部对齐 B 的顶部,C 在 A 右侧 20dp"),Box 一层不够时可以用嵌套 Column/Row 拆解------性能开销比 XML 里嵌套 RelativeLayout 小很多。
ConstraintLayout → Compose ConstraintLayout
Compose 里有专门的 ConstraintLayout,API 跟 XML 版接近但语法用 Kotlin DSL。需要额外依赖:
kotlin
implementation("androidx.constraintlayout:constraintlayout-compose:1.0.1")
XML:ConstraintLayout
xml
<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
kotlin
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 是最基础的容器之一------一块带背景、阴影、圆角的可点击平面:
kotlin
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:
xml
<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 里:
kotlin
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:
kotlin
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 View | Compose |
|---|---|---|
| 文本 | TextView |
Text() |
| 按钮 | Button |
Button(onClick) { Text() } |
| 输入框 | EditText |
TextField / OutlinedTextField |
| 图片 | ImageView |
Image() |
| 网络图片 | Glide/Coil 加载 | AsyncImage() |
| 垂直布局 | LinearLayout(vertical) |
Column {} |
| 水平布局 | LinearLayout(horizontal) |
Row {} |
| 叠加布局 | FrameLayout |
Box {} |
| 约束布局 | ConstraintLayout |
ConstraintLayout {}(不建议优先用) |
| 列表 | RecyclerView + Adapter |
LazyColumn {} |
| 滚动 | ScrollView |
.verticalScroll(rememberScrollState()) |
| 勾选框 | CheckBox |
Checkbox() |
| 开关 | Switch |
Switch() |
| 样式 | styles.xml 继承 |
MaterialTheme.typography |
| 颜色 | @color/xxx |
MaterialTheme.colorScheme.primary |
| 间距 | margin / padding dp |
Modifier.padding() |
| 圆角 | shape drawable |
Modifier.clip(RoundedCornerShape()) |
| 显示/隐藏 | visibility |
if / AnimatedVisibility |
| 点击事件 | setOnClickListener |
Modifier.clickable {} / onClick 参数 |
| 进度指示器 | ProgressBar |
CircularProgressIndicator / LinearProgressIndicator |
| 相对布局 | RelativeLayout |
Box + Modifier.align() |
| 卡片容器 | MaterialCardView |
Surface / 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 这两个使用掌握------一个是数据列表自动刷新,一个是输入必接状态提升------这两个理解了,基础使用慢慢就掌握了。