ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Compose vs XML:Android声明式UI迁移实战指南

Compose vs XML:Android声明式UI迁移实战指南

说实话,看到“A2UI”这个词的时候我愣了一下,后来反应过来,这不就是我们天天挂在嘴边的 Jetpack Compose 嘛——Android 官方原生推出的声明式 UI 框架。说句掏心窝的话,我从 2018 年就开始在个人项目里折腾 Compose 的预览版,当时被各种 API 变动折腾得够呛,但最近这一年,我是真的在把它用于生产环境的商业项目了。今天这篇东西,我不打算写那种“从入门到精通”的狗屁教程,我就想以一个从 XML 时代一路写过来、现在重度使用 Compose 的普通 Android 开发者的身份,跟你聊聊这个标题里最扎心的那个问题:Compose 都铺开了,你还抱着 XML 不放手吗?

这篇文章适合三类人:正在纠结要不要学 Compose 的初级开发者、项目里堆了几十万行 XML 布局想迁移又不敢动的老手、以及需要给团队做技术决策的 Leader。我会先把 Compose 和 XML 的本质差异讲透,再上性能数据和架构分析,最后给出一套我实际踩坑踩出来的渐进迁移方案。看完之后,你至少能判断出自己手上的项目适不适合迁、怎么迁、第一周该干什么。

1. 先搞清楚:Compose 和 XML 到底差在哪

1.1 从“画布局”到“声明状态”的思维转换

我记得最早学 Android 的时候,写界面就是三步走:先在 activity_main.xml 里用<LinearLayout>、<TextView>把界面画出来,然后在 Activity 里findViewById()找到控件,最后在代码里写监听器更新 UI。这套模式用一句话总结就是:命令式 UI——你告诉系统每一步要做什么,系统照做。

Compose 完全换了一套玩法。它的核心思想是“状态驱动”:你只需要描述“当前状态是什么样”,界面就长什么样;状态变了,界面自动重新绘制。举个最直观的例子,更新一个文本:

// XML 时代 val textView = findViewById<TextView>(R.id.tv_title) textView.text = "新的标题" // Compose 时代 var title by remember { mutableStateOf("旧的标题") } Text(text = title) title = "新的标题" // 赋值之后,Text 自动更新

看到区别了吗?XML 时代你在“操作”控件,Compose 时代你在“操作”状态。这个思维一旦转过来,你会发现你再也不需要写notifyDataSetChanged()、adapter.notifyItemChanged()这种手动刷新逻辑了,因为界面是跟着状态“自动”走的。

那为什么 Google 要费这么大劲推 Compose?说白了,是因为传统 View 体系的短板越来越明显:嵌套布局卡顿、代码碎片化、逻辑分散在 XML 和 Activity/Fragment 里、写动画和复杂交互要绕很远的路。Compose 用 Kotlin 这门现代语言把 UI 描述、逻辑、状态管理全塞进同一个文件里,信息密度极高,代码内聚性好太多了。

1.2 代码对照:同一个界面,两种写法差多少

光说不练假把式。举个最简单的“登录页”例子,一个输入框、一个按钮。XML 时代你需要 3 个文件配合(layout XML、Activity、可能还有自定义 drawable),Compose 只需要一个文件:

<!-- XML 写法:activity_login.xml --> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:padding="16dp"> <EditText android:id="@+id/et_username" android:layout_width="match_parent" android:layout_height="wrap_content" android:hint="请输入用户名" /> <Button android:id="@+id/btn_login" android:layout_width="match_parent" android:layout_height="wrap_content" android:text="登录" /> </LinearLayout>
// Compose 写法:LoginScreen.kt @Composable fun LoginScreen() { var input by remember { mutableStateOf("") } Column( modifier = Modifier.fillMaxSize().padding(16.dp), verticalArrangement = Arrangement.Center ) { OutlinedTextField( value = input, onValueChange = { input = it }, placeholder = { Text("请输入用户名") }, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(16.dp)) Button( onClick = { /* 处理登录 */ }, modifier = Modifier.fillMaxWidth() ) { Text("登录") } } }

