一、从 View 体系到声明式 UI
传统 Android 开发中,我们通过 findViewById 拿到控件引用,再在回调里手动调用 setText、setVisibility 去"命令"界面发生变化。这套命令式模型在页面复杂之后会出现两个顽疾:状态与视图的同步点分散在各处,任何一处漏改都会造成 UI 与数据不一致;同时 View 的复用与回收逻辑需要手动维护。
Compose 把控制反转了过来:你只描述"在给定状态下界面长什么样",由框架负责在状态变化时重新执行描述函数并更新渲染,核心收益是状态单向流动——数据往下传,事件往上传。
| 维度 | View 体系 | Compose |
|---|---|---|
| 编程模型 | 命令式,手动改属性 | 声明式,函数描述 UI |
| 视图定义 | XML 布局文件 | Kotlin 函数 |
| 状态同步 | 手动 findViewById + setter | 状态驱动,自动重组 |
| 组合方式 | 继承与 XML include | 函数组合与参数传递 |
| 列表复用 | RecyclerView + Adapter | LazyColumn + item 函数 |
| 最小更新粒度 | 单个 View 属性 | 单个 Composable 作用域 |
需要强调的是,Compose 并非"更快的 View",而是不同的抽象层级。把它当成"写起来更短的 XML"来用,往往会写出频繁重组、难以调试的代码。理解重组与状态的作用域,比记住多少 API 更重要,这一点在 Compose 状态管理与重组优化 中有更深入的分析。
一句话总结:Compose 的价值不在于少写代码,而在于把"界面 = 状态的函数"这一等式变成编译器可以验证的事实。
二、Compose 编译器与 @Composable
@Composable 不是普通注解,它是给 Compose 编译器插件(Kotlin 2.0 起随 Kotlin 编译器一同发布,插件 id 为 org.jetbrains.kotlin.plugin.compose)的指令。编译器会改写函数签名,插入 Composer 参数、$changed 位掩码以及可跳过(skippable)与可重启(restartable)的运行时逻辑。
理解这一点能解释很多"看起来反直觉"的规则:
@Composable函数只能在另一个@Composable函数中调用,因为它需要上游传递Composer。- 只有返回
Unit的 Composable 才能被编译器正确改写,需要返回值的场景要用remember持有结果。 if/when分支可以出现在 Composable 内部,但普通for循环要改用forEach,因为编译器需要稳定的调用位置(call site)来做增量更新。
Gradle 侧的关键配置如下(Kotlin 2.0 之后不再需要单独指定 composeOptions.kotlinCompilerExtensionVersion):
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose") version "2.0.21"
}
android { buildFeatures { compose = true } }
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.09.00")
implementation(composeBom)
implementation("androidx.compose.material3:material3")
implementation("androidx.compose.ui:ui-tooling-preview")
debugImplementation("androidx.compose.ui:ui-tooling")
implementation("androidx.activity:activity-compose:1.9.3")
}
BOM(Bill of Materials) 是 Compose 依赖管理的核心机制:声明 BOM 后,所有 androidx.compose.* 构件不再写版本号,由 BOM 统一锁定。这样升级时只需改一行 BOM 版本,避免 ui 与 material3 版本错配导致的 NoSuchMethodError。
| 构件 | 作用 | 是否需要显式版本 |
|---|---|---|
androidx.compose:compose-bom | 版本对齐 | 需要(即 BOM 版本) |
androidx.compose.ui:ui | 基础 UI 与布局 | 否,由 BOM 提供 |
androidx.compose.material3:material3 | Material 3 组件 | 否 |
androidx.compose.ui:ui-tooling | 预览与检查器 | 否,建议 debugImplementation |
三、setContent 与第一个界面
Activity 中启用 Compose 只需把 setContentView(R.layout.xxx) 换成 setContent { }。ComponentActivity 提供的 setContent 扩展会创建一个 ComposeView 并挂到内容视图上,同时在内部建立 Recomposer 与 AndroidComposeView 的生命周期绑定。
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Greeting(name = "Android")
}
}
}
}
}
@Composable
fun Greeting(name: String, modifier: Modifier = Modifier) {
Text(
text = "Hello, $name!",
modifier = modifier.padding(16.dp),
style = MaterialTheme.typography.headlineSmall
)
}
注意 Greeting 的参数顺序:modifier 放在最后一个且带默认值,这是 Compose 的官方约定。它让调用方可以自然地追加样式,也让函数本身保持可组合性。所有公开的 Composable 都应遵循这一约定。
如果只需要在 Fragment 或已有 View 树中嵌入一小块 Compose 界面,用 ComposeView 而不是 setContent:
val composeView = ComposeView(requireContext()).apply {
// 必须设置策略,否则视图销毁时组合不会释放
setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
setContent { AppTheme { ProfileScreen() } }
}
setViewCompositionStrategy 是 Fragment 场景下最容易漏掉的一步:默认策略不会在视图销毁时释放组合(composition),长期驻留的 Fragment 会因此泄漏,而 DisposeOnViewTreeLifecycleDestroyed 让组合随视图生命周期销毁而释放。
四、布局基础 Column Row Box
Compose 没有 LinearLayout、RelativeLayout 这些概念,取而代之的是三个基础布局容器:Column 纵向排列、Row 横向排列、Box 层叠(等价于 FrameLayout)。
@Composable
fun ProfileCard(name: String, bio: String) {
Row(
modifier = Modifier.fillMaxWidth().padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Box(
modifier = Modifier
.size(56.dp)
.clip(CircleShape)
.background(MaterialTheme.colorScheme.primaryContainer),
contentAlignment = Alignment.Center
) {
Text(text = name.take(1), style = MaterialTheme.typography.titleLarge)
}
Column(modifier = Modifier.weight(1f)) {
Text(text = name, style = MaterialTheme.typography.titleMedium)
Text(text = bio, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
Modifier.weight(1f) 只能在 Column 或 Row 的直接子项上使用,它对应 LinearLayout 的 layout_weight。在 Box 或嵌套层级更深的节点上使用会抛 IllegalStateException。同理 Arrangement.spacedBy 是容器级参数,不要用给每个子项加 padding 的方式模拟间距——那样会在首尾多出不需要的边距。
三个容器的对齐参数也不同名,容易混淆:Column 用 verticalArrangement 配 horizontalAlignment,Row 用 horizontalArrangement 配 verticalAlignment,Box 只有 contentAlignment。
五、Modifier 链式调用与顺序语义
Modifier 是 Compose 中最容易被低估的设计。它本质上是一个不可变的链表,链上每个元素按声明顺序由外向内包裹元素。顺序不同,视觉效果与命中区域可能完全不同。
// 先 padding 再 background:背景只覆盖内容区域
Text(text = "A", modifier = Modifier.padding(16.dp).background(Color.Yellow))
// 先 background 再 padding:背景覆盖包含 padding 的整块区域
Text(text = "B", modifier = Modifier.background(Color.Yellow).padding(16.dp))
两者的差异是:前者的黄色区域等于文字尺寸,后者等于文字尺寸加上四周 16dp。这是"顺序语义"最典型的例子。
另一类顺序陷阱与点击区域有关。clickable 放在 padding 之前,点击热区包含 padding;放在之后,热区只有内容。所以 Modifier.clickable { onClick() }.padding(16.dp) 的热区比 Modifier.padding(16.dp).clickable { onClick() } 更大。
常用 Modifier 的职责可以按"影响自身"与"影响父容器"分类:
| Modifier | 作用对象 | 典型用途 |
|---|---|---|
padding | 自身 | 内边距 |
fillMaxWidth | 自身 | 撑满可用宽度 |
weight | 父容器 | 按比例分配剩余空间 |
size / height | 自身 | 固定尺寸 |
clip | 自身 | 圆角裁剪,必须在 background 之前 |
clickable | 自身 | 点击与无障碍语义 |
一个常见的坑是 clip 与 background 的顺序:若写成 background(...).clip(...),背景色不会被圆角裁剪,圆角效果只在后续内容上生效。正确顺序是 clip(...).background(...)。
一句话总结:Modifier 是"由外向内"的装饰链,读代码时要像读洋葱一样从外往里看,顺序错了语义就错了。
六、列表与 LazyColumn
LazyColumn 是 RecyclerView 的声明式替代。它只组合当前可见区域及其预取窗口内的 item,其余按需创建,因此天然支持无限列表。
@Composable
fun MessageList(messages: List<Message>, onItemClick: (Message) -> Unit) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
item(key = "header") { Text(text = "共 ${messages.size} 条") }
items(items = messages, key = { it.id }) { message ->
MessageRow(message = message, onClick = { onItemClick(message) })
}
}
}
key 是 LazyColumn 正确性的关键。不提供 key 时,Compose 按位置(index)复用 item 状态;一旦列表发生插入、删除或排序,remember 保存的状态会留在原位置,导致输入框内容错位、动画跳变等问题。提供一个稳定且唯一的 key(通常是数据实体的 id)可以让 Compose 追踪 item 的身份而非位置。
对比 RecyclerView 的 Adapter:
| 能力 | RecyclerView | LazyColumn |
|---|---|---|
| 复用单元 | ViewHolder | item 组合作用域 |
| 稳定标识 | getItemId + setHasStableIds | key = { it.id } |
| 局部刷新 | notifyItemChanged | 状态变化自动生效 |
| 列表差异 | DiffUtil | 无需手写,key 匹配即可 |
| 头尾元素 | 多 ViewType | item { } 直接声明 |
需要注意 items 的 key 必须全局唯一。如果两个 item 返回相同的 key,Compose 会抛出 IllegalArgumentException: Key "xxx" was already used。
七、Material 3 与 Scaffold
Material 3(androidx.compose.material3)是当前推荐的组件库,相比 Material 2 换用了动态取色(dynamic color)与新的设计令牌。MaterialTheme 是整套样式的根,提供 colorScheme、typography、shapes 三组令牌。
@Composable
fun AppTheme(darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit) {
val colorScheme = if (darkTheme) darkColorScheme() else lightColorScheme(
primary = Color(0xFF6750A4), surface = Color(0xFFFFFBFE)
)
MaterialTheme(colorScheme = colorScheme, typography = Typography(), content = content)
}
Scaffold 提供标准的页面骨架,负责处理系统栏内边距(insets)、顶部栏、底部栏、悬浮按钮的槽位:
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(onAdd: () -> Unit) {
Scaffold(
topBar = { TopAppBar(title = { Text("消息") }) },
floatingActionButton = {
FloatingActionButton(onClick = onAdd) {
Icon(Icons.Default.Add, contentDescription = "新增")
}
}
) { innerPadding ->
// 必须消费 innerPadding,否则内容会被系统栏或顶栏遮挡
MessageList(emptyList(), {}, Modifier.padding(innerPadding))
}
}
Scaffold 的 innerPadding 是最常被忽略的参数。直接丢弃它会让内容顶到状态栏下方或被底部导航遮挡。正确做法是把 innerPadding 交给最外层的内容容器,或按需拆分消费。
八、输入与交互
Material 3 中 TextField 分两种外观:填充式的 TextField 与描边式的 OutlinedTextField,二者都接受 value 与 onValueChange 组成受控输入。
@Composable
fun LoginForm(onSubmit: (String, String) -> Unit) {
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
val canSubmit by remember {
derivedStateOf { email.contains("@") && password.length >= 8 }
}
Column(
modifier = Modifier.fillMaxWidth().padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
value = email,
onValueChange = { email = it },
label = { Text("邮箱") },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
modifier = Modifier.fillMaxWidth()
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth()
)
Button(onClick = { onSubmit(email, password) }, enabled = canSubmit) {
Text("登录")
}
}
}
这里 canSubmit 用 derivedStateOf 而不是直接写表达式,是因为它只依赖两个状态变量,用派生状态可以避免在 email 每次击键时都触发 Button 的重组——只有 canSubmit 的布尔值真正翻转时才重组。
九、状态 remember rememberSaveable derivedStateOf
Compose 的状态 API 围绕 mutableStateOf 展开,但三者的使用场景截然不同:
| API | 存活范围 | 适用场景 |
|---|---|---|
remember | 组合的生命周期 | 临时 UI 状态,如展开/折叠 |
rememberSaveable | 组合 + 进程重建 | 输入框内容、滚动位置 |
derivedStateOf | 组合的生命周期 | 由其他状态推导的昂贵计算结果 |
rememberSaveable 依赖 SavedStateRegistry,因此要求保存的值可被 Bundle 序列化。自定义类型需要提供 Saver:
private val FilterSaver = listSaver<Filter, Any>(
save = { listOf(it.keyword, it.onlyUnread) },
restore = { Filter(it[0] as String, it[1] as Boolean) }
)
var filter by rememberSaveable(stateSaver = FilterSaver) { mutableStateOf(Filter("", false)) }
derivedStateOf 的典型误用是把简单表达式也包起来:若计算成本极低(比如 a + b),直接写表达式反而更省,因为 derivedStateOf 本身要注册依赖追踪。它真正有价值的场景是"输入频繁变化、输出变化稀疏",例如由滚动位置推导"是否显示回到顶部按钮"。另一个常见错误是把状态声明在错误的层级,状态应尽量下沉到真正使用它的 Composable,否则任何一次变化都会触发整棵树的重组。
十、CompositionLocal 与隐式传递
CompositionLocal 用于跨层级传递"隐式"数据,典型例子是 MaterialTheme 内部就是用 LocalColorScheme、LocalTypography 实现的。它适合主题、语言、密度这类全局且少变的数据。
val LocalSnackbarHost = compositionLocalOf<SnackbarHostState> { error("未提供") }
CompositionLocalProvider(LocalSnackbarHost provides hostState) {
HomeScreen() // 子树任意深度都能通过 LocalSnackbarHost.current 访问
}
两种工厂函数的差异很关键:compositionLocalOf 的读取会被追踪,值变化时只有读取过它的 Composable 重组;staticCompositionLocalOf 不追踪读取,值变化时整个子树重组,但读取开销更低。因此主题这类几乎不变的数据适合 staticCompositionLocalOf,频繁变化的数据适合 compositionLocalOf。代价是隐式依赖会削弱可测试性与可读性,除主题、本地化等真正的全局上下文外,业务数据仍应通过参数显式传递。
十一、@Preview 与开发效率
@Preview 让 Composable 无需运行 App 即可在 Android Studio 中渲染,是 Compose 相比 XML 最大的开发体验优势之一。
@Preview(name = "浅色", showBackground = true, locale = "zh-rCN")
@Preview(name = "深色", showBackground = true, uiMode = Configuration.UI_MODE_NIGHT_YES)
@Composable
private fun ProfileCardPreview() {
AppTheme { ProfileCard(name = "Leeting", bio = "Android 开发工程师") }
}
@Preview 函数必须是 private、无参、返回 Unit,并且要依赖 ui-tooling-preview 与 debugImplementation("androidx.compose.ui:ui-tooling")。预览无法渲染依赖网络或真实 ViewModel 的界面,这类场景应把 Composable 设计为接收纯数据参数,预览时传入假数据。
十二、与 View 体系互操作
迁移是渐进的,Compose 与 View 必须能双向互嵌。
在 Compose 中使用 View:用 AndroidView。
@Composable
fun MapView(location: LatLng) = AndroidView(
factory = { context -> MapLibreView(context) }, // 只调用一次,负责创建
update = { view -> view.setCameraPosition(location) }, // 重组时同步状态
modifier = Modifier.fillMaxSize()
)
factory 与 update 的职责划分是互操作的核心:factory 只执行一次做初始化,update 在重组时执行做刷新;把创建逻辑写进 update 会导致每次重组都新建 View。
在 View 中使用 Compose:用 ComposeView,或用 ViewCompositionStrategy 控制组合释放时机。对于 RecyclerView 的 item,应使用 ComposeView 并设置 DisposeOnViewTreeLifecycleDestroyed,避免 item 回收后组合残留。
四个桥接 API 的对应关系是:Compose 内嵌 View 用 AndroidView 或 AndroidViewBinding(后者用于嵌入已有 XML 布局);View 内嵌 Compose 用 ComposeView 或 ComponentActivity 专用的 setContent。
十三、常见坑清单
| 坑位 | 现象 | 修正 |
|---|---|---|
忘记消费 innerPadding | 内容被系统栏遮挡 | 把 innerPadding 交给内容容器 |
LazyColumn 未设 key | 增删后状态错位 | items(key = { it.id }) |
clip 在 background 之后 | 圆角不生效 | 调整为 clip(...).background(...) |
weight 用在非直接子项 | 抛 IllegalStateException | 只对 Column/Row 直接子项使用 |
ComposeView 未设策略 | Fragment 泄漏 | DisposeOnViewTreeLifecycleDestroyed |
| 状态提到顶层 | 整树重组 | 状态下沉到使用它的节点 |
derivedStateOf 滥用 | 性能反降 | 仅在输入频繁、输出稀疏时使用 |
@Preview 依赖真实 ViewModel | 预览崩溃 | 拆分纯数据参数的 Composable |
| BOM 与显式版本混用 | 版本冲突 | 统一由 BOM 管理版本 |
@Composable 返回非 Unit | 编译报错 | 改为副作用或返回值显式声明 |
版本矩阵建议:Kotlin 2.0.21 / 2.1.x、AGP 8.5+、Compose BOM 2024.09.00 及以上、activity-compose 1.9.x、androidx.compose.material3 随 BOM;升级 Kotlin 2.0 后务必启用 org.jetbrains.kotlin.plugin.compose,否则会因编译器插件缺失而无法编译 Composable。
从 View 迁移到 Compose 的节奏建议:先在新页面使用 Compose,把 ComposeView 作为旧页面内的嵌入点,等基础组件沉淀后再逐步替换。生命周期与状态持有的部分需要与 Android 生命周期与 ViewModel
配合设计,否则容易在 setContent 里直接持有 Activity 引用。
小结
Jetpack Compose 的学习曲线不在于 API 数量,而在于三个心智模型的转变:
- 界面是状态的函数。不要问"如何改这个控件",要问"这个状态应该由谁持有"。
- Modifier 是有序的装饰链。顺序决定语义,读代码要从外向内。
- 重组是常态,范围要可控。用
key、remember、derivedStateOf把重组限制在必要范围内。
一句话总结: Compose 的入门门槛在语法,进阶门槛在重组——把状态放对位置、把 Modifier 排对顺序、把 key 给对身份,就能写出既简洁又高效的声明式界面。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。