1. 从 RecyclerView 到 LazyColumn:迁移这件事到底难在哪
做过几年 Android 的人应该都有体会,Compose 刚出来那阵子,大家的态度基本分两派:一派觉得声明式 UI 是未来,早点上车早受益;另一派觉得现有 View 体系跑得好好的,迁移成本高、风险大,能拖就拖。但到了现在这个时间点,越来越多的团队开始认真考虑把存量项目往 Compose 上迁,原因很现实——新功能用 Compose 写确实快,维护成本也低,但老代码还在用 XML + RecyclerView,两套体系混在一起,状态同步、主题适配、导航管理全是坑。
Meta 这次分享的 AI 辅助迁移方案,核心思路不是"让 AI 帮你重写整个项目",而是把迁移拆成一个个可验证的小步骤,让 AI 在每一步里做它擅长的事——模式识别、代码转换、样板生成,而人负责判断和兜底。这个思路听起来简单,但实际操作下来,有几个关键点如果没想清楚,很容易翻车。
先说最核心的难点。Compose 和 View 体系不是简单的 API 替换关系,它们背后的编程模型完全不同。View 体系是命令式的,你拿到一个 View 引用,手动调setText、setVisibility、notifyDataSetChanged;Compose 是声明式的,UI 是状态的函数,状态变了 UI 自动重组。这意味着迁移不是"把TextView换成Text"这么简单,而是要重新思考数据怎么流动、状态存在哪里、副作用怎么管理。
RecyclerView 的迁移尤其典型。一个典型的 RecyclerView 有 Adapter、ViewHolder、DiffUtil、ItemDecoration、LayoutManager 这一整套东西,搬到 LazyColumn 之后,Adapter 和 ViewHolder 直接消失了,DiffUtil 被key和contentType取代,ItemDecoration 变成了Modifier上的 padding 或者自定义绘制。如果你只是机械地做 API 映射,写出来的 Compose 代码会非常别扭,性能也可能更差。
还有一个容易被低估的点是互操作。Meta 的方案里特别强调了AndroidView和ComposeView这两个桥梁的作用。实际迁移中,你不可能一次性把所有界面都换掉,必然是渐进式的——今天迁一个列表页,明天迁一个详情页,中间还要保证两套体系能互相嵌套、状态能互通。这时候互操作的边界怎么划、状态提升到哪一层、生命周期怎么对齐,都是需要提前设计的。
我见过不少团队一上来就想用 AI 全量转换,结果生成的代码编译都过不了,或者勉强跑起来但行为跟原来不一致,最后返工的成本比手写还高。Meta 分享里反复提到的一个原则我觉得很对:AI 负责"翻译",人负责"设计"。翻译是局部的、模式化的,设计是全局的、需要判断的。把这两件事混在一起交给 AI,基本没有好结果。
下面我会按实际迁移的流程,把每个阶段 AI 能帮上什么忙、哪些地方必须人工介入、有哪些坑要提前避开,一条条拆开讲。内容会涉及具体的代码模式、提示词写法、验证方法,也会分享一些我自己踩过的坑。
2. 迁移前的准备工作:先搞清楚哪些能迁、哪些不能迁
2.1 用 AI 做代码盘点,而不是直接改代码
很多人拿到迁移任务的第一反应是打开 Android Studio,找个文件就开始改。这个做法在 Compose 迁移里是大忌。存量项目动辄几十上百个界面,每个界面的复杂度、依赖关系、测试覆盖都不一样,不先做盘点就动手,改到一半发现某个界面依赖了一个全局的 View 缓存机制,整个方案就得推倒重来。
Meta 的做法是先用 AI 做一轮代码盘点。具体来说,把项目里所有的 Activity、Fragment、自定义 View、布局文件喂给 AI,让它输出一份分类报告。提示词可以这样写:
你是一个 Android 架构分析助手。请分析以下代码文件,按以下维度分类: 1. 纯展示型界面(只读数据,无复杂交互) 2. 表单型界面(有输入、校验、提交) 3. 列表型界面(使用 RecyclerView 或 ListView) 4. 自定义绘制界面(继承 View 并重写 onDraw) 5. 强依赖 View 体系特性的界面(如 View 动画、View 状态保存) 对每个界面,标注: - 迁移难度(低/中/高) - 主要风险点 - 是否建议优先迁移 输出格式用表格。这个盘点做完,你会得到一张清晰的迁移地图。纯展示型界面和表单型界面通常是最容易迁的,因为它们的逻辑简单,Compose 的Text、TextField、Button能直接对应。列表型界面难度中等,主要工作量在 Adapter 到 LazyColumn 的转换。自定义绘制和强依赖 View 特性的界面难度最高,很多时候建议保留原样,用AndroidView包一层就行,没必要硬迁。
提示:盘点阶段不要省时间。我见过一个项目,团队跳过了盘点直接开干,结果迁到第 20 个界面时发现有个全局的
ViewTreeObserver监听逻辑,所有界面都依赖它,最后不得不把已经迁好的十几个界面全部回退。盘点花两天,能省两周。
2.2 依赖和构建配置的迁移顺序
代码盘点之后,下一步是处理依赖和构建配置。这一步看起来琐碎,但顺序错了会很麻烦。正确的顺序是:
- 先升级 Kotlin 版本到 Compose 编译器兼容的版本
- 在
build.gradle里开启buildFeatures { compose true } - 配置 Compose 编译器版本(Kotlin 2.0 之后用
composeCompiler插件,之前用composeOptions) - 引入 Compose BOM,统一管理 Compose 相关库的版本
- 引入
androidx.compose.material3、androidx.activity:activity-compose、androidx.lifecycle:lifecycle-viewmodel-compose等基础库 - 最后再引入
androidx.compose.ui:ui-viewbinding之类的互操作库
这个顺序的原因在于,Compose 编译器版本和 Kotlin 版本是强绑定的,版本对不上会直接编译失败。而 Compose BOM 能帮你避免各个 Compose 库版本不一致导致的运行时崩溃。我建议在迁移初期就把 BOM 加上,后面加新库的时候不用操心版本号。
// build.gradle.kts (Module) android { buildFeatures { compose = true } } dependencies { val composeBom = platform("androidx.compose:compose-bom:2024.09.00") implementation(composeBom) implementation("androidx.compose.ui:ui") implementation("androidx.compose.material3:material3") implementation("androidx.activity:activity-compose:1.9.0") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.0") implementation("androidx.compose.ui:ui-viewbinding") }这里有个细节值得说:ui-viewbinding这个库是互操作的关键,它让你能在 Compose 里用 ViewBinding 加载 XML 布局,也能在 XML 里嵌入 ComposeView。迁移期间这个库基本是必加的,等全部迁完再考虑移除。
2.3 建立迁移的验证基线
迁移最怕的不是改错,而是改错了不知道。所以在动手之前,必须建立一套验证基线。Meta 分享里提到他们用截图对比 + 行为录制的方式做验证,我觉得这个思路很实用。
具体做法是:对每个待迁移的界面,先用原 View 版本跑一遍,截取关键状态的截图(初始态、加载态、空态、错误态、数据填充态),同时录制一段操作流程的视频。迁移完成后,用同样的操作流程跑 Compose 版本,对比截图和视频。如果视觉上有差异,或者行为不一致,就说明迁移有问题。
这个基线建立的过程可以部分交给 AI 辅助。比如让 AI 根据界面的布局文件生成一份"状态清单",列出这个界面所有可能的状态和对应的 UI 表现,你照着清单去截图就行。提示词示例:
以下是一个 Android XML 布局文件和对应的 Fragment 代码。 请列出这个界面所有可能的 UI 状态,包括: - 初始加载状态 - 数据加载成功状态 - 空数据状态 - 错误状态 - 各种交互后的状态变化 对每个状态,描述界面上各个元素的可见性和内容。有了这份清单,验证就不会漏。我自己的经验是,迁移中最容易出问题的往往不是主流程,而是那些边缘状态——空数据、网络错误、权限被拒、配置变更后的重建。这些状态在原版本里可能藏得很深,不专门测根本发现不了。
3. 用 AI 做代码转换:哪些模式可靠,哪些必须人工兜底
3.1 布局文件到 Composable 的转换
XML 布局到 Composable 的转换是 AI 最擅长的部分,因为这是高度模式化的映射。LinearLayout对应Column或Row,FrameLayout对应Box,ConstraintLayout对应ConstraintLayout的 Compose 版本或者用Modifier组合实现。属性映射也有规律:android:padding对应Modifier.padding(),android:background对应Modifier.background(),android:textSize对应fontSize。
但这里有几个坑必须注意。第一个是layout_weight。XML 里的layout_weight在 Compose 里对应Modifier.weight(),但weight只能在Column或Row的 scope 里用,如果 AI 生成的代码把weight用在了Box里,编译直接报错。第二个是ConstraintLayout的转换,Compose 版的 ConstraintLayout 需要createRefs()和constrainAs,写起来比 XML 啰嗦,AI 经常生成引用不存在的 ref 的代码。第三个是include标签和merge标签,这两个在 Compose 里没有直接对应,需要拆成独立的 Composable 函数。
我的建议是,布局转换可以让 AI 生成初稿,但生成之后必须人工过一遍,重点检查weight的作用域、ConstraintLayout的 ref 引用、以及include的拆分。提示词可以这样写:
将以下 Android XML 布局转换为 Jetpack Compose 代码。 要求: 1. 使用 Material3 组件 2. 保持原有的层级结构和间距 3. 对于 ConstraintLayout,优先用 Column/Row/Box + Modifier 实现,除非约束关系复杂才用 Compose 版 ConstraintLayout 4. 所有可交互元素提取为独立的 Composable 函数参数 5. 不要生成预览注解,我会自己加 XML 布局: [粘贴布局文件]3.2 RecyclerView Adapter 到 LazyColumn 的转换
这是迁移中工作量最大、也最容易出问题的部分。一个典型的 RecyclerView Adapter 包含onCreateViewHolder、onBindViewHolder、getItemCount、getItemViewType这几个方法,搬到 LazyColumn 之后,这些全都不需要了,取而代之的是一个items调用加上一个 item 的 Composable。
但真正的难点不在 API 映射,而在状态管理。RecyclerView 的 ViewHolder 模式天然地把 item 的状态封装在 ViewHolder 里,而 Compose 的 item 是无状态的,状态要么提升到 ViewModel,要么用remember存在 Composable 内部。如果 AI 只是机械地把onBindViewHolder里的代码搬到 item Composable 里,那些依赖 ViewHolder 复用的逻辑(比如动画、点击态、展开收起)就会出问题。
举个例子,原 Adapter 里可能有一个 item 的展开收起状态存在 ViewHolder 里:
// 原 View 版本 class MyViewHolder extends RecyclerView.ViewHolder { boolean isExpanded = false; void bind(Item item) { itemView.setOnClickListener(v -> { isExpanded = !isExpanded; notifyItemChanged(getAdapterPosition()); }); } }这种写法在 RecyclerView 里能跑,因为 ViewHolder 会被复用,isExpanded跟着 ViewHolder 走。但搬到 Compose 之后,如果你直接写:
// 错误的迁移方式 @Composable fun ItemView(item: Item) { var isExpanded by remember { mutableStateOf(false) } Column(modifier = Modifier.clickable { isExpanded = !isExpanded }) { // ... } }看起来没问题,但remember的状态在 item 滚出屏幕后会被回收,滚回来就重置了。正确的做法是把展开状态提升到 ViewModel 或者用一个rememberSaveable配合稳定的 key。这个判断 AI 做不了,必须人工介入。
所以我的做法是,Adapter 转换分两步走:第一步让 AI 生成基础的 LazyColumn 结构,把 item 的 UI 部分转过来;第二步人工审查所有涉及状态的部分,决定状态提升到哪一层。提示词示例:
将以下 RecyclerView Adapter 转换为 LazyColumn 实现。 要求: 1. 只转换 UI 结构,不要处理状态逻辑 2. 对于 onBindViewHolder 中的状态相关代码(如点击态、展开态),用 TODO 注释标出,我来决定状态提升方案 3. 保留 DiffUtil 的等价逻辑,用 items 的 key 参数实现 4. 如果有多种 viewType,用 contentType 参数区分 Adapter 代码: [粘贴 Adapter]3.3 状态和副作用的迁移
Compose 的状态管理和 View 体系差异最大,也是 AI 最容易出错的地方。View 体系里,状态通常存在 View 的属性里(TextView.text、ImageView.drawable)或者 Activity/Fragment 的成员变量里。Compose 里,状态必须是State<T>或者MutableState<T>,而且要在合适的作用域里创建。
常见的错误模式有这么几种。第一种是把状态存在 Composable 的局部变量里,导致重组时状态丢失。第二种是状态提升的层级不对,提升太高导致不必要的重组,提升太低导致状态无法共享。第三种是副作用用错了 API,该用LaunchedEffect的地方用了rememberCoroutineScope,该用DisposableEffect的地方用了SideEffect。
这些错误 AI 很难自己发现,因为它不知道你的业务逻辑。我的做法是,状态迁移这部分基本不让 AI 全自动做,而是让 AI 生成一个"状态清单",列出原代码里所有的状态变量、它们的作用域、读写时机,然后我根据这个清单手动设计 Compose 的状态结构。提示词:
分析以下 Fragment/Activity 代码,列出所有状态变量。 对每个状态变量,标注: - 变量名和类型 - 声明位置(成员变量/局部变量) - 读写的时机(初始化/用户交互/网络回调) - 是否需要在配置变更后保留 - 建议的 Compose 状态方案(remember/rememberSaveable/ViewModel) 代码: [粘贴代码]有了这份清单,状态迁移就有了依据。我一般会把需要跨配置变更保留的状态放到 ViewModel 里,把纯 UI 的临时状态用rememberSaveable存在 Composable 里,把只在单个 Composable 内使用的状态用remember存。
4. 互操作阶段:两套体系怎么共存不打架
4.1 AndroidView 和 ComposeView 的使用边界
渐进式迁移的核心是互操作。Compose 提供了AndroidView让你在 Composable 里嵌入 View,View 体系提供了ComposeView让你在 XML 里嵌入 Composable。这两个桥梁用好了,迁移可以非常平滑;用不好,会引入一堆生命周期和状态同步的问题。
先说AndroidView。它的基本用法是:
AndroidView( factory = { context -> TextView(context).apply { textSize = 16f } }, update = { textView -> textView.text = state.text } )factory只在第一次组合时调用,update在每次重组时调用。这里的关键是,factory里创建的 View 实例会被 Compose 持有,它的生命周期跟 Composable 绑定。如果你在factory里做了耗时操作或者注册了监听器,记得在onRelease里清理。
AndroidView最适合的场景是嵌入那些迁移成本高、但功能独立的 View,比如自定义图表、地图控件、WebView。这些 View 内部有自己的状态管理,跟 Compose 的状态交互很少,包一层就行。
ComposeView的用法是在 XML 里放一个androidx.compose.ui.platform.ComposeView,然后在代码里调setContent:
findViewById<ComposeView>(R.id.compose_view).setContent { MaterialTheme { MyComposable() } }这里有个坑:ComposeView的setContent必须在主线程调用,而且如果这个ComposeView在 RecyclerView 的 item 里,每次onBindViewHolder都会调setContent,导致重复组合。正确的做法是在onCreateViewHolder里调一次setContent,在onBindViewHolder里通过状态驱动更新。
4.2 主题和样式的统一
迁移期间,View 体系和 Compose 体系会同时存在,主题和样式如果不统一,界面会看起来很割裂。View 体系用Theme.Material3或者自定义主题,Compose 用MaterialTheme,这两套主题系统是独立的,需要手动对齐。
Meta 的做法是,把 View 体系的主题属性映射到 Compose 的MaterialTheme里。具体来说,在setContent的外层包一个MaterialTheme,把颜色、字体、形状都从 View 主题里读出来:
@Composable fun AppTheme(content: @Composable () -> Unit) { val context = LocalContext.current val typedArray = context.obtainStyledAttributes( intArrayOf( com.google.android.material.R.attr.colorPrimary, com.google.android.material.R.attr.colorOnPrimary, com.google.android.material.R.attr.colorSurface ) ) val colorScheme = lightColorScheme( primary = Color(typedArray.getColor(0, 0)), onPrimary = Color(typedArray.getColor(1, 0)), surface = Color(typedArray.getColor(2, 0)) ) typedArray.recycle() MaterialTheme(colorScheme = colorScheme, content = content) }这样 View 和 Compose 用的是同一套颜色值,切换的时候不会有突兀感。字体和形状也可以类似处理。这个映射代码可以让 AI 生成,但颜色属性的对应关系需要人工确认,因为 Material2 和 Material3 的属性名不完全一样。
4.3 导航的过渡方案
如果项目用的是 Navigation Component,迁移期间会面临一个选择:是继续用 XML 导航图,还是换成 Compose Navigation。我的建议是,迁移期间先不要动导航,继续用 Navigation Component,把 Compose 界面当作普通的 Fragment 或者 Destination 接进去。等所有界面都迁完了,再考虑整体换成 Compose Navigation。
原因是 Navigation Component 和 Compose Navigation 的模型差异比较大,迁移期间两套导航混用会引入很多状态同步问题。而且 Navigation Component 本身支持ComposeView作为 Destination 的 content,接起来很自然。
具体做法是,把迁移好的 Compose 界面包在一个 Fragment 里:
class ComposeFragment : Fragment() { override fun onCreateView( inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? ): View { return ComposeView(requireContext()).apply { setContent { AppTheme { MyComposableScreen() } } } } }然后在导航图里像普通 Fragment 一样引用它。这样导航逻辑完全不用改,迁移的界面和未迁移的界面可以无缝跳转。
5. 验证和回归:怎么确认迁移没改坏东西
5.1 截图对比的自动化
前面提到的截图基线,在迁移完成后要用来做对比。手动对比效率太低,可以写个简单的自动化脚本。用adb截图,然后用图像对比库算差异:
# 截取当前界面 adb shell screencap -p /sdcard/screen.png adb pull /sdcard/screen.png ./current.png # 用 Python 对比 python compare.py baseline.png current.png# compare.py from PIL import Image, ImageChops import sys baseline = Image.open(sys.argv[1]) current = Image.open(sys.argv[2]) diff = ImageChops.difference(baseline, current) bbox = diff.getbbox() if bbox: print(f"差异区域: {bbox}") diff.save("diff.png") else: print("无差异")这个脚本很简单,但能帮你快速发现视觉回归。对于有动画或者动态内容的界面,截图对比可能不准,这时候就需要人工看录屏。
5.2 行为一致性的检查清单
视觉之外,行为一致性更重要。我整理了一份检查清单,每次迁移完一个界面都会过一遍:
| 检查项 | 检查方法 | 常见问题 |
|---|---|---|
| 点击响应 | 手动点击所有可点击元素 | Compose 的 clickable 没加 ripple 或者点击区域不对 |
| 滚动性能 | 快速滚动长列表 | LazyColumn 的 key 不稳定导致重组过多 |
| 状态保存 | 旋转屏幕后检查状态 | remember 用了但没提升到 ViewModel |
| 返回栈 | 连续跳转后按返回 | ComposeView 的 lifecycle 没对齐 |
| 输入法 | 在输入框输入后收起键盘 | Compose 的 imePadding 没处理 |
| 深色模式 | 切换系统深色模式 | 颜色硬编码没走 MaterialTheme |
| 字体缩放 | 调整系统字体大小 | 用了固定 sp 值没适配 |
这份清单里的每一项我都踩过坑。比如输入法那个,Compose 的TextField默认不会自动处理键盘遮挡,需要在根布局加Modifier.imePadding(),或者用WindowInsets.ime手动处理。这个在 View 体系里是adjustResize自动搞定的,迁移时很容易漏。
5.3 性能回归的监控
Compose 的性能模型和 View 不一样,迁移后性能可能变好也可能变差。变差通常是因为重组过多。可以用 Layout Inspector 的 Compose 支持来看重组次数,或者用Modifier.composed的替代方案来减少不必要的重组。
一个常见的性能问题是,在 LazyColumn 的 item 里用了不稳定的参数。比如传了一个List<T>进去,Compose 认为它不稳定,每次重组都会重新执行 item。解决办法是用ImmutableList或者给数据类加@Stable注解。
// 不稳定,会导致多余重组 @Composable fun ItemView(items: List<String>) { ... } // 稳定,用 ImmutableList @Composable fun ItemView(items: ImmutableList<String>) { ... } // 或者给数据类加注解 @Stable data class UiState(val items: List<String>)这个点 AI 生成代码时基本不会考虑,需要人工审查。我的做法是迁移完成后用 Compose 的编译器报告检查一遍,看看有没有不稳定参数导致的性能警告。
6. 几个我踩过的坑和对应的解法
6.1 AI 生成的代码编译不过怎么办
这是最常见的问题。AI 生成 Compose 代码时,经常犯的错误包括:引用了不存在的 import、用了旧版本的 API、Modifier链式调用顺序不对、remember的 key 参数漏了。遇到编译错误不要慌,把错误信息连同代码一起丢回给 AI,让它修:
以下 Compose 代码编译报错,请修复: 错误信息:[粘贴错误] 代码:[粘贴代码] 注意:不要改变代码的业务逻辑,只修复编译问题。通常一两轮就能修好。但如果同一个错误反复出现,说明 AI 对这个 API 的理解有问题,这时候就别让它修了,自己查文档改更快。
6.2 迁移后界面闪烁或者跳动
这个问题的根源通常是状态初始化时机不对。View 体系里,View 创建后马上就能设置内容,用户看不到中间态。Compose 里,第一次组合时状态可能还是初始值,等LaunchedEffect或者 ViewModel 的数据加载完才更新,中间会有一帧显示初始态。
解决办法是用remember的初始值直接给一个合理的默认值,或者用AnimatedContent做过渡。如果数据加载很快,也可以在 ViewModel 里用StateFlow的initialValue给一个占位状态。
6.3 列表滚动位置丢失
RecyclerView 迁移到 LazyColumn 后,如果滚动位置在配置变更或者页面切换后丢失,通常是因为rememberLazyListState没有正确保存。rememberLazyListState内部用的是rememberSaveable,理论上能自动保存,但如果你的 LazyColumn 在一个动态创建的 Composable 里,或者 key 不稳定,保存就会失效。
解法是确保rememberLazyListState在稳定的作用域里调用,并且给 LazyColumn 的 items 提供稳定的 key。如果还是不行,可以手动把firstVisibleItemIndex和firstVisibleItemScrollOffset存到 ViewModel 里。
6.4 互操作界面的生命周期问题
AndroidView里嵌入的 View 如果注册了监听器或者启动了动画,在 Composable 离开组合时不会自动清理,需要手动在onRelease里处理。我遇到过一个案例,AndroidView里放了一个播放器 View,页面退出后播放器还在后台跑,就是因为没在onRelease里暂停。
AndroidView( factory = { context -> PlayerView(context) }, update = { view -> view.setPlayer(player) }, onRelease = { view -> view.player = null } )onRelease是AndroidView的一个参数,很多人不知道,结果就漏了清理逻辑。
7. 迁移节奏和团队协作的建议
最后聊点偏工程管理的东西。Compose 迁移不是一个人的事,也不是一周两周能搞完的。我见过比较成功的迁移,都是按"小步快跑、持续验证"的节奏来的。
具体来说,每个迭代周期(比如两周)选 2 到 3 个界面迁移,迁完立刻做验证和回归,确认没问题再合入主干。不要攒一大批一起合,那样出了问题很难定位是哪个界面引入的。AI 在这个节奏里扮演的是"加速器"的角色,它帮你快速生成初稿、快速修复编译错误、快速做代码盘点,但每个界面的最终质量还是靠人工把关。
团队协作上,建议指定一个人负责维护迁移的规范和工具链,比如统一的提示词模板、统一的验证脚本、统一的主题映射方案。其他人按规范执行,遇到规范覆盖不到的情况再讨论补充。这样能避免每个人用不同的方式迁移,最后代码风格五花八门。
还有一个实际的经验:迁移期间尽量不要同时做大范围的重构。Compose 迁移本身已经改变了很多代码结构,如果再叠加架构调整或者业务重构,出问题的概率会成倍增加。先把迁移做完,稳定一段时间,再考虑其他优化。
我在实际项目里用这套方法迁了大概三十多个界面,整体下来最深的体会是:AI 确实能省很多力气,但它省的是"打字"的力气,不是"思考"的力气。迁移中真正花时间的,是想清楚状态怎么设计、互操作边界怎么划、验证怎么做全面。这些事 AI 帮不上忙,但恰恰是决定迁移成败的关键。把 AI 用在它擅长的地方,把人的精力留给需要判断的地方,这个分工想清楚了,迁移就不会太烧心。