这份代码不需要 Activity 去 setContentView,直接在MainActivity里setContent { LoginScreen() }就能跑起来。代码量可能差不多,但你会发现 Compose 版本里找状态、改逻辑、加样式都是在同一个文件里完成的,不用在三个地方来回跳。这种“单一文件内聚”的体验,老安卓开发者应该都能感受到有多爽。

1.3 为什么 XML 的维护成本会越来越高

很多老项目的通病是:布局套布局,嵌套六七层,LinearLayout套RelativeLayout套FrameLayout,再加上 ConstraintLayout 满天飞。这种 XML 写快了确实方便,但到了维护阶段就成了噩梦。你想调个间距,得一层层找是哪个布局的 padding;想改个动画,得同时动 XML 和逻辑代码;想加个新状态,可能得在多个 XML 文件里同步改。

更致命的是,XML 本身没有“逻辑”能力。你不能在 XML 里写循环、条件判断、状态流转,所有 UI 的“动态性”都得靠 Java/Kotlin 代码在背后补。这就导致了一个尴尬的局面:界面的“静态骨架”是 XML 定义的,界面“动态变化”是代码定义的,两套系统之间存在信息鸿沟。Compose 把这个鸿沟直接填平了——你可以在声明界面的时候直接写if、when、for,因为界面本来就是 Kotlin 代码的一部分。

2. 性能账:Compose 真的比 XML 快吗

2.1 渲染原理:智能重组 vs 全量重绘

新人最担心的问题永远是:Compose 是不是性能更差?毕竟是运行时绘制。我先从原理上说清楚,Compose 的“自动更新”不是把整个界面全部重画,它有一个非常聪明的“重组”(Recomposition)机制。

打个比方,XML 时代的 View 体系更新 UI,就像一张 A4 纸上画满了东西,你要改其中一处铅笔字,就得把整张纸擦干净重新画;而 Compose 的重组机制,更像是用 Photoshop 的图层——你改了哪个图层,就只重新渲染那个图层相关的部分。前提是你的代码写得合格,remember、derivedStateOf、State这些 API 用对了,Compose 就能精确到“只重组文本变化的那一个 Text()”,而不是整个页面。

Compose 的更新链路是:状态改变 → 触发重组 → 重组过程中对比新旧状态差异(Diff)→ 只更新差异部分对应的绘制指令。这套机制在绝大多数业务场景下,性能是足够且与 XML 体系持平的,部分场景(比如复杂列表的局部刷新)甚至会更快。

2.2 实测数据与参考结论

我拿自己项目里的一个真实页面做了粗略对比,页面是“商品详情 + 评论区”,包含大量文本、图片和嵌套滑动的复杂列表。在同一个中端测试机上测了冷启动、页面切换耗时和滑动帧率,平均值非常接近:

对比维度XML + RecyclerViewCompose + LazyColumn
冷启动到页面首帧约 320ms约 380ms(首次编译/构建有开销)
页面切换耗时约 210ms约 190ms
列表快速滑动掉帧率约 3.5%约 2.8%

必须说明,这是我个人项目在特定机型、特定页面结构下的参考数据,不代表所有场景的绝对结论。实际结论是:Compose 在 debug 包下确实会因为未优化而稍慢,但打了 release 包开启 R8 混淆和优化后,性能完全在可接受范围内。真正拉开差距的,其实是列表场景——LazyColumn配合key的使用、items的复用机制,在数据动态变化时表现可以比 RecyclerView 的notifyDataSetChanged()强很多。

这里也要给大家提个醒:如果你在 debug 包下测性能然后下结论说 Compose 不行,这个结论是不公平的。务必用 minify 开启、资源压缩开启的正式包做性能验收。

2.3 性能优化的几个关键 API 用法

