Jetpack Compose 声明式 UI 开发

声明式界面正在取代传统的视图树写法,而 Compose 是 Android 官方给出的答案。本文带你从零上手 Jetpack Compose:可组合函数与内容设置、修饰符链式的顺序语义、常用布局容器、列表的键策略、Material 3 主题与页面骨架,以及状态记忆、预览和与旧视图的互操作方式。配合官方物料清单版本对照表,给出可直接落地的写法与常见坑清单。

一、从 View 体系到声明式 UI

传统 Android 开发中,我们通过 findViewById 拿到控件引用,再在回调里手动调用 setText、setVisibility 去"命令"界面发生变化。这套命令式模型在页面复杂之后会出现两个顽疾:状态与视图的同步点分散在各处,任何一处漏改都会造成 UI 与数据不一致;同时 View 的复用与回收逻辑需要手动维护。

Compose 把控制反转了过来:你只描述"在给定状态下界面长什么样",由框架负责在状态变化时重新执行描述函数并更新渲染,核心收益是状态单向流动——数据往下传,事件往上传。

维度View 体系Compose
编程模型命令式,手动改属性声明式,函数描述 UI
视图定义XML 布局文件Kotlin 函数
状态同步手动 findViewById + setter状态驱动,自动重组
组合方式继承与 XML include函数组合与参数传递
列表复用RecyclerView + AdapterLazyColumn + 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:material3Material 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:

能力RecyclerViewLazyColumn
复用单元ViewHolderitem 组合作用域
稳定标识getItemId + setHasStableIdskey = { it.id }
局部刷新notifyItemChanged状态变化自动生效
列表差异DiffUtil无需手写,key 匹配即可
头尾元素多 ViewTypeitem { } 直接声明

需要注意 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 数量,而在于三个心智模型的转变:

  1. 界面是状态的函数。不要问"如何改这个控件",要问"这个状态应该由谁持有"。
  2. Modifier 是有序的装饰链。顺序决定语义,读代码要从外向内。
  3. 重组是常态,范围要可控。用 key、remember、derivedStateOf 把重组限制在必要范围内。

一句话总结: Compose 的入门门槛在语法,进阶门槛在重组——把状态放对位置、把 Modifier 排对顺序、把 key 给对身份,就能写出既简洁又高效的声明式界面。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「Android 开发」更多文章

  1. Kotlin Multiplatform 跨平台共享
  2. Android R8 混淆与 Baseline Profile
  3. Android 测试体系:单元测试、Espresso 与 Compose 测试