这两年我越来越频繁地被问到同一个问题:新项目到底还用不用 XML?每次我都只有一个回答——如果你还在纠结,说明你还没真正体验过 Android 原生的 Compose A2UI。
先说明白,这里的 Compose 是 Jetpack Compose,不是 Docker Compose,别被热搜词带偏了。A2UI 这个叫法源自社区对 "Android 2.0 UI" 的调侃,但本质上说的是同一件事:Google 在 2017 年立项、2021 年正式稳定、最近几年持续迭代的声明式 UI 框架。它代表着 Android UI 开发从"描述视图层级"走向"描述界面状态"的根本转变。
这篇文章不打算跟你争论"XML 会不会被淘汰",而是想从一个实际写过多年 XML、现在主力用 Compose 开发的人的角度,把核心原理、迁移路线、真实踩坑、选型建议一次讲清楚。如果你是一个维护老项目的中级 Android 开发,或者准备开新项目但还在观望的架构师,这篇文章应该能帮你省下不少试错时间。
1. 为什么说 XML 开发正在进入"养老模式"
1.1 那些年我们为了 XML 干过的苦力活
我 2017 年开始做 Android,那会儿写界面是这样的:先打开 activity_main.xml,拖一个 LinearLayout,嵌套几个 RelativeLayout,配一堆wrap_content、match_parent,然后回到 Activity 里findViewById,写setOnClickListener。后来有了 ButterKnife,再后来有了 ViewBinding,稍微舒服了一点,但本质上还是在用"命令式"的思维写界面。
这种模式最让人心累的不是写,而是"改"。产品说"这个按钮在登录状态下要变红",你得先findViewById,再setOnClickListener,然后在回调里setColor,如果还要考虑页面销毁时的状态恢复,那代码就开始失控了。更不用说遇到那种布局层级叠了七八层的 XML——最里层一个控件要改个 margin,牵一发而动全身,连 Android Studio 自带的 Layout Inspector 都卡。
1.2 XML 的真正瓶颈不在"写",而在"改"
很多人说 XML 性能差,其实纯粹写布局、渲染的效率并没有那么糟糕。真正要命的是状态与 UI 的同步。打个比方,XML 开发就像你在 Excel 里手动维护一份报表——数据变了你要自己找单元格去改;而 Compose 的声明式模型像你写好了公式——数据一变,整个报表自动重算。
具体到日常开发,XML 的痛点非常明确:
- 逻辑与 UI 割裂:界面长什么样在 XML 里,界面怎么响应在 Java/Kotlin 里,中间靠 id 和 findView 连接,改一个界面要跳三个文件。
- 嵌套层级失控:为了实现对齐和边距,很多人无意识地把 LinearLayout 套 RelativeLayout 再套 LinearLayout,最终把布局文件写到几千行。
- 状态恢复靠模板代码:旋转屏幕要保存状态,进后台要保存状态,每次都要写
onSaveInstanceState,又臭又长。 - 深色模式、动态字体、多端适配:每加一个新特性,就要在 XML 里铺一堆
values-night、values-sw600dp的资源目录,维护成本直线上升。
这些痛点单独看都能忍,但叠在一起,就是为什么越来越多团队在选择新项目技术栈时直接跳过 XML。
2. Compose A2UI 的底层逻辑:从"告诉系统怎样画"到"告诉系统画什么"
2.1 声明式 UI 的工作原理
Compose 的核心思想是声明式(Declarative)。你不再描述"这个 LinearLayout 包含一个 TextView,TextView 在父布局居中",而是直接描述"在界面的这个位置,应该显示这些文字,颜色取决于当前状态"。
举个例子,同样的界面在 XML 里你要写:
<LinearLayout android:layout_width="match_parent" android:layout_height="wrap_content" android:orientation="vertical"> <TextView android:id="@+id/tv_name" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="Hello" /> <Button android:id="@+id/btn_click" android:layout_width="wrap_content" android:layout_height="wrap_content" android:text="点击" /> </LinearLayout>然后在 Activity 里findViewById两次,再设置监听。而 Compose 里整个布局加逻辑是这样的:
@Composable fun Greeting(name: String, onClick: () -> Unit) { Column { Text(text = "Hello $name") Button(onClick = onClick) { Text(text = "点击") } } }关键区别在于:Compose 的函数接收的是数据(name),而不是视图实例。界面会根据数据自动更新,你再也不用拿着某个 view 的引用来手动改它的显示内容。
2.2 重组机制与性能模型
Compose 不是说放弃了性能换开发效率,它有自己的性能模型,核心叫重组(Recomposition)。
Compose 背后有一个类似"diff"的机制:当状态变化时,编译器会标记那些依赖了这个状态的 Composable 函数,然后只重新执行这些函数,跳过没有变化的节点。这个过程远比你想的轻量——它不重建整个 View 层级,只是在内部做了一次非常快的标识对比。
我第一次看到这个机制时也有个疑问:那不是每次都要跑一遍整棵组合树吗?其实不是。编译期 Compose 编译器会生成专门的"跳过逻辑"(skip group),比如参数没变就整个 Composable 直接跳过,连函数体都不进。用个人话解释:它不是在"重画全区",而是在"精准重涂受影响的路段"。
2.3 生态配套:Material 3、Navigation、ViewModel
光有声明式还不够,A2UI 能落地还靠整套生态配套:
- Material 3:动态配色、形状系统、组件规范,一套代码适配深浅色模式,颜色全部 token 化。
- Navigation Compose:导航变成类型安全的函数调用,不再写字符串路由和 Fragment 事务。
- ViewModel 集成:Compose 里直接
viewModel()拿实例,配合collectAsState(),ViewModel 的状态一变化 UI 自动刷新。
还有一个很多人忽视的点:Android TV、Wear OS 等平台也在全面转向 Compose,这意味着你写的一套 UI 逻辑,未来天然拥有跨形态设备的迁移基础。老项目里为 TV 单独写的一堆 XML 交互规范,在 Compose 里反而成了一个组合函数参数的事。
3. 从 XML 迁到 Compose 的实操路线
3.1 环境准备与第一个 Compose 页面
动手之前先确认环境。我用的是 Android Studio Hedgehog 之后的版本,Gradle 版本 8.x,AGP 8.x,Kotlin 1.9 以上,这些都满足的话,Compose 基本开箱即用。
build.gradle.kts里需要加这些依赖:
implementation(platform("androidx.compose:compose-bom:2024.09.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") implementation("androidx.compose.ui:ui-tooling-preview") debugImplementation("androidx.compose.ui:ui-tooling")然后在你现有的 Activity 里,把setContentView换成setContent:
class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyAppTheme { Greeting(name = "World") } } } }就算老项目里几十个页面都是 XML,也没关系。Compose 设计了ComposeView做互操作层,你在 XML 布局里可以嵌入 Compose 内容,反过来 Compose 里也可以用AndroidView包裹旧的 View 控件。这意味着迁移完全可以是增量式的,不用一次性推倒重来。
3.2 常用 XML 控件映射成 Compose 写法
如果你已经有 XML 基础,最快的学习方式就是看一张对应关系表。我自己总结了一份常用映射:
| XML 控件/布局 | Compose 等价物 | 说明 |
|---|---|---|
| LinearLayout(vertical) | Column | 纵向排列 |
| LinearLayout(horizontal) | Row | 横向排列 |
| FrameLayout | Box | 叠加布局 |
| RelativeLayout | Box + Modifier.align | 相对定位 |
| ConstraintLayout | ConstraintLayout | Compose 也有同名 API |
| TextView | Text | 显示文字 |
| Button | Button / OutlinedButton / TextButton | Material 风格按钮 |
| EditText | TextField / OutlinedTextField | 输入框 |
| RecyclerView | LazyColumn / LazyRow | 列表/横向列表 |
| ImageView | Image / AsyncImage(coil) | 图片显示 |
| ProgressBar | CircularProgressIndicator / LinearProgressIndicator | 进度条 |
| ScrollView | Column + verticalScroll | 滚动容器 |
这张表不能 100% 一对一,因为有些控件的使用逻辑完全不同(比如 LazyColumn 是懒加载的,性能模型比 RecyclerView 更直观),但它能帮你快速建立"这个东西在 Compose 里大概怎么找"的直觉。
3.3 一个列表页面的迁移全过程
纸上谈兵没用,拿一个最常见的列表页走一遍。
假设老项目里有一个 "用户列表页",用 RecyclerView + Adapter,数据结构是List<User>。XML 方案里,你需要:写 item XML、写 Adapter、写 ViewHolder、写 DiffUtil、在 Fragment 里初始化布局管理器、设置 adapter、观察数据并更新列表。
Compose 里,整个页面浓缩成这样:
@Composable fun UserListScreen(viewModel: UserViewModel = viewModel()) { val users by viewModel.users.collectAsState() if (users.isEmpty()) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } return } LazyColumn { items(items = users, key = { it.id }) { user -> UserRow(user = user) } } } @Composable fun UserRow(user: User) { Row( modifier = Modifier .fillMaxWidth() .clickable { /* 点击事件 */ } .padding(16.dp), verticalAlignment = Alignment.CenterVertically ) { AsyncImage( model = user.avatar, contentDescription = null, modifier = Modifier.size(48.dp).clip(CircleShape) ) Spacer(Modifier.width(12.dp)) Column { Text(text = user.name, style = MaterialTheme.typography.titleMedium) Spacer(Modifier.height(4.dp)) Text(text = user.email, style = MaterialTheme.typography.bodySmall) } } }你会注意到几件事:
- 没有 Adapter 和 ViewHolder:列表项就是一个普通 Composable 函数,减少了大量样板代码。
- 状态改变自动刷新:
users变为空时,UI 自动从列表切到 Loading;数据回来时自动渲染,中间没有任何手动刷新操作。 key参数:相当于 RecyclerView 的setHasStableIds,告诉 Compose"哪些 item 是可以复用的",排列、动画时减少不必要的重组。
这段代码放到老项目里,只需要给 RecyclerView 那个位置补一个 ComposeView 容器,然后在这个容器里写setContent { UserListScreen() },页面就迁移完成了。业务层、ViewModel、网络层完全不动。
4. 迁移路上我踩过的坑
4.1 重组乱跳:状态到底该放在哪
Compose 最核心的坑几乎都集中在"状态管理"上。刚开始写时,我习惯把一堆状态都塞在 Composable 函数体里,结果发现随手点一下按钮,整页都在重组,列表还轻微闪烁。
后来才理解 Compose 的稳定性优化逻辑:状态一定要尽量下沉,能本地就不要全局。如果一个状态只在某个小组件内部使用,就放在那个组件里;如果多个组件要共享,再提升到父级。
另外一个高频坑是remember与rememberSaveable的误用。remember只在当前组合中保存,旋转屏幕后丢失;rememberSaveable会进 Bundle 自动恢复,用来存可序列化的简单值。千万别拿remember存大对象(比如整个 ViewModel 级别的数据),那是拿瑞士军刀当砖头使。
我还遇到过一种情况:列表项里有输入框,本来只改一个 item 的文本,结果整个 LazyColumn 全重排了。排查后发现是列表项写得太"粗糙",item 的内容没有拆成独立的@Composable函数,导致参数无法精细化比较。拆完之后问题立刻消失。
4.2 和老代码共存时的兼容性问题
增量迁移听上去很美,实际操作时会遇到不少"跨界"问题:
- 在 Compose 里嵌 WebView:要用
AndroidView包裹,而且必须小心生命周期——Compose 的组合销毁不等同于 View 销毁,WebView 的资源释放要看DisposableEffect的 onDispose 逻辑。 - 在 XML 里嵌 ComposeView:这个比较稳,但要注意如果 ComposeView 在 ViewPager 里使用,滑动离开页面时 Compose 的组合可能不会自动销毁,需要手动判断可见性。
- 主题不一致:老项目用的还是 AppCompat 主题,Compose 的 MaterialTheme 是另一套体系,两者切换时会闪烁。我当时的做法是:在 Compose 内容的容器布局里放一个不透明的背景色,遮住底层泄漏出来的样式差异。
- 字体、图标、颜色资源:
R.color.xxx在 Compose 里不能直接用,要转成Color(0xFFFF0000)或者用colorResource(R.color.xxx)包装。这是一个看起来小、做起来烦的体力活。
4.3 稳定性与 lint 的坑
这里必须提一个叫stability的概念。Compose 编译器会分析你传给 Composable 函数的参数类型:如果是一个稳定的类(基本类型、data class且字段都是 val),编译器可以跳过重组;如果是不稳定类型(比如List接口),它就可能保守地把整个子树重跑。
刚开始我的列表页写的是:
@Composable fun UserList(users: List<User>) { // List 接口不稳定,容易全量重跑 LazyColumn { ... } }改成下面这样就会好很多:
@Composable fun UserList(users: List<User>) { LazyColumn(state = listState) { // 关键是要保证传入的列表尽量稳定 items(users, key = { it.id }) { user -> // ... } } }Compose 的 lint 规则里有一个@Stable/@Immutable注解,可以告诉编译器"这个类型你放心用"。但别乱加,如果你把一个可变类型标成@Immutable,后果是缓存失效甚至 UI 错乱。我自己是能不加就不加,让编译器自己判断,遇到确实有性能瓶颈的地方再回头审视。
5. 动手之前,先想清楚这几件事
5.1 新项目 VS 老项目,决策模型不一样
如果你在开新项目,我的建议直接:用 Compose,别犹豫。为什么?因为新项目没有历史包袱,团队可以一次性建立统一的声明式代码风格,从第一行代码就走对路。
老项目要分情况。如果是一个维护了五六年的庞大 App,数以千计的 XML 文件,那"全部推翻重写"基本是灾难性的。别冒进,走"新页面用 Compose、老页面维持 XML、共用 ViewModel"的渐进式迁移路线,每一批改动控制在小范围,出问题能快速回滚。
但如果你的老项目刚好到了大版本迭代、核心页面重构阶段,那就是迁移的最佳时机。借着重构的窗口把几个高频页面迁到 Compose,后面新需求开发速度会明显提升。
5.2 团队学习成本与工具链成熟度
很多团队最担心的不是技术行不行,而是人。Compose 的学习曲线陡不陡?说实话,如果你熟悉 Kotlin 语言特性和函数式编程,一周就能上手;如果只会 Java 时代的 Android 开发,可能要两到三周才能"不用翻文档写 Compose"。
工具链方面,现在的 Android Studio 对 Compose 的支持已经比两年前成熟太多:
- Compose Preview:直接在 IDE 里预览 Composable 函数,还能通过
@Preview注解模拟不同参数、深色模式、字体大小。这点是 XML 时代最大的效率提升。 - Live Edit:改代码实时刷新到模拟器,不用重新编译,配合模拟器用真是爽到飞起。
- Layout Inspector:查看 Compose 的 UI 树结构、定位重组范围,排查性能问题比 XML 时代更直观。
5.3 混合过渡方案的经验
最后讲讲我这一年实际项目的混合架构经验,分为三个阶段:
- 阶段一:新建页面全部用 Compose。团队里所有人先学基础,把登录页、设置页这种低风险页面拿来做练兵场。
- 阶段二:老页面"点状迁移"。选用户访问频率最高的 2-3 个页面,用 ComposeView 重写列表区域,保留外层 Fragment 壳。逻辑层不动,只换 UI 层。
- 阶段三:抽取公共组件。把头像、按钮、弹窗、空态等最常用的 UI 抽成项目内统一的 Composable 组件,形成团队内部的组件库,后面所有新页面直接调用。
这个路线最大的优势就是每走一步都是可交付的,不会出现"迁移了三个月什么都用不了"的尴尬局面。
我在实际执行中还发现一个小技巧:混合阶段把 XML 页面和 Compose 页面放在同一个 Navigation 组件里管理,用 Navigation Compose 的composable("xxx")注册新页面,老的 Fragment 用fragment("xxx")注册,两个可以共存于一张导航图中。这样用户跳转无感,代码层面的边界也很清晰。
写在最后
折腾了一年 Compose,我最深的体会是:XML 不是不能写,但写 XML 的思路已经跟不上现在产品迭代的节奏了。动态主题、多端适配、频繁改 UI,这些都是声明式模型的舒适区。而 Compose 真正可怕的地方在于,它把"界面状态"变成了一等公民,你考虑的不再是视图怎么摆放,而是数据怎么流转——这个思维转换,比学会几行 API 重要得多。
最后再分享一个我个人的习惯:每写一个新的 Composable 之前,先问自己"这个界面的状态有哪些、它们怎么变化",状态想清楚了,代码基本不会错。如果你想从 XML 老项目开始尝试 Compose,不必有大动作,挑一个最简单的设置页,用 Compose 重写一次,跑通了,你自然就明白为什么说 XML 养老时代正在落幕。