
做 Android 开发这几年的一个直观感受是大部分开发时间其实都耗在“改 UI”上而不是写业务逻辑。以前用 XML 写界面改一个列表要同时碰布局文件、Adapter、ViewHolder还要小心翼翼处理刷新时机一个不留神就是崩溃或者闪一下白屏。后来 Jetpack Compose 出来我从“UI 是控件树”切换到“UI 是函数”的思维方式花了一段时间但适应之后确实回不去了。这篇文章就从我在 Android Studio 里接入 Jetpack Compose 的实际过程出发把环境配置、第一个页面、状态管理、性能调优、老项目共存这些核心问题讲清楚。过程中遇到的 SDK 无法勾选、Gradle 下载卡住、构建报 tag number 报错这些高频问题也会一一展开排查思路。适合刚接触 Compose 的初级开发者也适合准备在公司老项目里引入 Compose 的团队参考。1. 为什么说 Compose 不是又一个新的控件库而是换了一套写 UI 的脑子1.1 XML 时代的日常改一个列表为什么要碰四个文件传统 Android UI 开发的核心链路是布局 XML 里定义控件Activity/Fragment 里用 findViewById 拿到控件实例然后在代码里操作它的属性比如 setText、setOnClickListener、notifyDataSetChanged。这套模式我们用了很多年但它有一个绕不开的痛点UI 的“静态描述”和“动态行为”被强行拆成了两套代码。拿一个最简单的“点赞数 按钮”来说XML 里先是 LinearLayout、TextView、Button然后是 Activity 里 findViewById、setOnClick、更新 TextView。代码量看起来还能接受但一旦页面复杂比如一个带筛选条件的列表页你需要维护 Adapter 的 item 布局、ViewHolder 的复用逻辑、数据集合的快照、点击事件的分发还有列表刷新时那个经典的“共享同一个 ArrayList 被外部改了导致崩溃”的坑。更麻烦的是UI 状态一变你得“手动”去同步所有相关控件。一个页面有三个控件依赖同一个数据漏刷新一个就是 bug。这种“命令式 UI”的本质是开发者需要描述“一步步怎么做”操作顺序一旦错界面就乱。1.2 声明式 UI 到底在声明什么Jetpack Compose 的思路完全反过来了。它的核心是一个朴素的想法UI f(state)也就是“界面是状态的函数”。开发者只需要声明“当前状态下界面应该长什么样”至于状态变化后怎么去更新控件、更新哪棵树的哪个节点、动画怎么过渡这些脏活都由框架内部完成。打个比方XML 时代像你打电话“遥控”同事干活你得一步步交代“把那个 TextView 的文字改成 10再把 Button 的背景换掉”Compose 时代像你把整份文档重新发给同事他拿旧的对比一下自己找出变化的地方去改你只需要关心文档内容对不对不需要关心他怎么改的。这个思想转变直接影响了代码结构。写 Compose 页面时你不需要关注某个控件是“第几个子 View”不需要记 id不需要找父容器 removeView只需要写出“在 Column 里放一个 Text根据 count 显示不同文案”然后 count 变了Text 自动变。1.3 用同一个“点赞数”案例对比两种写法先看 XML 方式的常规实现LinearLayout android:layout_widthwrap_content android:layout_heightwrap_content android:orientationvertical TextView android:idid/tvCount android:layout_widthwrap_content android:layout_heightwrap_content / Button android:idid/btnLike android:layout_widthwrap_content android:layout_heightwrap_content android:text点赞 / /LinearLayoutval tvCount findViewByIdTextView(R.id.tvCount) val btnLike findViewByIdButton(R.id.btnLike) var count 0 btnLike.setOnClickListener { count tvCount.text 点赞数$count }再看 Compose 方式Composable fun LikeButton() { var count by remember { mutableStateOf(0) } Column { Text(点赞数$count) Button(onClick { count }) { Text(点赞) } } }第二种写法里没有控件 id没有 findViewById没有 setText 调用。count 变化后Compose 自动重新执行这段函数生成新的“UI 描述”框架对比后只更新 TextView 对应的那颗叶子节点。页面难维护的问题从根本上被瓦解了因为界面和数据被绑定在同一个函数作用域里。所以“接入 Jetpack Compose UI”这件事关键的第一步不是装依赖、不是学 API而是先把脑子里那套“控件树 手动驱动”的思路换成“函数 状态驱动”。后面所有工具、配置、实践都是围绕这个基础展开的。2. 环境准备阶段最容易卡住的地方SDK 勾不上、Gradle 下不动、构建突然报 tag2.1 SDK Manager 里勾选框消失或安装没反应其实多半是网络路径出了问题不少人在 Android Studio 里打开 SDK Manager发现 Platform、Build Tools 列表加载不出来或者明明勾选了某个版本点 Apply 后进度条半天不动甚至直接没反应。这个问题在接入 Compose 时尤其致命因为 Compose 需要较新的 SDK Platform一般建议 API 34 以上和对应版本的 Build Tools如果 SDK 装不上后面全白搭。排查思路很简单先确认不是 Android Studio 本身卡了再检查 SDK 目录的写入权限最后看网络。国内开发环境最常见的触发点是SDK Manager 默认从 Google 官方仓库拉取列表路径不通时列表就是空的。解决方式是在 Android Studio 的设置里手动配置可用的 SDK 镜像站点地址。打开 Settings - Appearance Behavior - System Settings - HTTP Proxy选择手动配置填入 HTTP 主机名和端口然后重启 Android Studio再进 SDK Manager 重新加载。国内可用的 SDK 镜像源网上有很多腾讯云和阿里的都比较稳定。提示配置完镜像后如果 SDK Manager 里能看到列表但下载速度依然很慢可以把已经下载到一半的临时文件清掉重新下载避免损坏的包干扰后续构建。2.2 每次新建项目都要重新下载 Gradle问题就出在那一行 distributionUrl“新建项目又卡在 Gradle 下载”几乎是被问烂的问题。绝大多数情况Android Studio 会按照gradle/wrapper/gradle-wrapper.properties里的 distributionUrl 去官方地址下载对应版本的 Gradle。官方服务器在国内连接不稳定于是每次新建项目只要 wrapper 指定的 Gradle 版本本地没有缓存就要重新下载一遍。解法其实不复杂把 distributionUrl 换成国内镜像地址即可。distributionUrlhttps\://mirrors.cloud.tencent.com/gradle/gradle-8.7-all.zip这里注意保留-all.zip因为 IDE 需要源码包来看 Gradle API 的说明只下 bin 包也能构建但开发体验会差一些。除了 Gradle 本身项目的依赖仓库也要一起处理。build.gradle.kts或build.gradle里加上阿里云镜像仓库buildscript { repositories { maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/google } maven { url https://maven.aliyun.com/repository/gradle-plugin } google() mavenCentral() } } allprojects { repositories { maven { url https://maven.aliyun.com/repository/public } maven { url https://maven.aliyun.com/repository/google } google() mavenCentral() } }这样改完新建项目时 Gradle 和依赖库的下载速度会明显改善。还有一个“每次新建项目都要下载 Gradle”的隐蔽原因不同项目用的 Gradle 版本不同本地缓存没有对应版本就会重新下载。建议团队里统一 Gradle 版本用同一个 wrapper 配置这个问题会少很多。2.3 build 时报 “tag number over 30 is not supported” 的根因与排查路径构建时突然冒出一句tag number over 30 is not supported看着挺吓人但实际上它并不是 Compose 本身的语法错误而是构建链路上的某个环节对 dex 索引或注解处理数量做了限制。常见于使用较旧版本的 Android Gradle PluginAGP去编译依赖项较多的模块或者本地缓存里有损坏的中间产物。我遇到过一次类似报错当时的排查顺序是先看 AGP 版本和 Gradle 版本是否匹配。AGP 7.x 要求 Gradle 7.xAGP 8.x 要求 Gradle 8.x版本不匹配时会出现各种难以理解的编译错误。执行File - Invalidate Caches / Restart清掉 IDE 缓存然后Build - Clean Project把build/目录里可能损坏的中间产物清掉。检查 JDK 版本。AGP 8.x 建议 JDK 17AGP 7.x 建议 JDK 11JDK 版本过旧或过新都会引发 dex 编译工具的兼容性问题。如果项目开过 R8 混淆可以先临时把minifyEnabled false确认是否是混淆压缩环节触发的限制。我自己那次最后是升级 AGP 到 8.2 并配合 JDK 17 后解决的部署 Compose 项目前建议直接把 AGP、Kotlin、Gradle 和 JDK 统一到一个官方推荐匹配的版本组合上能避开大量这类“查不到原因”的报错。Compose 对构建工具链的版本敏感程度比传统 View 系统高很多别大意。2.4 顺手解决的低频问题中文界面、连不上测试机关于 Android Studio 汉化成中文网上有很多教程我个人建议保持英文环境因为报错信息、官方文档、Stack Overflow 上的回答基本都用英文术语中文界面反而容易让你在搜索问题时找不到对应项。如果你已经有英文基础但刚开始用可以在设置里把字体调大降低阅读压力。测试机连不上的问题小米这类设备最常见。除了在开发者选项里打开 USB 调试还要把“USB 安装”也打开否则 adb 能识别设备但安装 APK 时会弹窗失败。连接后如果 Android Studio 的设备列表还是空的试试先拔掉数据线重新插上并选择“传输文件”模式。环境这块顺了Compose 的学习曲线会平滑很多。环境配置不复杂但坑很集中网络路径和工具链版本是两个最主要的矛盾点先解决好再动手写代码心态会稳很多。3. 从模板工程开始把第一个 Compose 页面拆明白3.1 新建工程时选对模板少走一半弯路新版 Android StudioHedgehog 之后的版本新建项目时Empty Activity模板默认就是 Compose 工程生成的结构里已经包含 Compose 相关的依赖和MainActivity.kt示例代码。如果你的 Studio 版本生成的还是 XML 模板也没关系手动加依赖也很简单。一个 Compose 工程最核心的依赖组是三件套androidx.compose.ui:ui、androidx.compose.material3:material3、androidx.compose.ui:ui-tooling-preview。另外记得在模块的build.gradle.kts里开启 Compose 编译选项android { buildFeatures { compose true } } kotlinOptions { jvmTarget 17 }现在 Kotlin 2.0 之后Compose 编译器已经作为 Kotlin 插件的一部分直接引入org.jetbrains.kotlin.plugin.compose插件即可不再需要单独指定composeOptions.kotlinCompilerExtensionVersion。如果项目还没升到 Kotlin 2.0沿用老方式也没问题但注意版本要与 Kotlin 编译器匹配。3.2 setContent、Composable 和“函数就是 UI”的最小闭环模板生成的 MainActivity 大致长这样class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MyAppTheme { MainScreen() } } } }这里的setContent相当于传统写法里的setContentView(R.layout.activity_main)但参数从“布局资源”变成了“可组合函数块”。这套机制之所以成立核心是Composable注解。Composable不是 Java 里普通的注解它其实是给 Compose 编译器插件看的标记。编译器在处理带这个注解的函数时会做额外插桩记录状态读取、追踪重组范围、生成跳过逻辑。普通函数编译完就是一个普通方法而可组合函数编译完会被“改造”成带额外参数的内部实现。这也是为什么可组合函数只能在另一个可组合函数里调用你不能在一个普通for循环里直接调Text()编译器会报错。理解这一层后就能明白为什么 Compose 代码必须遵循“组合函数不能写在普通函数里”这种看似奇怪的规则了。它不是语法洁癖是实现机制决定的。3.3 Column/Box/LazyColumn 这三个布局组件覆盖了大多数页面传统 XML 布局有 LinearLayout、FrameLayout、RecyclerView 等Compose 对应的是 Column、Row、Box、LazyColumn。它们和传统布局的定位类似但写法和语义完全不同。Column和Row是线性排列对应 XML 里的 LinearLayout 的 vertical/horizontal。Box是层叠布局对应 FrameLayout适合做角标、遮罩、叠加层。LazyColumn对应 RecyclerView但它最大的好处是不需要 Adapter把“列表项”直接写成函数再通过items扩展函数传入数据集合就行。一个典型的卡片列表长这样Composable fun ArticleList(articles: ListArticle) { LazyColumn( contentPadding PaddingValues(16.dp), verticalArrangement Arrangement.spacedBy(12.dp) ) { items(articles, key { it.id }) { article - ArticleCard(article) } } } Composable fun ArticleCard(article: Article) { Card( shape RoundedCornerShape(12.dp), elevation CardDefaults.cardElevation(defaultElevation 2.dp) ) { Column(Modifier.padding(16.dp)) { Text(article.title, style MaterialTheme.typography.titleMedium) Text( article.content, style MaterialTheme.typography.bodyMedium, maxLines 3, overflow TextOverflow.Ellipsis ) } } }注意items(articles, key { it.id })里的key参数。它对应 RecyclerView 的setHasStableIds如果你不给 keyCompose 会按列表下标当作稳定标识插入、删除后可能复用错 item 的状态比如复选框勾选状态串了。接生产项目时这条一定要养成习惯。3.4 Modifier 不是随便乱链的顺序变了 UI 就变了Compose 里 Modifier 是实现“样式和行为”的主要方式padding、背景、点击事件都靠它。但新手最容易忽略的是Modifier 链式调用是有顺序的顺序不同效果也不同。看这两个写法// 写法一先 padding 再 background Modifier.padding(16.dp).background(Color.Red) // 写法二先 background 再 padding Modifier.background(Color.Red).padding(16.dp)第一种的效果是“红色背景被 padding 缩小”第二种是“红色背景铺满padding 区域也是红色”。很多“为什么我的背景尺寸不对”的问题根源就是顺序写反了。这里用一个简单规律记忆Modifier 的书写顺序就是外部到内部的包裹顺序先写的在外面后写的在里面。另外Modifier.clickable和Modifier.background搭配时如果先写 background 再写 clickable点击水波纹会被背景盖住反过来写点击反馈区域就正常。遇到“点击没反应但视觉上有点击区域”的问题检查的也是 Modifier 顺序。Compose 没有 Android Studio 里那个可视化拖拽布局编辑器所有布局描述都在代码里这反而对可读性有好处一个页面长什么样、元素怎么排、状态怎么流转全部能在一段代码里读出来。模块拆得合理代码本身就是文档。4. State 和重组Compose 的心脏循环也是卡顿和 Bug 的高发区4.1 没有 findViewById 之后UI 到底怎么刷新Compose 页面里没有“更新 UI”的概念只有“状态变了通知 Compose 重新执行”。承接这个工作的就是mutableStateOf。var count by remember { mutableStateOf(0) }这一行代码创建了一个被 Compose 观察的状态对象。当可组合函数里读取了这个状态时Compose 会自动把这个函数注册成该状态的观察者。之后任何地方给 count 赋值Compose 都会调度重组重新执行哪些读取过 count 的可组合函数。这就是它叫“重组”的原因不是整个 Activity 重新创建也不是整个页面树全部重建而是精确到“读了那个状态的最小函数范围”。所以我们写 Compose 时不用关心“改完数据要不要调 notifyDataSetChanged”只需保证“UI 描述必须依赖状态”剩下的就是框架的活了。这里有一个关键约束可组合函数不能长期持有修改状态的能力。正确逻辑是状态往上层放函数向下层传值后面详说。4.2 remember、rememberSaveable、State三个最常用状态工具的边界remember的作用是“跨重组保存值”。重组时函数会重新执行如果没有 remember局部变量每次重组都会重新创建。但注意remember 只在“组合过程”里有效Activity 真的被销毁重建时remember 里的值会丢。比如旋转屏幕Activity 被重建Compose 树重新创建remember 缓存的全没了。为了应对这种情况用rememberSaveable它内部通过 Bundle 机制保住了配置变更后的数据活着时和 remember 用法几乎一样。实际开发的区分很简单临时计算缓存、不需要跨重建状态用remember需要 Activity 重建后还保留的 UI 状态比如输入框内容、列表滚动位置用rememberSaveable业务数据存在 ViewModel 里用StateFlow配合collectAsStateWithLifecycle收集还是那句最常见的例子搜索框输入的内容必须用rememberSaveable否则用户转个屏搜到一半的关键词就没了。4.3 重组不是“整个页面重画”理解重组范围才能治好卡顿Compose 声称有“智能重组”但智能有代价很多卡顿就是滥用状态读取位置造成的。原理很简单如果一个可组合函数内部读取了多个状态其中任何一个变化整个函数参与重组。哪怕其他状态相关的 UI 完全没变。举一个典型反例Composable fun BigScreen(viewModel: MyViewModel) { val state by viewModel.uiState.collectAsStateWithLifecycle() Column { Header(state.userName) Body(state.list) Footer(state.userName) // 这里只依赖 userName但 list 变化也会让 Footer 重组 } }BigScreen整体订阅了一个大状态list 一变Header、Body、Footer 全部跟着重组。优化方式是把状态读取“下压”让每个子组件只读自己关心的那部分数据或者把大对象拆小。Compose 编译器在大多数时候能够跳过没变化的函数但跳过的前提是“入参稳定”传一个无状态 lambda 比传一个会变的值更容易触发跳过。在排查卡顿时先看重组次数。Android Studio 的 Layout Inspector 打开 Composition 标签能看到每个可组合函数的重组次数和原因。如果某个组件重组次数远超预期再用“状态读取下压”的思路拆分组件。4.4 实战一个带搜索过滤的列表页把状态管理串起来把上面的知识串起来写一个带搜索框的过滤列表。这个页面覆盖了 TextField 输入、状态保存、派生数据、列表 key 四个核心点。Composable fun SearchableList(allItems: ListString) { var keyword by rememberSaveable { mutableStateOf() } val filteredItems remember(allItems, keyword) { if (keyword.isBlank()) allItems else allItems.filter { it.contains(keyword.trim(), ignoreCase true) } } Column(Modifier.fillMaxSize()) { OutlinedTextField( value keyword, onValueChange { keyword it }, modifier Modifier.fillMaxWidth(), singleLine true, placeholder { Text(输入关键词过滤列表) } ) LazyColumn( modifier Modifier.weight(1f) ) { items(filteredItems) { item - Text( item, modifier Modifier.fillMaxWidth().padding(horizontal 16.dp, vertical 10.dp) ) } } } }注意remember(allItems, keyword)这一段当 allItems 或 keyword 变化时重新计算过滤结果。keyword 由输入框驱动allItems 可能来自网络请求两者都是状态过滤结果是“派生状态”。这里不需要用derivedStateOf因为计算本身很快直接用 remember 就够如果过滤逻辑涉及复杂计算再考虑derivedStateOf或者放到 ViewModel 里做。这个页面的数据流是输入框产生 keywordfilteredItems 根据 keyword 计算LazyColumn 根据 filteredItems 渲染。状态单向流动UI 只和最终状态挂钩以前 XML 时代“输入框内容变了要手动刷新列表”的既有操作全部隐去了。5. 把 Compose 接进真实项目共存方案、UI 层架构和绕不开的坑5.1 老项目不需要推倒重来ComposeView 与 AndroidView 的双向混用很多人担心接 Compose 意味着老项目要重写事实完全不是这样。Compose 可以和传统 View 体系在同一个 Activity 里共存切换方式也简单。想在 XML 布局里嵌入 Compose 页面直接放一个 ComposeViewandroidx.compose.ui.platform.ComposeView android:idid/compose_view android:layout_widthmatch_parent android:layout_heightwrap_content /然后在代码里找到它并 setContentfindViewByIdComposeView(R.id.compose_view).setContent { MyComposeContent() }反向操作也成立在 Compose 组件里使用传统 View用AndroidView包裹Composable fun LegacyViewInCompose() { AndroidView( factory { context - LayoutInflater.from(context).inflate(R.layout.legacy_header, null) }, modifier Modifier.fillMaxWidth() ) }这两种混用模式让团队可以渐进式迁移老页面继续用 XML新页面直接用 Compose已经在维护的复杂页面可以等下一次大版本重构再换。对一个成熟项目来说这是风险最低的落地路径。5.2 UI 层怎么分层StateFlow、ViewModel 与 Compose 的配合方式项目规模一大UI 状态绝不会只躺在 Activity 里。正确的分层思路是ViewModel 持有业务状态Compose 负责把状态渲染出来事件通过回调或 Intent 向上传递。ViewModel 侧用 StateFlow 暴露不可变状态class MainViewModel : ViewModel() { private val _uiState MutableStateFlow(MainUiState()) val uiState: StateFlowMainUiState _uiState.asStateFlow() fun onUserInput(text: String) { _uiState.update { it.copy(keyword text) } } }Compose 侧用collectAsStateWithLifecycle收集Composable fun MainScreen(viewModel: MainViewModel viewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() SearchableList( allItems uiState.filteredItems, keyword uiState.keyword, onKeywordChange viewModel::onUserInput ) }这里有几个细节容易踩坑第一collectAsStateWithLifecycle需要额外引入androidx.lifecycle:lifecycle-runtime-compose依赖直接写会编译报错。第二不要在 ViewModel 里持有 Compose 的 State 对象它会破坏 ViewModel 的生命周期边界业务层应该保持与 UI 框架无关。第三事件回调用简单 Lambda 传递不要在可组合函数里“手动触发”另外一个状态更新容易引发不必要的重组。这套分层方式覆盖了谷歌推荐的单向数据流UI 事件向下传给 ViewModel处理完后以新状态的形式流回 UI中间没有捷径。5.3 性能问题别靠猜Layout Inspector 和 Compiler Metrics 怎么用接入 Compose 后“页面为什么卡”这个问题有了更直接的定位方式。Android Studio 的 Layout Inspector 在 Compose 下变强了选中设备后打开 Layout Inspector 的 Composition 标签能看到每个可组合函数的重组次数、跳过次数以及触发重组的状态。如果一个函数重组次数远多于其他函数优先查看它是否直接读取了一个频繁变化的状态。另外注意所有从外部传入组件的大 Data Class如果父组件重组时生成了新对象子组件即使“没变化”也会跟着重组。更精细的排查用 Compose Compiler Metrics。Kotlin 2.0 之后在 build.gradle.kts 中开启plugins { id(org.jetbrains.kotlin.plugin.compose) } composeCompiler { reportsDestination layout.buildDirectory.dir(compose_compiler) }构建后会在build/compose_compiler目录生成报告文件里面标记了哪些可组合函数是可以跳过的skippable哪些是不可跳过的restartable方便做针对性的稳定性优化。刚开始不用追求全部可跳过只要热点页面的关键组件状态清晰性能就不会有大问题。5.4 一个接生产必看的避坑清单把常见的几个坑列成表格每一条都是我实际见过或踩过的场景现象处理建议图片加载Glide 不能像 ImageView 那样直接用Compose 首选 Coil 的 AsyncImage或 AndroidView 包一层 ImageView点击区域视觉上可点的区域和实际点击区域不一致检查 Modifier 顺序clickable 放外层配合 clip 使用深色模式写死的 Color 在深色下看不清用 MaterialTheme.colorScheme 的语义色少用 Color 常量WebViewCompose 没有内置 WebView 组件用 AndroidView(factory { WebView(it) }) 桥接Dialog 生命周期Compose 的 Dialog 若在重组中使用不当会泄漏或闪退用 androidx.compose.ui.window.Dialog并确保不在非组合作用域创建输入法与布局键盘弹起后输入框被盖住给根布局加 Modifier.imePadding()动画状态动画驱动的值频繁变化导致大范围重组把动画值限制在动画组件内部不向上传递item 状态串位LazyColumn 滚动后复选框状态错乱items 时必须传稳定的 key这份清单不是固定不变的但遇到诡异问题先对照一遍能省下大量排查时间。Compose 整体还很年轻版本迭代快遇到问题第一选择不是怀疑自己而是先查版本和依赖链。6. 在我看来跨过 Compose 这道坎真正要抓住的三件事如果朋友问我 Compose 怎么快速上手我不会让他先背 API而是建议先想明白三件事。第一别用 XML 时代的思维写 Compose。老想着把每个控件握在手里手动控制只会写出“Compose 皮命令式里子”的代码。开始写之前先让脑子切换成“声明式”模式UI 是状态的映射状态变UI 自己会变。第二状态管理是比 UI API 更重要的门槛。Text、Button、LazyColumn 这些组件看一遍文档就会用但 State、remember、ViewModel 配合起来怎么设计数据流才决定页面复杂度高了之后是不是豆腐渣工程。建议把官方文档里 Thinking in Compose 和状态管理两节反复读几遍配合一个小项目实践别怕推倒重来。第三版本绑定意识要强。Compose、Kotlin、AGP 三者版本有绑定关系升级任意一个另外两个常常也要同步动。团队里推广 Compose 时最好统一用同一个稳定版本组合不要各写各的否则 build 报错会消耗大量时间和热情。我在实际项目里的经验是先挑一个不重要的新功能页面用 Compose 落地团队跑顺了再慢慢推广。第一批痛过、踩过坑之后后面的路会越来越顺。Compose 这条路走下来最大的收益不只是少了 findViewById而是整个 UI 层代码变得更像“业务描述”而不是“操作指令”。对开发效率、代码可读性、新成员上手速度都是实打实的提升。