从老的 View 体系迁到 Jetpack Compose,这事说起来就是"换个写法重新画一遍界面",但真在几十万行代码的项目里实施过的人都知道,它远比想象中磨人。布局要重写、状态生命周期要重新对齐、回归测试要重新跑,更别提迁移期间的团队协作——一个分支改不好,整个迭代节奏都会被打乱。后来我参考了 Meta 工程团队分享的 Compose 大规模迁移思路,再结合自己实际落地的经验,总算把"AI 辅助迁移"这套流程跑通了。这篇文章就把 Meta 分享里的核心打法拆开讲清楚:哪些环节该交给自动化、哪些该交给大模型、哪些必须靠人守住,以及我实际操作中踩过的坑和沉淀下来的可复用步骤。如果你正被安排牵头做 Compose 迁移,或者手上有个历史包袱很重的老项目,看完这篇应该能少走不少弯路。
1. 为什么纯手工迁移 Compose 会"烧心"——先认清问题本质
1.1 迁移的本质不是"重写界面"
很多团队接到 Compose 迁移任务,第一反应是"把 layout 文件换成 Kotlin 代码"。这个理解太浅了。Compose 不是一套新的 XML,它是一种完全不同的 UI 编程模型——声明式、基于状态、由可组合函数组合而成。真正要迁移的不是"画界面"的代码,而是"数据和 UI 之间的对应关系"。
传统 View 体系里,界面通常是这样:一个 XML 描述结构,Activity/Fragment 用 findById 拿引用,再用 setText、setVisibility 去改状态。搬到 Compose 之后,你不再"操作控件",而是"声明当某个状态变化时,界面应该长成什么样"。这个差异直接决定了迁移过程中最耗时的环节不是写 UI,而是梳理状态——哪些是页面局部状态,哪些来自 ViewModel,哪些是网络结果,哪些纯属展示性数据。这些在旧代码里可能散落在各个回调里,如今必须被收拢到可组合函数能感知的范围中。
1.2 三个最烧心的典型场景
结合我观察到的团队现状,最容易让人心态崩溃的是下面三个场景。
第一个是纯 XML 重写。一个复杂页面几百行 XML,嵌套着各种 RelativeLayout、weight、include,迁移过来还要保证像素级一致。这种活手工做,一个页面动辄两三天,而且改完基本没法正常 Code Review——diff 太大,reviewer 根本看不下去。
第二个是状态绑定。旧代码里setText(computeSomething())这种写法到处都是,UI 和数据流的因果关系完全靠人肉追踪。一旦开始迁移,很容易出现"界面能显示但状态不同步"的隐性 bug,常规测试还很难覆盖到。
第三个是回归验证。迁移完一个页面,怎么证明它和原来长得一样、行为也一样?光靠肉眼点一遍完全不够。手工迁移到后期,最大的开销反而是回归验证:对比截图、反复操作页面、跟产品确认细节,一轮又一轮。
Meta 在分享里有一个观点我特别认同:迁移最大的风险不是"写不出来",而是"不知道写得对不对"。所以整个迁移方案的设计,必须围绕"可验证"来搭——这个思路贯穿了后面所有环节。
2. 迁移前先做"技术侦察":把项目地图画出来再动手
2.1 模块与页面的迁移优先级评估
我发现很多团队上来就挑一个小页面试水,跑通了就觉得万事大吉,然后从首页开始按顺序一个个迁。这样做的麻烦在于:完全没考虑页面之间的依赖。Compose 上手门槛不高,但跨页面、跨模块的状态共享才是真正的坑。你从首页开始迁,很可能一口气把整个导航栈都拉下水。
Meta 的做法是先把资产盘清楚:把项目里所有 UI 相关代码、布局文件、Fragment 全部列出来,按几个维度打分——复杂度、依赖关系、业务价值、改动风险。这个阶段很像打仗前的侦察,地图画得越清楚,后面行动越少意外。
我自己在项目里用的评分表大概是这样的:
| 维度 | 权重 | 评估内容 |
|---|---|---|
| 代码规模 | 30% | 旧 View/XML 代码行数,衡量重写工作量 |
| 依赖复杂度 | 25% | 依赖的模块数量、共享状态数量、第三方控件介入程度 |
| 业务重要性 | 20% | 页面活跃度、是否处于核心转化链路 |
| 可测试性 | 25% | 有没有现成的 UI 测试或截图基线,便于回归验证 |
这个阶段别纯靠人肉估算,写个小脚本扫一遍代码库,把 Fragment、XML、自定义 View 的分布统计拉出来。数据到手之后,迁移顺序就清楚了——先做"低垂果实"建立信心,再啃硬骨头。所谓低垂果实,就是那些代码简单、依赖干净、业务相对独立的页面,它们能帮团队快速积累 Compose 手感,也能让推进节奏看着不那么吓人。
2.2 迁移顺序的核心逻辑:先打通"管道"
还有一个特别重要的点:不要一上来就指望"把页面全部迁完"。先集中力量把"一个页面能从 View 体系走到 Compose 体系"这条路完全打通。对大多数项目来说,这包括三件事:
- 安全引入 Compose 依赖,最好用独立的 migration module 先跑一个最小闭环;
- 确认新旧代码之间能互相宿主——View 能嵌进 Compose(用 AndroidView),Compose 也能嵌进 View(用 ComposeView);
- 统一导航方案。如果项目里同时存在多个导航栈,迁移期会非常痛苦,建议先收敛到一个入口。
打通管道之后再逐页迁移,每一步都走得稳。反过来,如果迁了十几个页面才发现底层架构不支持混用,那一刻才是真的"烧心"。
3. AI 辅助迁移的完整工作流:静态分析 + 代码改写 + 大模型
3.1 先把重复劳动交给结构化改写工具
很多人一听"AI 迁移",第一反应就是"把代码喂给大模型让它重写"。坦率说,这挺危险的。Meta 的思路是:能用规则化工具解决的,绝不先交给大模型。
结构化代码改写工具(Codemo 这类,基于 AST 做精确变换,不是字符串替换)非常适合处理 Compose 迁移里的机械化部分,比如:
- 把
findViewById加setText的成对调用,改写成状态绑定的雏形; - 把具名布局参数做机械平移;
- 把 Fragment 生命周期里的 UI 初始化代码挪出来;
- 批量替换资源引用、样式 token。
举一个最简单的例子。老的 View 代码里到处是这种写法:
val title = findViewById<TextView>(R.id.title) title.text = viewModel.title这种代码没有一丁点智力含量,但量大、改起来烦、还容易改错。我当时写了个小的改写规则,能自动把它变成调用一个占位函数,等大模型来补充这个组合函数的内部实现。这种**"规则处理确定性的部分 + 大模型处理创造性的部分"**的分工,是我从 Meta 分享里学到的、也是实际收益最大的一点。先把流程跑通,把 AI 的精力留给真正需要理解语义的代码。
3.2 给大模型喂"上下文",而不是喂代码
真正把大模型用起来之后,我最大的发现是:决定 AI 产出质量的关键不是模型本身,而是你喂给它的上下文的组织方式。把一个大文件的全部代码丢过去让它"帮我转成 Compose",大概率会吐出一堆似是而非的东西。
正确做法是分角色、分层次地喂。我实践下来比较顺手的组合是这样:
第一步,让大模型只负责"翻译布局结构"。喂给它 XML 布局文件,明确要求:描述这个界面的层级、控件类型、尺寸约束关系,先在 Compose 里对应的布局组件和 Modifier 是什么。这个阶段不让它生成完整代码,只生成一份"结构说明书"。
第二步,再让大模型负责"梳理状态绑定"。喂给它 ViewModel 和旧的 Activity/Fragment 代码,让它输出:这个页面有哪些状态、哪些是单向数据流、哪些事件需要回调。同样先给文字描述,把状态边界理清楚。
第三步,把前两步产出的"结构说明书"和"状态说明书"合并,再让它生成完整的 Composable 函数。
为什么要拆开?因为人脑在迁移时也是这么工作的:先看明白界面结构,再看明白数据流,最后才动手写。而且拆开之后每一份上下文都很短,模型的幻觉率明显下降。我在团队里测过:直接从完整代码一次性转换,成功率不到一半;拆成两步走之后,产出的可用率能到八成以上。
3.3 一个直接能用的提示词模板
把我在实际用的提示词结构分享出来,你稍微改改就能套:
任务:把下面的 XML 布局转换成 Jetpack Compose 代码。 约束: 1. 只做结构翻译,不考虑 ViewModel 状态绑定。 2. 所有尺寸用 dp 表示,颜色和字符串保持原资源引用。 3. XML 中每个标签都要有对应说明,不许省略。 4. 遇到自定义 View,用 AndroidView 包裹,并标出建议后续重构点。 输入 XML: <RelativeLayout ...>...</RelativeLayout>状态绑定单独开一轮对话,把 ViewModel 结构、Activity 里的状态更新逻辑、事件监听代码贴进去,先让模型回答"这个页面的状态模型是什么",再让它基于上一步生成的结构代码补状态绑定。
这里有一个很实操的提醒:不要在同一个对话里来回切换两个任务。大模型的注意力是有限的,你一会儿让它翻译布局,一会儿让它改状态,最后很容易产出两者都不彻底的代码。宁可多开几个对话,每个对话只做一件事。
3.4 AI 产出之后的校验闭环
AI 生成的代码,直接合进仓库是绝对不行的。Meta 的流程里有一个关键环节:校验闭环。AI 代码至少要过四道关卡:
- 编译通过是最基本的。很多模型生成的代码在 Kotlin 类型层面就过不去,这一步能刷掉大量低级错误。
- Lint 和静态检查。如果团队有自定义 lint 规则,也要在这个环节跑起来。
- 单元测试加现有 UI 测试跑一遍,确保没有明显行为回归。
- 截图对比。我强烈建议在测试基建还不完善时,先把截图对比搭起来:同一组测试场景,分别渲染旧 View 页面和新 Compose 页面,做像素级 diff,能接受再进人工 review。
我实际还加了一步:把编译错误和 lint 告警贴回给大模型,让它自己修正第一版代码。这个"AI 自纠错"的循环通常跑两三轮,产出质量会有肉眼可见的提升。别小看这一步——相当于把编译器的反馈变成了模型的修正信号,比人工一行行盯效率高得多。
4. View 与 Compose 共存期的互操作细节——别让项目变成"半新半旧的烂摊子"
4.1 共存架构的三种选择
迁移不可能一蹴而就,绝大多数项目都有一段不短的共存期。我见过三种共存模式:
- 页面级隔离:Compose 代码作为独立页面存在,通过导航层和旧页面打通。隔离性好,缺点是页面级协作时要反复跨边界传参。
- 页面内混用:同一个页面里,部分区域用 Compose,部分用 View。最灵活,但要非常小心生命周期和事件传递的边界。
- Compose 为主 + 旧控件兜底:把实在迁不了的旧控件用 AndroidView 包起来应急。这是迁移后期最常见的形态。
Meta 工程团队分享时强调的思路是"渐进式、可逆式"迁移——每一步都能单独上线,每一步都不影响整体质量。我实际做下来,页面内混用虽然灵活,但对团队要求最高,建议只在特定场景使用。最稳的还是页面级切换:一个页面整体迁完、验证完、上线,再动下一个。
4.2 ViewCompositionStrategy 是高频踩坑点
混合使用阶段最容易出现的问题,就是"Compose 嵌进 View 容器后不刷新"或者"内存泄漏"。十有八九和 ViewCompositionStrategy 有关。
很多团队习惯了在 Fragment 的 onCreateView 里直接写:
ComposeView(requireContext()).apply { setContent { MyScreen() } }这样写如果不设置 ViewCompositionStrategy,Compose 会把整个 Window 当成组合的生命周期锚点,页面销毁时组合不会跟着销毁,内存泄漏随之而来;状态更新时还可能出现"界面不刷新"的诡异现象。
正确写法是:
val composeView = ComposeView(requireContext()).apply { setViewCompositionStrategy( ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed ) setContent { MyScreen() } }DisposeOnViewTreeLifecycleDestroyed的意思是:当 ComposeView 所在的 ViewTree 生命周期结束时,组合资源一起释放。这个策略和 Fragment 的生命周期对齐得最好。
我在实际项目里就吃过一次亏:一个聊天页面迁移完,用户反馈"返回再进来,列表不刷新"。排查了很久,最后发现就是 ComposeView 挂在了旧的 View 容器里,组合释放策略没设置,旧组合状态一直残留。加上清理策略之后,问题立刻消失。这个坑非常典型,建议所有从 View 体系迁移过来的团队第一件事就是检查这行配置。
4.3 ViewModel 怎么共享:别把它拆散
迁移过程里有一个很大的诱惑:既然页面迁成 Compose,ViewModel 干脆也改成 Compose 风格。我的建议是:迁移初期,ViewModel 一个都别动。
ViewModel 是介于 UI 和业务之间的一层,它本身并不关心 UI 是 View 还是 Compose。如果你把一个 ViewModel 重构成 Compose 专属的写法,而旧页面可能还在用、新页面也马上要用,就意味着两套 UI 要维护两套状态逻辑,迁移工作量和风险同时放大。
正确做法是:保持 ViewModel 完全不变,无论是旧页面还是新页面都持有同一个实例。Compose 侧用collectAsStateWithLifecycle或者 transform 后的 State 来接住它。等所有页面都迁完,再回头统一整理 ViewModel 的暴露方式。这样既安全又干净。
我们当时就是这么做下来的:一整层 ViewModel 在迁移期间零改动,页面却迁了四十多个。后来不少做迁移的同事都说,这个决策帮他们省掉了大量不该有的麻烦。
5. 最容易翻车的三类问题——我先帮你踩过了
5.1 重组性能坑:别让"状态观察"变成"整页重建"
迁移后用户感知最强的就是性能问题,这也是 Code Review 阶段最容易漏掉的。旧 View 体系里,你 setText 一个 TextView,系统只会重绘那一个控件。Compose 里,如果你在可组合函数里观察了一个大的状态对象,任何微小变化都可能触发整个函数体重组。
最典型的反例是整页所有子组件都在读同一个 ViewModel 的 State:
@Composable fun ChatScreen(viewModel: ChatViewModel) { val state by viewModel.uiState.collectAsStateWithLifecycle() Header(state.headerInfo) MessageList(state.messages) InputBar(state.inputText) }消息列表一更新,Header 和 InputBar 全都跟着重组。Compose 会尝试智能跳过一部分,但如果状态粒度过粗,性能损耗照样肉眼可见。
我的建议很直接:迁移完成后第一件事不是优化代码,而是把整个页面所有可组合函数的"状态读取范围"重新梳理一遍。原则只有一个——谁用到状态的哪一部分,就在离它最近的层级去读取。用derivedStateOf、Lambda 化回调、稳定类型等手段,把重组范围压到最小。
跑性能测试时重点看两个指标:帧时长和重组次数。如果发现一个简单的状态变化引发了几百次重组,基本就可以断定状态粒度有问题,别急着骂 Compose 慢。
5.2 主题与资源差异:像素一致没那么简单
迁移完成后最常见的视觉 bug 是"颜色对不上""字体大小变了一点""间距缩了一个 dp"。这些细小差异来自 View 和 Compose 两套资源体系的不同默认值。
Android 的 View 体系用了大量系统默认值:默认字体、默认背景、最小触摸目标、textSize 默认值等等。而 Compose 的 Material 主题自带一套完全不同的排版和配色体系。光把 XML 里的数值搬过来远远不够,还得把系统级的差异补上。
这里有几个实操中很容易漏掉的点:
- 主题映射:旧 Activity 用的 Theme 是哪个,Compose 侧有没有对应到 MaterialTheme 的配色;
- dimens 值在做 dp 转换时有没有发生四舍五入;
- 自定义字体有没有在 Compose 里显式传递;
- XML 的 shape drawable 背景,要转换成
Modifier.background加 shape; - 旧页面常用的 selector 按压反馈,Compose 里要处理 indication。
这些点单独看都不大,但叠加起来就是一个页面"看起来就是不一样"。好在截图 diff 工具会让差异暴露得很明显。之前提的"结构说明书"这一步就派上用场了——你可以让大模型在产出代码的同时,附一份"与原 XML 的差异清单",人工照着过一遍,遗漏会少很多。
5.3 手势、滚动、可访问性行为不一致
这部分最容易在测试时漏掉、上线后被用户发现。旧页面可能有滚动到底自动加载、下拉刷新、侧滑返回、长按弹出菜单等交互习惯,迁移后如果没有准确带过去,产品体验会突然"降级"。
Compose 的手势处理在 Modifier 层面:pointerInput、draggable、anchoredDraggable,和 View 体系里 TouchEvent 的写法完全不同。大模型生成的代码在这一块尤其容易出问题——它能产出看起来合理的 Modifier,但真实手势行为往往和原来大相径庭。
我的经验是:交互复杂的手势逻辑不能指望模型一次到位。还有容易被忽略的是可访问性。Compose 的语义系统独立于视觉层,TalkBack 读屏和 View 体系的可访问行为并不天然一致。很多团队迁移后才发现读屏把按钮顺序读乱了、焦点顺序也变了。
这类问题最有效的防线是两样:自动化可访问性测试,以及真实用户反馈群。迁移后的页面先灰度给一小波用户,观察有没有异常操作路径,再决定要不要全量放量。灰度期拉长一些,宁可慢,也不要把交互习惯在无声无息中改坏。
6. 上线与验证的节奏:让"迁移"这件事本身可观测
6.1 截图测试和 UI 测试基线怎么铺
前面反复提到过截图对比,这里单独讲讲落地方式。
最省事且可靠的方案,是给每个待迁移页面准备一组固定测试场景:空数据、默认数据、长文本、错误态、夜间模式,等等。迁移前用旧代码跑出基准截图;迁移后用新代码跑同样场景,做像素级 diff。
Android 上做这个可以用 Roborazzi 这类工具,配好后在 CI 里自动执行。diff 阈值要设得严格一些,但也要容忍 1-2dp 的合理偏差——毕竟 Compose 的渲染管线和 View 不完全一样,追求"完全一致"在理论上不现实。关键是每一个 diff 都要可解释:要么人工确认是"新体系本身特性导致的差异,可接受",要么被打回重新改。
我踩过的一个坑是:截图测试的夜间模式没纳入自动场景,结果一批页面迁移后在深色模式下颜色全乱。后来把所有 theme 场景全部加进去,这类问题基本绝迹。记住,截图测试场景设计得好坏,直接决定这条防线能拦住多少问题。
6.2 灰度方式与监控指标
页面迁移完毕,不建议立刻全量。我们用的灰度方式是:每个页面迁移完,先放到独立构建渠道里跑内测,让团队里非该模块的同事当"小白鼠"随手点一点;然后扩大到某个用户分组的 A/B 实验,观察核心指标。
监控指标至少要覆盖五类:崩溃率、帧时长、卡顿率(Slow Frame Ratio)、核心页面停留时长、用户反馈关键词。如果"页面停留时间下降"和"卡顿率上升"同时出现,几乎可以断定是迁移导致的体验回退,立刻回滚。
还有一个特别想提醒的点:不要把迁移和别的功能改动塞进同一次发版。我见过太多团队踩这个坑——一个版本里既改了迁移又加新功能,出了问题根本定位不到是谁的责任。一次发版只做一件事,迁移就是纯迁移。
6.3 回滚预案:别等出事了再想
很多初做迁移的团队没有意识到:Compose 页面一旦上线,回滚并不是简单的"把代码还原"。
如果你迁移时把旧 View 页面删掉了,回滚就意味着重新恢复一整套旧代码;如果还改了资源引用和模块依赖,回滚时要小心的连带改动会更多。我的建议是:迁移的每个阶段都保留一份"回滚包",也就是该页面迁移前的 View 版本,必须能在五分钟内切换回来。
这个做起来其实很简单:旧代码不要急着删除,而是通过一个开关控制路由。例如在导航层维护一个"该页面是否走 Compose"的配置,默认走新代码,出问题一键切回旧页面。等新页面稳定运行一个版本周期之后,再彻底清理旧代码。
这个开关在灰度阶段尤其有用。有一次我们灰度一个核心页面,出现了一个只在特定机型上触发的问题,靠这个开关当天就把用户切回了旧页面,影响范围被压到了极小。如果没有这个预案,那次的反馈压力很可能把整个迁移项目叫停。
7. 收个尾:迁移这件事真正难在哪儿
把 Meta 分享的思路和自己的实践结合之后,我最大的感触是:Compose 迁移这个事,真正的难点从来不是 Compose 语法本身,而是如何在一个持续迭代的项目里,做到可验证、可回滚、可控风险的渐进式迁移。AI 在其中扮演的角色,与其说是"自动写代码的机器",不如说是"效率放大器"——它把我们从大量机械化的重写中解放出来,让我们把精力留给真正需要判断力的地方。
如果你正准备启动类似的迁移,我给三个最直接的提醒。第一,迁移前先把验证手段搭好,哪怕只是一个简单的截图工具,也比到上线前再补强得多。第二,把 AI 当"实习生"使:给它清晰的任务拆解和上下文,它的产出必须经过编译、测试、人工三道检查。第三,永远留好回滚退路,不要为了让进度好看就让风险裸奔。
等迁移完成后再回头看,你会发现整个团队的 Compose 能力也跟着上了一个台阶——因为所有人都不只是在抄代码,而是在理解为什么这样写。这大概就是"不烧心"迁移的正确打开方式吧。