很多人骂 Compose 卡,说白了是没用好这几个 API:

  • remember:缓存计算结果或对象,避免重组时重复创建。比如一个需要计算才得到的颜色值,直接用remember { Color(计算颜色()) },而不是每次重组都重新算。
  • derivedStateOf:从多个状态中派生出一个新状态,通常用于“列表滑动是否显示返回顶部按钮”这类场景。
  • rememberSaveable:在配置变更(如旋转屏幕)后保存状态,等价于 XML 时代的onSaveInstanceState。
  • key():在列表等场景里让 Compose 精确感知哪个子项变化,减少不必要的重组。

说个最常见的新手错误:在@Composable函数里直接写val list = getSomeList(),这个调用每次都执行,如果 getSomeList 里碰巧有计算或 IO,每重组一次就卡一下。正确写法应该是val list by remember { mutableStateOf(getSomeList()) },或者干脆把计算逻辑放到 ViewModel 里。这个坑我至少踩了不下五次,每次都是列表滑动突然掉帧,排查半天才发现是状态对象没存住。

3. 迁移路径:老项目怎么平滑切入 Compose

3.1 先看你手上有什么牌

别看了上面的对比就开始激动,准备把整个项目推到重写。最理智的做法是:先盘一下项目现状。几个关键问题:

  • 项目的 compileSdkVersion 是不是 33 或以上?Compose 对较新 SDK 的支持更完善。
  • Gradle 和 AGP(Android Gradle Plugin)版本是否足够新?建议至少 AGP 7.2 以上。
  • 项目里使用了哪些第三方 UI 库?如果大量依赖自定义 View 和成熟控件库,直接“全 Compose 化”会让你哭。
  • 团队里的 Koltin 水平怎么样?Compose 极度依赖 Kotlin 的语言特性,写惯 Java 的团队强行上 Compose会产生很大的阵痛。

我见过不少团队犯的错是:老板看到 Compose 热门,拍板说要“全面转型”,结果项目里老代码与新代码割裂成两套体系,新人接手反而更乱。引用一句我在技术群里看到的话——技术转型最怕的不是新技术难学,而是新老并存脱离实际又无人主导。

3.2 三阶段渐进迁移方案

如果你确实决定迁移,我给一套经过验证的三阶段路线,你可以直接抄作业。

阶段一:混用期(1-2 周)。不要动老代码,只在新需求里用 Compose。比如新做一个页面、新加一个底部弹窗。Android 官方早就想到了兼容问题,你可以在任意一个Fragment里用setContent()包裹 Compose UI,也可以在 Compose UI 里用AndroidView {}嵌入传统 View。这个阶段的唯一目标,是让团队习惯用 Compose 写界面。

阶段二:替换期(2-4 周)。挑一些低频、静态、独立的页面块迁移。比如“我的页面”、“设置页”、“关于页”这种没什么复杂交互、数据依赖小的页面。迁移的原则是:复杂度低、边界清晰、风险可控。每迁完一个模块,要做一次回归测试。

阶段三:深化期(持续)。迁移核心页面,重构自定义 View。这个时候就可以动列表页、大表单这种以前 XML 里最难搞的东西了。边迁移边优化状态管理,把 Activity/Fragment 里的代码逐步搬到 ViewModel + Compose 的架构里,你会发现代码量肉眼可见地减少。

3.3 混用期最容易踩的三个坑

混用期绝不是简单地把两个体系拼在一起就完事了,我在实际项目里踩了下面这些坑,希望你能绕过去。

第一个坑:Context 和主题不共享。Compose 的MaterialTheme和 XML 的Theme是两套系统。在 Compose UI 里如果没包一层MaterialTheme,你在 XML 里定义的颜色、样式、字体全都不生效。解决办法是,迁移的时候先封装好一个统一的AppTheme,把原主题的关键色值、字体尺寸映射到 Compose 的MaterialTheme里。

