Compose 入门篇

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 体系里分散在多个属性中的配置:paddingmargin宽高背景点击事件圆角滚动行为。多个 Modifier 用 . 链在一起,顺序会影响最终效果------paddingbackground 顺序修改,效果不同。

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()。没有 gravitylayout_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.addTextChangedListenerInputFilter 这套在 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("右")
}

LinearLayoutlayout_weight 在 Compose 里是 Modifier.weight()------要配合 ColumnRow 使用。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 的 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

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.colorSchemeShapes 集中控制。

不在每个控件上写颜色、字号、圆角------是声明式 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.TopStartAlignment.CenterAlignment.BottomEndAlignment.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 这两个使用掌握------一个是数据列表自动刷新,一个是输入必接状态提升------这两个理解了,基础使用慢慢就掌握了。

相关推荐
杉氧4 小时前
Compose 时代的 MVI 架构:如何用单向数据流驱动复杂 UI?
android·架构·android jetpack
杉氧5 小时前
Modifier 的艺术:为什么链式调用的顺序决定了UI 的生命周期?
android·架构·android jetpack
李斯维5 小时前
腾讯 XLog 日志框架 Android 端接入
android·android studio·android jetpack
黄林晴5 小时前
Kotlin Toolchain 0.11 发布:Amper 正式更名,统一 kotlin 命令
android·kotlin
雨白7 小时前
C语言基础快速入门与指针初探
android
Exploring8 小时前
避坑指南:升级 AGP 8.0+ 导致第三方 SDK 编译崩溃的完美解决方案
android
石山岭1 天前
自己动手写了一个 Android 虚拟定位 App:GPSSimulate 技术实
android·前端
杉氧1 天前
副作用 (Side Effects) 全攻略:如何像大师一样掌控 Composable 的生命周期?
android·架构·android jetpack
唐青枫1 天前
别再把 inline 当性能开关:Kotlin 内联、noinline、crossinline 与 reified 实战详解
kotlin