首页 / 资讯中心 / 文章详情

Jetpack Compose 与 XML:Android UI 开发的演进与迁移实践

Jetpack Compose 与 XML:Android UI 开发的演进与迁移实践 ★ FEATURED ARTICLE
说实话前两天在技术群里看到“Android 原生的 Compose A2UI 也来了你还抱着 XML 养老吗”这个标题我第一反应是A2UI 是啥后来琢磨了一下这名字倒也没毛病。A2UI 不是官方名词但你可以把它理解成“Android 的第二套 UI 方案”——A 就是 Android2 就是第二UI 就是用户界面。把它读成“A to UI”也通顺你只要把状态交给界面界面就自动跟着状态更新不用像以前那样手动去控制每一个 View。Jetpack Compose 就是这样一套由 Android 官方主导的声明式 UI 框架这几年版本迭代越来越快基本已经从“新技术尝鲜”变成了“新项目默认”。不过我也理解依然抱着 XML 不放手的队伍。老项目里几百个 layout 文件团队习惯 findViewById模板代码写了一堆忽然让全组转 Kotlin 函数写界面压力确实不小。这篇不是劝你明天就把老项目推倒重写而是用比较实际的方式拆一下Compose 和 XML 到底差在哪、一个典型页面怎么写、老项目怎么平稳过渡、以及大家在折腾过程中搜到的高频问题到底对应什么场景。无论你是刚入门的 Android 初学者还是被 XML 折腾多年的老开发看完应该都能拿到一份可落地的判断。1. 为什么 XML 会被“退休”从填表格到写公式1.1 XML 布局真正痛的不是性能是状态同步XML 本身是 Android 布局文件的“老掌门”它把界面结构用树状标签描述出来LinearLayout 包着 TextViewRecyclerView 加上适配器再绑定数据。这套东西本身没有语法问题很多人吐槽它性能差但说实话XML 渲染并不慢慢的是你作为开发者去维持它的状态同步。你想一想一个列表界面如果要更新某个 Item 的文本传统写法是先在 layout 文件里给 TextView 加个 id再在代码里 findViewById 拿到这个 View然后 setText如果数据来自多个接口还得记住哪个 View 对应哪个数据字段稍微一复杂页面状态很容易和实际显示的 View 不一致。比如网络请求失败本该显示一个错误提示但因为你忘了在回调里调用 tvError.visibility VISIBLE用户就看到一个空屏。这就是 XML 给的约束界面是静态的你只能通过外部命令一次次去“驱动”它。我习惯用一个类比说明XML 是填写纸质表格填一份就要复印给各个部门后续数据变了还得重新填一遍复印件Compose 更像是 Excel 公式你改了单元格的数据依赖它的格子自动重算。后者的工作方式离“状态驱动界面”更近。另外再提一点Android 传统 View 树的粒度和深度问题。复杂的界面嵌套多层 LinearLayout会增加测量和布局的成本。虽然 ConstraintLayout 缓解了很多但视图树的本质没变所有节点都受全局绘制逻辑影响。在 Compose 里状态改变后只有依赖该状态的组合函数会重新执行不做重活的节点直接跳过这让“点击一下只更新 Text”成为默认行为而不是靠开发者手动优化的一个老技巧。1.2 声明式 UI 的底层逻辑你给状态它给界面Compose 最核心的思想是声明式DeclarativeUI。你不是告诉系统“把这个 TextView 的文本改成 xxx”而是告诉系统“界面长这样”系统自己去处理差异。一个最简单的例子Composable fun Greeting(name: String) { Text(text 你好$name) }这个函数看上去只是普通 Kotlin 函数但它加上 Composable 注解后就成为一个组合函数。组合函数的特点是可以读取状态、根据状态生成界面并且在状态变化时自动重新执行重组。你不需要手动调用 greeting.setText(...)只需要保证传给它的 name 是正确的状态变量。比如这样Composable fun App() { var userName by remember { mutableStateOf(小明) } Greeting(name userName) }当 userName 变化时Compose 运行时会在底层找到读取了 userName 的界面范围只重新执行那一小部分没有读取该变量的组件不会被无端重建。这个机制叫重组Recomposition由 Kotlin 编译器插件和 Compose 运行时配合完成。理解这个逻辑是入门的第一个门槛。很多从 XML 转过来的同学一开始总忍不住在组合函数里写“手动刷新 UI”的代码比如把数据塞到某个全局 View 对象里再找时机调用。其实大可不必Compose 的思路是状态变了界面自己变。你只要保证状态来源正确剩下的事交给运行时。这里把两套体系做个直观对比对比项XML View 系统Compose界面描述XML 布局文件 Kotlin/Java 代码Kotlin 组合函数状态更新findViewById setText 手动刷新状态变化自动触发重组列表RecyclerView Adapter ViewHolderLazyColumn items主题XML 资源里的系统 ThemeMaterialTheme ColorScheme测试Espresso 控件测试Compose UI 语义测试2. 搭建 Compose 项目的工程准备从 Android Studio 到 Gradle 依赖2.1 先把 Android Studio 环境弄明白既然要写 Compose开发环境总得先端平。Android Studio 现在已经迭代到 Ladybug、Meerkat 这些版本界面和插件都越来越顺手。下载直接从 Android 开发者官网下注意别去乱七八糟的下载站如果你在搜索引擎里看到“android studio 怎么设置中文”这类问题多半是安装完成后觉得英文界面不适应。中文化其实很简单Settings设置→ Plugins插件→ 搜索“Chinese (Simplified) Language Pack”安装后重启 Studio 就是中文界面。当然我个人更建议别急着装中文包因为很多报错、文档和社区帖子都用英文术语保留英文界面反而能少一层翻译损耗。但如果你刚入门装中文包确实能降低一部分心理压力。接着说一下 SDK。Android Studio 安装时会提示下载 Android SDK正常情况下它会自动带上 platform-tools、build-tools 和对应 platform。如果下载慢或者版本不齐建议打开 SDK Manager勾上当前项目要求的 compileSdk 版本。很多“编译报错找不到 android sdk”的问题本质就是 platform 版本没装全。还有一点容易忽略Compose 项目要求最低支持版本最好在 API 21 以上老项目如果 minSdk 太低就不是工具问题而是兼容性取舍了。2.2 Gradle 配置和依赖版本选择新建一个 Compose 项目最简单的方式是用 Android Studio 的模板New Project → Empty Activity语言选 Kotlin。把官方模板跑起来之后你会发现 MainActivity 里已经有 setContent 的代码。这个模板帮你省掉了配置依赖的麻烦。不过我还是建议你把配置弄明白因为依赖版本选错是新手进阶最常见的坑。在 Kotlin 2.0 之前Compose 编译器需要单独声明 composeOptions 去指定编译器版本Kotlin 版本一换就容易出现“Compose compiler 与 Kotlin 版本不匹配”的诡异错误。Kotlin 2.0 之后Compose 编译器插件和 Kotlin 版本统一不再需要单独维护版本号配置清爽很多。一个典型的新项目 Gradle 配置是plugins { id(com.android.application) id(org.jetbrains.kotlin.android) id(org.jetbrains.kotlin.plugin.compose) } android { namespace com.example.demo compileSdk 35 defaultConfig { applicationId com.example.demo minSdk 24 targetSdk 35 versionCode 1 versionName 1.0 } } dependencies { implementation(platform(androidx.compose:compose-bom:2024.12.01)) implementation(androidx.compose.ui:ui) implementation(androidx.compose.material3:material3) implementation(androidx.activity:activity-compose:1.9.3) }BOMBill of Materials的作用是统一 Compose 相关库的版本。你只要指定一个 BOM 版本下面所有 Compose 库的版本都会对齐省得一个个查。初学者最喜欢问“依赖版本怎么写”答案就是用 BOM少折腾。这里还要提醒一句很多朋友搜“docker compose 安装”会来到 Android Compose 的教程页面然后一脸懵。这两个 Compose 根本不是一回事。Docker Compose 是容器编排工具负责定义和运行多个容器服务Android Compose 是界面开发框架负责写 UI。如果你遇到的问题和容器、服务编排有关去搜 Docker Compose如果你看到的是 Composable、setContent那才是 Android 这个。2.3 模拟器和真机准备的细节开发 Compose 不一定要模拟器但我体验下来还是建议一手 AVDAndroid Virtual Device一手真机。AVD 在 Android Studio 里创建SDK Manager 里先把 System Image比如 Android 35 的 Google APIs 版下载好然后 Device Manager 新建一个虚拟设备。模拟器的好处是启动快、可以随便造各种屏幕尺寸适合调试布局响应性。真机调试要开启开发者选项里的 USB 调试插线后在 Studio 顶部选择设备。真机的好处是定位底层性能问题更真实特别适合跑列表滚动、动画这些场景。我的习惯是开发阶段用模拟器到了列表性能、传感器、蓝牙等硬件相关功能时再用真机。如果你搜过“android 蓝牙”“dlna 接收端 android”这类涉及系统服务和硬件能力的开发模拟器基本白搭直接上真机测试就好。3. 核心实操状态、重组和 Modifier 的正确打开方式3.1 用 remember 和 mutableStateOf 让界面“活”过来写第一个交互式 Compose 页面最经典的是计数器Composable fun Counter() { var count by remember { mutableStateOf(0) } Button(onClick { count }) { Text(text 点击了 $count 次) } }拆开看这三个部件mutableStateOf(0)创建一个可观察的状态容器初始值是 0。Compose 只要读到这个状态就会自动登记依赖关系。remember把状态缓存在组合composition对应的位置里。只要这个组合函数还在界面树上状态就会被保留一旦离开组合比如该界面被销毁状态就被回收。byKotlin 的属性委托语法把 var count 的读写委托给这个状态容器。你也可以写成 val state remember { mutableStateOf(0) }然后 count state.value 这样用但 by 更简洁。为什么不能用普通变量有人会想直接在组合函数里定义一个 var count 0点击时 count再用 Text 显示。这样做界面不会更新原因是 Compose 没有“观察”到普通变量。只有它观察到的状态对象改变时才会触发重组。这里有个真正的实操坑remember 虽然能用但它不是所有场景的万能钥匙。比如你把业务数据存在 remember 里用户旋转屏幕后 Activity 重建remember 可能会被清空——因为它绑定的是组合的生命周期不是 ViewModel 的生命周期。业务数据要放进 ViewModelremember 更适合保存纯 UI 状态比如对话框开关、输入框焦点、滚动位置。3.2 状态提升和 ViewModel别把组合函数写成大杂烩组合函数虽然灵活但有一个原则要尽量保持“无状态化”把状态提升到上层。所谓状态提升State Hoisting就是把可被多个组件共享或修改的状态放到更高的层级去管理。一个运行示例Composable fun CounterScreen(viewModel: CounterViewModel viewModel()) { val count by viewModel.count.collectAsStateWithLifecycle() Counter( count count, onIncrement { viewModel.increment() } ) } Composable fun Counter(count: Int, onIncrement: () - Unit) { Button(onClick onIncrement) { Text(text 点击了 $count 次) } }Counter 组件自己不管状态只接收一个 count 值和一个点击回调。好处很明显第一组件可复用换个数据源还能用第二状态来源清晰调试时不用到处找是谁改了全局变量第三测试容易传不同的参数就能验证不同界面。用 collectAsStateWithLifecycle() 而不是 collectAsState()是近年官方推荐的做法它能感知 Lifecycle在界面退到后台时停止收集避免白跑一遍。这个细节很多教程不会提但实际项目里对电量和性能影响不小。记得在依赖里加上 lifecycle-runtime-compose否则这个方法找不到。3.3 Modifier 的顺序问题踩过一次就忘不掉Modifier 是 Compose 里给组件加尺寸、间距、背景、点击、动画等能力的“装修队”。比如Composable fun CardButton() { Text( text 点我, modifier Modifier .fillMaxWidth() .padding(16.dp) .background(Color(0xFF1E88E5)) .clickable { doSomething() } ) }这个链式调用看起来简单但顺序不同效果可能完全不一样。举个例子padding 放在 background 前面和放在 background 后面点击区域、背景能否覆盖到 padding 区域都会变。多数情况下如果想把整块区域都变成可点击的应该把 clickable 放在链子末尾附近让背景和 padding 都包含进点击范围如果你把 clickable 放在 padding 前可能出现点击圈和视觉圈不一致的奇怪表现。我见过同事在项目里因为 Modifier 顺序搞错调了半天背景色死活不显示最后发现是 background 被 padding 挡在后面画出来的区域被裁掉了。处理这类问题的通用思路是先想清楚每一个 Modifier 要影响哪个范围再按从外到内的顺序写。Modifier 不是装饰性语法它是 Compose 里定义交互和布局约束的重要工具顺序错了行为就错了。4. 列表、进度条和网络状态用一页仿真实页面练手4.1 LazyColumn 替代 RecyclerView省的不只是代码XML 时代写一个列表并更新数据要做的事大概是写 layout 里 RecyclerView 节点、创建 LayoutManager、创建 Adapter、创建 ViewHolder、处理 item 布局、最后还得根据数据变化手动 notifyDataSetChanged。数据一多还要上 DiffUtil 来减少不必要的刷新。代码量不小团队里要是没有统一规范每个页面都能长出不同风格的 Adapter。Compose 里最接近 RecyclerView 的是 LazyColumn它同样只渲染可见区域内的 Item而不是把整个列表全部创建出来。典型写法val users listOf(...) LazyColumn { items( items users, key { it.id } ) { user - UserRow(user) } }key 参数非常重要它相当于给每个 item 一个稳定身份。列表数据变化时LazyColumn 可以通过 key 知道哪个 Item 保持不变、哪个新增、哪个删除从而减少不必要的重建。如果不给 key默认按索引匹配排序或插入数据后容易整段重画严重时还会出现动画闪烁和焦点丢失。如果列表顺序会变化建议用 itemsIndexed它在提供 item 的同时把 index 也给你。比如你需要根据奇偶索引给 item 加不同底色用它就很顺手。4.2 加载中、成功、失败三态怎么体面地展示进度条在 XML 里要用进度条你通常找 ProgressBar在 Compose 里对应的是 CircularProgressIndicator转圈和 LinearProgressIndicator横条。“android 进度条”这个搜索词在 Compose 时代依然有效只是类名变了。一个典型的三态页面when (state) { is Loading - { Box(modifier Modifier.fillMaxSize(), contentAlignment Alignment.Center) { CircularProgressIndicator() } } is Success - UserList(state.users) is Error - ErrorRetry(state.message) { viewModel.reload() } }为了比较规范地管理状态推荐定义一个密封接口sealed interface把加载中、成功、失败三种情况建模出来sealed interface LoadStateout T { data object Loading : LoadStateNothing data class SuccessT(val data: T) : LoadStateT data class Error(val error: Throwable) : LoadStateNothing }然后在 ViewModel 里维护一个 MutableStateFlowLoadStateList 网络请求开始前发 Loading成功后发 Success(data)捕获异常后发 Error(e)。界面只用 when 去匹配当前状态代码清晰也不会出现“loading 卡死”这种问题。4.3 网络请求别在组合函数里裸奔错误分支一定要接住我见过很多从 XML 刚转过来的人喜欢直接在 Composable 函数里写 Retrofit 回调然后试图更新某个局部变量。这样做的最大问题是组合函数会被多次执行每次重组都可能重复发请求而且直接在这里开协程生命周期管理会变得很脏。正确做法是把网络请求放到 ViewModel 中用 viewModelScope.launch 发起通过 Flow 把状态吐给 UI。一个简化的 ViewModel 写法class UserViewModel : ViewModel() { private val _state MutableStateFlowLoadStateListUser(LoadState.Loading) val state: StateFlowLoadStateListUser _state.asStateFlow() fun load() { viewModelScope.launch { _state.value LoadState.Loading try { val data api.getUsers() _state.value LoadState.Success(data) } catch (e: Exception) { _state.value LoadState.Error(e) } } } }这里有个网络排查的热门场景non-xml response from server. response code: 400, content-type: text/xml。这种报错看起来像是“XML 解析失败”其实绝大多数情况是后端返回了非预期的响应——客户端按 JSON 或特定格式解析服务器却返回了错误页或纯文本。排查思路分三步一是看接口 URL 和请求方法是不是对的二是直接用浏览器或 Postman 访问接口看真实返回体三是确认 Content-Type 是否和客户端解析器一致。很多老系统写的是 XML 解析逻辑后端却悄悄换成了 JSON接口一报错就会出现这种别扭信息。5. 旧项目迁移不要“一夜重写”先“边跑边换”5.1 Compose 中嵌 XMLXML 中嵌 Compose两种姿势都掌握老项目不可能为了 Compose 马上全部推倒重来官方也推荐渐进式迁移。实际项目里我用的组合方式是双方向的。一种是在 Compose 界面里嵌入传统 XML 布局用 AndroidView 包装Composable fun LegacyViewInCompose() { AndroidView( factory { context - LayoutInflater.from(context) .inflate(R.layout.legacy_panel, null, false) }, modifier Modifier.fillMaxWidth() ) }另一种是在 XML 布局里放入 Compose用 ComposeView 承载LinearLayout ... androidx.compose.ui.platform.ComposeView android:idid/compose_view android:layout_widthmatch_parent android:layout_heightwrap_content/ /LinearLayout然后在代码里给这个 ComposeView 设置内容val composeView findViewByIdComposeView(R.id.compose_view) composeView.setContent { MaterialTheme { NewUserPanel() } }这套“双栖”方案适合老页面里局部替换。比如一个详情页顶部复杂的内容还是保留 XML下面评论区新功能用 Compose 写两边接缝用 AndroidView 或 ComposeView 过渡风险控制得比较好。5.2 混合模式的坑主题、边到边和生命周期混合模式最常出的问题集中在三块。第一块是主题。XML 里的 AppCompat 主题和 Compose 里的 MaterialTheme 是两个体系直接混用会出现控件风格不一致、字体颜色对不上。解决方案是给 Compose 区域单独包裹一个 MaterialTheme并以一个共同的色板为基础。如果项目暂时不想引入 Material3尽量用 MaterialTheme(colorScheme ...) 先对齐品牌色。第二块是边到边Edge-to-Edge和键盘避让。Android 15 开始全面强制边到边XML 页面如果依赖旧的 adjustResize 行为切换到 Compose 页面后可能出现内容被导航栏遮挡。用 enableEdgeToEdge() 配合 WindowInsets 统一处理比在 XML 里写 android:fitsSystemWindows 更省心。第三块是生命周期。在 XML 页面中嵌 Compose 时如果直接使用 collectAsState()可能会在页面处于后台时继续收集 Flow。建议用 collectAsStateWithLifecycle() 加上 lifecycle-runtime-compose 依赖效果更稳定。我团队踩过一次坑一个列表页嵌了 Compose 以后退到后台再回来滚动位置丢了最后排查就是状态收集生命周期没接好导致组合状态被提前回收。6. 顺手查了一波热搜这些高频问题到底是怎么回事6.1 XML 文件打开、编辑和“没有标签”报错如果你搜过“xml 文件怎么打开和编辑”“xml 格式文件没有标签怎么办”说明你遇到了 XML 文件层面的基本问题。在 Android 开发里layout 的 XML 文件由资源编译器aapt2编译如果文件里缺少根标签或者标签不闭合编译时会直接报错。我在本地试过最典型的“没有标签”情况某个 XML 文件被别的工具清空或截断过只剩下一行注释或者一个空白节点。用 Android Studio 打开后右侧预览区一片空白构建也报 expected begin tag 之类的错误。解决办法是先用编辑器打开该文件确认内容结构然后把根节点补上。Android 资源 XML 的根节点通常是 、 或者 取决于你的布局不闭合的子标签也要顺手补全。用 Studio 自带的 Reformat CodeCtrlAltL可以统一缩进再用 Analyze → Inspect Code 检查潜在问题。如果你是想在 Android 里解析 XML 数据比如第三方接口返回 XML或者解析本地配置文件常见方案有 XmlPullParser、DOM 解析和 dom4j 这类库。简单场景用 XmlPullParser 最轻量复杂对象映射建议直接转成 JSON 或引入序列化工具。说实话现在新接口已经很少用 XML 做数据交换格式了大家搜“dom4j 解析 xml 步骤”多数是在维护老系统。如果是刚启动的项目别给自己上 XML 数据解析直接 JSON。6.2 Docker Compose 和 Android Compose 的“撞名”困惑“docker compose 安装”是一个搜索量很大的词但和本文主题八竿子打不着。可为什么这两个概念总被放在一起因为搜索引擎对“Compose”这个英文单词的语义判断有限你要是搜“compose 报错”出来的结果可能是 Android Compose也可能是 Docker Compose。快速区分方法看到 docker-compose.yml、容器、镜像、服务编排这些词那是 Docker Compose跟 UI 没关系看到 Composable、setContent、重组、LazyColumn 这些词才是 Android 的 Compose。如果你的报错信息是 Cannot start docker compose application. reason: compose [start] exit status请去查 Docker 日志和编排文件格式别在 Android Studio 里瞎找。6.3 Compose 的“android 测试”怎么写继续说“android 测试”这个热搜词其实在 Compose 项目里写 UI 测试比 XML 时代舒服不少。官方提供了 createComposeRule() 和一套节点查找接口可以直接模拟点击某个文本、断言某个界面是否存在。RunWith(AndroidJUnit4::class) class CounterTest { get:Rule val rule createComposeRule() Test fun clickButton_updatesCounter() { rule.setContent { Counter() } rule.onNodeWithText(点击了 0 次).assertExists() rule.onNodeWithText(点击了 0 次).performClick() rule.onNodeWithText(点击了 1 次).assertExists() } }需要添加测试依赖androidTestImplementation(androidx.compose.ui:ui-test-junit4) debugImplementation(androidx.compose.ui:ui-test-manifest)这种测试跑起来依赖真实设备或模拟器但不用像 Espresso 找 id 那样啰嗦直接按文案、语义描述找节点维护成本低不少。项目在转 Compose 的过程中每替换一个页面就补一个这种冒烟测试回归时心里会踏实很多。7. 最后一句大实话关于“XML 养老”这个话题我的看法是与其争谁比谁好不如看项目现状和团队储备。新项目如果技术上没有历史包袱直接用 Compose开发速度和对状态管理的体验都好很多老项目别急着重写先拿一个低风险页面试水把混合模式跑通再慢慢扩大边界。我个人带团队推进顺序是这样的第一步在新加的功能页里全用 Compose第二步把老页面中间件替换成 Compose 组件第三步XML 页面逐步退役直到代码库里不再出现新的 layout_*.xml。过程中别怕“两套体系并存”的过渡期因为大多数项目都不可能给你一次完美重写的窗口更稳的方式就是边跑边换。最后分享一个实战小技巧如果你在小组件上调背景和点击区域不一致十有八九是 Modifier 顺序问题如果你在混合页面里发现状态丢了先检查是不是用了 collectAsState() 而不是 collectAsStateWithLifecycle()。这两个坑我至少见过五六个人踩进去记下来能省不少 debug 时间。等 Compose 在你项目里跑顺以后再回头看那些 findViewById、notifyDataSetChanged 和层层嵌套的 XML你可能也会发出一句感慨真香。
阅读完成 · 觉得有帮助?
咨询建站