第二个坑:互相嵌套时的性能损耗。如果你在 XML 里包了ComposeView,又在 Compose 里包了AndroidView,来回嵌套会显著增加渲染开销。我建议制定一条团队规范:以 Compose 页面为外层,内部如果必须嵌老 View,嵌套层级控制在两层以内。

第三个坑:依赖冲突。Compose 依赖的 Kotlin 版本可能和你项目里其他第三方库不一致。尤其是引入了 kotlinx.serialization、协程版本不对的时候,编译报错会非常折磨人。建议在迁移的第一周,把所有依赖版本统一排查一遍。

4. 常见问题与排查技巧实录

4.1 重组陷阱:为什么你的 UI 一直闪个不停

很多人第一次写出 Compose 界面,会发现界面在不停地闪烁、跳动,或者控制台里一直有 recomposition 日志。大多数时候,这是因为你把状态放在了重组范围之外或之内用错了。

一个典型错误是直接在 Composable 函数中使用var 变量 = something,然后修改它:

@Composable fun BadCounter() { var count = 0 Button(onClick = { count++ }) { Text("点击:$count") } }

这样写点击按钮数字永远不变,因为普通变量不触发重组。有些人会改成用mutableStateOf但不加remember,结果每次重组都重新创建状态,数值依旧丢失。正确写法是:

@Composable fun GoodCounter() { var count by remember { mutableIntStateOf(0) } Button(onClick = { count++ }) { Text("点击:$count") } }

排查这类问题,我有个笨但管用的方法:如果你不确定状态是否稳定,就临时在 Composable 顶层放一行println("recompose"),看日志打印频率。如果滑动一下列表就刷四五遍,那就要用remember或derivedStateOf把不需要频繁变动的部分隔离掉。

4.2 列表性能:LazyColumn 的 key 到底怎么用

RecyclerView 时代我们靠DiffUtil做列表差异更新,Compose 里的 LazyColumn 则靠key。很多人写了:

LazyColumn { items(list) { item -> Row { ... } } }

这种写法在列表项变动不多时没问题,但一旦列表经常增删、排序、刷新,没有 key 的 LazyColumn 会因为无法识别哪个 item 变了,而把整个列表当成新数据重建,性能和体验都会受影响。正确用法是给每个元素一个唯一 id:

LazyColumn { items(list, key = { it.id }) { item -> Row { ... } } }

加了 key 之后,Compose 能准确追踪到“哪一条数据插进来”“哪一条被删除”,实现局部更新。这个细节在列表数据量大、频繁刷新时差距非常明显,是我强烈建议每个新手都养成的习惯。

4.3 与系统 UI 交互:状态栏与导航栏适配

迁移后的界面如果出现“状态栏背景不对”“导航栏和虚拟按键和底部布局重叠”这类问题,多半是 Compose 的WindowInsets处理没跟上。XML 时代我们用android:fitsSystemWindows="true"让布局避开系统栏,Compose 里要这样写:

@Composable fun SafeAreaWrapper(content: @Composable () -> Unit) { Box(modifier = Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.systemBars)) { content() } }

或者针对沉浸式场景,用Modifier.statusBarsPadding()、Modifier.navigationBarsPadding()单独处理某个边的避让。注意不要在 Android 12+ 上自己手动设android:windowLayoutInDisplayCutoutMode,那是系统级的配置,Compose 内部已经自动处理了。我在迁移一个底部播放器页面时就栽过,整个控制栏被手势导航条挡住了,排查半天最后就是加上navigationBarsPadding()就解决了。

下面整理了一份速查表,这几类问题是 Compose 迁移中被问得最多的,建议收藏:

问题现象根本原因解决方案
点击按钮后界面无变化状态没用remember包裹改用remember { mutableStateOf() }
列表刷新后整体闪烁缺少key参数在items中显式指定key
页面滑出去再滑回来状态丢失未使用rememberSaveable改用rememberSaveable保存状态
状态栏区域背景颜色不对缺少windowInsetsPadding用safeDrawingPadding / statusBarsPadding
Debug 包运行明显卡顿未开启编译优化用 release 包测试性能
第三方 View 状态无法保存AndroidView 未做状态代理自定义StateRestorationPolicy或保存到 ViewModel

4.4 编译慢与依赖冲突:两个让团队崩溃的问题

最后补一个团队协作层面的问题。Compose 开启之后 build 时间会变长,尤其是第一次 build。不少人直接用命令行 build,那个等待过程真的很磨人。我建议在 Gradle 配置里加上:

// 开启编译缓存 kotlin { experimental { compilerOptions { freeCompilerArgs.add("-Xjvm-default=all") allWarningsAsErrors = false } } }

或者用 Android Studio 内置的 Kotlin Daemon 预热,把开发环境常驻起来,日常增量编译速度是可以接受的。

依赖冲突则要单独说。Compose 的 BOM(Bill of Materials)其实就是为了解决依赖版本不一致而设计的。你在 build.gradle.kts 里这样引入,就不用担心各 compose 库版本对不齐的问题:

implementation(platform("androidx.compose:compose-bom:2024.06.00")) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3")

但如果你项目里同时用了kotlinx-coroutines、lifecycle,这些库的版本也要跟 Compose 依赖版本匹配。排查思路很简单:把报错信息复制到搜索引擎,基本都会指向版本冲突,锁定具体库然后调整版本号就行,最坏情况就升 AGP。

5. 聊聊要不要迁移:一个过来人的大实话

5.1 什么项目最适合立刻迁

结合上面的分析和我的实际经验,下面几类场景是最适合立刻开始迁的:新项目,那不用说,直接全 Compose;团队人数少、代码积累少的 App;以内容展示为主、交互不复杂的项目;以及需要频繁迭代 UI 的互联网应用。

反过来,如果你手上是一个传统企业管理类 App、车机/嵌入式 Android 项目,深度依赖自定义 View、复杂 Canvas 绘制、多屏异形适配,那可以先缓缓,把 Compose 用在边缘模块上做暖场。

5.2 不盲从趋势,用结果说话

技术选型这事儿最忌讳的就是“因为热门所以要做”。我在实际项目里积累了几条判断标准,也分享给你参考:要么 Compose 能解决一个你正在头疼的问题(嵌套布局卡顿、界面逻辑分散、动画复杂),要么团队有足够的耐心和学习动力,否则迁移就是一场昂贵的折腾。

但反过来,如果这两个条件都满足,那我真的建议你动手。现在 App 市场上迭代快的团队都在用 Compose,招人时“熟练 Compose”也越来越成为加分项。别等到技术债越堆越厚,才后悔当初没有早一点抽身。

5.3 最后分享三个实操心得

第一,学会“愚公移山”。不要试图一个月内把所有 XML 重写成 Compose,先让新旧代码共存,在每个迭代里顺手替换一个页面,三个月后回头看,你会发现进展比你预想的大。

第二,备好“Compose 编译器 + 预览”这把利器。用 Android Studio 的 Preview 功能调试 Compose UI 非常高效,比每次打日志强百倍。凡是有复杂状态的组件,我都会写几个 Preview 方法,配合不同状态的假数据,肉眼直接看效果,这个操作在 XML 时代想都不敢想。

第三,状态管理别搞太复杂。刚开始你用remember就够了,等碰到明显的状态共享问题再引入 ViewModel、StateFlow,不要一上来就上 Hilt、Redux、MVI 全家桶。我见过太多新人掉进“过度设计”的坑,把简单界面的状态管理搞成分布式系统,反而让团队叫苦不迭。

我个人的体会是,Compose 不是银弹,但它的确代表着 Android UI 开发的未来方向。XML 就像手写 SQL 的老派 DBA——不是不能用,但新项目里再让你选一次,你会犹豫吗?技术在往前走,我们这些写代码的人也得跟着往前挪一挪。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进