Jetpack Compose成熟度评估与落地实践:状态管理、重组与性能优化
2026/9/8 14:06:18 网站建设 项目流程

1. 现状观察:Compose到底发展到哪一步了

聊到Android Compose,圈里人的态度大致可以分成两拨:一拨觉得“这就是Android的未来,赶紧梭哈”,另一拨还在观望,担心迁移成本高、性能兜不住底。我在一线写了十来年Android,从View体系一路用到Compose,这几年也带过几个完全用Compose从零搭建的项目,也做过老项目渐进式混改。今天这篇不说那些官方文档里抄得到的话,就从一个落地的角度,聊聊Compose在当前这个时间点到底成不成熟,哪些场景可以放心用,哪些地方还得留个心眼。

先说个结论:截止到目前,Compose在纯新项目里已经完全具备上生产的能力,在老项目渐进式接入里也基本可行,但前提是你得接受它那套完全不同于View体系的思维方式,并且团队里至少有一个人能搞定Compose的底层原理——尤其是重组机制和生命周期。如果你拿Compose当“XML换了个写法”,那大概率会踩坑踩到怀疑人生。

先看一下Compose现在所处的位置。Jetpack Compose从2021年7月发布1.0版本,到现在已经迭代到了1.5.x甚至1.6.x,API已经基本稳定。Google自家的应用——比如Play Store、Maps、Home Control这些——都已经在生产环境里大规模用了。这个信号其实比任何技术博客都有说服力:连Google都在自己最核心的产品里用Compose,说明它已经过了“实验品”的阶段。

但“基本稳定”不等于“处处顺手”。我见过不少团队,用了Compose大半年之后又改回View体系的,原因无外乎是几个:性能瓶颈暴露在复杂列表上、团队学习成本太高、三方库兼容性滞后、以及Compose那套状态管理在多人协作时特别容易写出烂代码。这些坑我都踩过,后文逐个说,顺便把解法也聊透。

1.1 一个技术到底算不算成熟,得看这四件事

判断一个UI框架是否成熟,我自己的标准有四个维度,不吹不黑,挨个拿出来看Compose的表现。

第一是API的稳定性。API三天两头变,谁都不敢在它上面盖楼。Compose在1.0之后,大部分核心API都稳定下来了,但一些高频API还是有变动,比如Modifier的排列顺序、LazyColumn的item key策略、以及rememberCoroutineScope的使用场景,在1.x版本之间有过调整。我的建议是:锁版本,别追新。项目里明确固定Compose版本,升级时要单独排期做回归测试,不要跟着Android Studio的提示顺手就点了升级。

第二是工具链的完善度。开发一个UI框架,光有框架本身还不够,调试工具、预览工具、性能分析工具都得跟上。Android Studio对Compose的支持从北极狐版本开始逐步完善,到现在已经相当好用了——布局检查器能看到重组次数和跳过次数,Compose Preview也支持交互模式。但说实话,和View体系的成熟工具链比,Compose的调试体验还是差那么一点,尤其是遇到重组逻辑异常时,定位问题比View体系难不少,后面我会专门讲这个。

第三是生态的适配度。这个维度Compose处于一个“基本能用,偶有缺口”的状态。主流的图片库(Coil、Glide)都有Compose适配,网络库(Retrofit、OkHttp)本来就和UI无关,无所谓适不适配。但一些偏门的三方SDK——比如地图、直播、图表类的——在Compose里的支持就参差不齐了,经常得用AndroidView去包一层传统的View实现。这种“包一层”的写法短期内能解决问题,但会破坏Compose统一的声明式风格,而且埋下性能隐患。

第四是社区经验的沉淀。这个维度Compose已经积累了足够多的坑和解决方案,国内外博客随手一搜都是。但从另一个角度看,社区里也存在大量“半懂不懂”的文章,把StateFlowLaunchedEffect这些东西讲得云里雾里,反而误导了一批初学者。这也是我觉得有必要写这篇文章的原因——有些东西,得把原理说透了,你才不会被那些“看似讲得很深、实则没说到点子上”的文章带偏。

2. 生态与兼容性的真实战况

2.1 兼容性:Compose能不能无缝接进老项目?能,但有条件

很多团队最关心的问题是:现有的View体系代码,要不要推倒重来?答案很明确:不需要,也不建议。Compose和View体系不是二选一,而是可以共存的。Google在设计Compose时就考虑到了渐进式迁移的场景,所以提供了ComposeView这个容器——在XML布局里放一个ComposeView,就可以把Compose UI嵌入到传统View体系中。反过来,在Compose的代码里也可以通过AndroidView把传统View嵌进来。这种双向兼容的能力,是Compose成熟度里最被低估的一个点。

我在实际项目里用的混合方案是这样的:新写的页面优先用Compose,老页面保持View体系不动,只有当某个老页面需要大改时,才顺手迁移成Compose。比如去年我接手的一个电商项目,购物车页面原本是RecyclerView+多类型item的老写法,改动需求频繁、代码越来越难维护,我直接重构成了Compose + LazyColumn,效果立竿见影。而像订单详情这种改动少的页面,就没必要动它,留着View体系也没任何问题。

具体操作上有一个注意点:Compose和View体系间的数据流转,尽量用ViewModel来承载。Compose里不要直接持有Android View的引用,View体系里也不要直接读Compose的State,两边各写各的,通过ViewModel统一通信。这样混改期间架构最干净,后面想继续迁移也容易。

2.2AndroidView包裹三方SDK的几个坑

说个实际的。我们项目里用过一个第三方的直播SDK,它只提供传统View的接口,比如一个叫LivePlayerView的类,继承自FrameLayout。在Compose里要集成它,标准做法就是套一层AndroidView

@Composable fun LivePlayerScreen(url: String) { val playerView = remember { LivePlayerView(context).apply { setUrl(url) startPlay() } } AndroidView( factory = { playerView }, modifier = Modifier.fillMaxSize() ) }

看起来很简单对吧?但实际用起来有几个坑:

第一个坑是生命周期绑定AndroidView里的View不会自动感知Compose的生命周期,你得自己在DisposableEffect里手动管理:

@Composable fun LivePlayerScreen(url: String) { val context = LocalContext.current val playerView = remember { LivePlayerView(context) } DisposableEffect(Unit) { playerView.setUrl(url) playerView.startPlay() onDispose { playerView.stopPlay() playerView.release() } } AndroidView( factory = { playerView }, modifier = Modifier.fillMaxSize() ) }

不写这个DisposableEffect的后果是:页面退出后播放器还在跑,声音还在响,内存也释放不了。这是Compose混用传统View时最容易翻车的点。

第二个坑是更新时机。传统View经常有类似setData(data: List<Item>)的方法,在View体系里调用很自然。但放到Compose里,你不能在重组时直接调用它,因为重组会跳过或者重复执行,导致数据被重复设置。正确做法是把这类调用放进LaunchedEffect或者SideEffect里,根据key的变化来触发更新:

@Composable fun LivePlayerScreen(url: String) { val playerView = remember { LivePlayerView(LocalContext.current) } LaunchedEffect(url) { playerView.setUrl(url) playerView.startPlay() } AndroidView( factory = { playerView }, modifier = Modifier.fillMaxSize() ) }

2.3 团队切Compose的学习曲线问题

技术选型不只是技术问题,更是团队管理问题。Compose的编程范式和View体系差异极大,从“命令式”到“声明式”的转变,这个坎不是所有人都能轻松迈过去的。

我在内部推Compose时,统计过团队的学习成本:一个熟悉Android但没接触过声明式UI的工程师,大约需要两到三周才能独立写页面,一个多月才能写出符合Compose惯用法的代码。这里说的“符合惯用法”,不是你实现了功能就行,而是你的状态管理、重组设计、Modifier使用都是规范的,不会写出那种“能跑但性能稀烂”的代码。

最容易让新手翻车的地方有几个。一个是状态提升,该提升的不提升,导致状态存在某个无关的Composable里,一改数据整个页面重组;一个是rememberrememberSaveable的滥用,为了省事全部无脑套remember,结果数据存不住、状态错乱;还有就是不理解LazyColumn的重组机制,把所有item都写成一个巨大的Composable,导致滑动时疯狂重组、帧率直线下降。

我的建议是,团队推Compose之前,先找一两个人做技术预研,把常用的组件库封装好,沉淀出一套内部的Compose风格规范,再铺开给所有人。不要一上来就让全员直接写Compose——没人带的情况下,三个月后会写出一堆没法维护的代码,然后你就会听到“Compose只是个玩具”之类的结论。

3. 性能账本:重组与监听的“军备竞赛”

3.1 Compose的三大心脏:State、重组与remember

想用好Compose,先得搞清楚它最底层的运行机制。Compose的核心是“声明式UI”:你描述的是UI在不同状态下的样子,状态一变化,UI自动更新。这个“自动更新”的底层就是重组——Compose会重新执行发生过变化的Composable函数,生成新的UI。

但这里有个关键概念:重组不是全量重跑,而是“跳过没变的,只重跑那个变了的”。Compose通过“位置记忆”来跟踪每个Composable的状态,如果某个Composable读取的状态没有变,它就不会重新执行。这个机制设计得很好,但如果你的代码不规范,它就会退化成“页面一抖,全量重组”,性能直接崩盘。

remember的作用是在重组时保留数据,避免初始化和计算逻辑重复执行。举个例子:

@Composable fun ExpensiveList() { val data = remember { loadExpensiveData() } // 只会在首次进入时加载 LazyColumn { items(data) { item -> Text(item.title) } } }

如果去掉那个remember,每次重组都会重新调用loadExpensiveData(),用户滑个列表,转场调个色,都会触发这个昂贵的加载逻辑。这属于最基础的优化,但越是基础越容易犯。

3.2 “逆行依赖”与“定向重组”:为什么我用Compose重构后更卡了

很多团队从View切到Compose后,第一个直观感受就是:怎么滑动列表比以前卡了?我以前也困惑过这个问题,后来仔细排查才发现,原因往往出在“状态放错了位置”。

来看一个反面例子。这是一个常见的错误写法:

@Composable fun MessageList(messages: List<Message>) { Column { var expanded by remember { mutableStateOf(false) } MessageListItem(messages, expanded) // 注意:expanded传给子节点 } }

当你切换expanded时,理论上只有当前页面需要重组。但因为MessageList读取了expanded状态,而messages也是它的参数,导致整个列表——包括所有item——全部重新组合了一遍。这在列表较长时会非常明显:切换一个展开动画,整个列表都跟着闪一下。

正确的做法是“状态下沉”——让状态只存在于需要它改变的最小范围内。比如这个例子,应该把expanded放进MessageListItem内部,或者直接把状态声明在item的那一层:

@Composable fun MessageList(messages: List<Message>) { LazyColumn { items(messages, key = { it.id }) { message -> MessageListItem(message) } } } @Composable fun MessageListItem(message: Message) { var expanded by remember { mutableStateOf(false) } // 只影响当前item }

在动手重构之前,先做一次全项目的状态审计:查清楚哪些状态放错了层级、哪些Composable被无谓地重复组合。跳过这一步,你就连“为什么我的Compose那么卡”这个问题的答案都找不到。

3.3 稳定性标记与@Stable/@Immutable:进阶优化的必修课

到了Compose性能优化的深水区,就绕不开“稳定性”(Stability)这个概念。你可以把它理解成Compose编译器的一个判断:这个参数在重组时到底变了没有?如果编译器能确认你没变,它就会跳过这次重组;如果确认不了,它会“宁可信其有,不可信其无”——每次都重组,保证不会出bug。

这个判断机制很关键,但默认情况下它比较保守。比如你定义了一个普通的数据类:

data class User(val name: String, val age: Int)

编译器会认为它“可能没变”,因为它无法确认这个类的属性在某次状态更新后是否保持原样。于是每次父级重组,它都会把这个User重新传给子级Composable,子级全部跟着重组。这在列表场景下会导致滑动时所有item都重组——明明数据没变,UI却全部重画一遍。

解决方案是加稳定性标记:

@Immutable data class User(val name: String, val age: Int) // 或者 @Stable data class User(val name: String, val age: Int) { var isFollowing by mutableStateOf(false) }

@Immutable告诉编译器这个类永远不会变,@Stable告诉编译器我有部分字段是稳定的,你可以智能地跳过重组。这两个注解用好了,复杂列表的性能会有质的提升。

注意一个坑:只有当你确定这个类的所有属性真的不变时才用@Immutable,否则会有隐藏bug——UI不更新,你排查半天都找不到原因。稳妥起见,用@Stable比用@Immutable更安全。

3.4LazyColumn的key:不写key,滑动列表就会“花”

LazyColumn是Compose里的列表组件,对标View体系里的RecyclerView。RecyclerView有一个DiffUtil机制自动计算item差异,而LazyColumn的差异计算能力则弱得多——它默认按位置对比,不写key就会出现“数据错位”的诡异问题。

举个我踩过的坑。当时做一个聊天列表,item里有个“已读/未读”的状态:

@Composable fun ChatList(messages: List<Message>) { LazyColumn { items(messages) { message -> ChatItem(message) } } }

当用户在某个item上触发“已读”操作时,那个item的数据变了,但因为没写key,LazyColumn按位置对比,发现位置没变,就不刷新UI。结果用户点了一下已读,界面纹丝不动,但没有key的新数据插入时又会导致整个列表错乱。

正确写法是明确指定key:

items(messages, key = { it.messageId }) { message -> ChatItem(message) }

有了key之后,LazyColumn才能准确定位到“哪一条item变了”,组合逻辑才能在正确的地方触发更新。这个key的选择也有讲究,最好用业务上的唯一ID,不要用index或者随机数。

4. 状态管理与架构设计:代码能不能撑住三年

4.1 单向数据流:别把State传得满天飞

Compose的状态管理并不难,难的是在多人协作的场景下保持一致。我在项目里看过最糟糕的Compose代码,是一个页面上有十几个remember,每个Composable都自己管理状态,互相之间通过回调函数通信,读代码的人完全分不清谁是谁的父节点、谁在什么时候改了什么数据。

这种情况和View体系时代“Activity写了一坨集合了UI状态和业务逻辑”的“上帝类”,本质上是同一个问题:状态管理没有边界,业务逻辑和UI逻辑混在一起。

正确的Compose状态管理应该是这样的:

  • ViewModel里用StateFlowState持有UI状态,业务数据只管往ViewModel里扔
  • UI层通过collectAsStateWithLifecycle()收集状态,只做一件事:把状态渲染成UI
  • 用户操作通过回调/事件传给ViewModel,ViewModel处理后更新状态

代码看起来就是这样的结构:

class MyViewModel : ViewModel() { private val _uiState = MutableStateFlow(MyUiState()) val uiState: StateFlow<MyUiState> = _uiState.asStateFlow() fun onButtonClick() { _uiState.update { it.copy(count = it.count + 1) } } } @Composable fun MyScreen(viewModel: MyViewModel = viewModel()) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() MyContent( uiState = uiState, onButtonClick = viewModel::onButtonClick ) }

这种单向数据流的好处是:状态有且只有一个“真相来源”(ViewModel),UI只是状态的投影,逻辑清晰,测试也好写。团队里不管谁来看代码,都能很快理清“数据从哪里来、要到哪里去”。

4.2collectAsStateWithLifecycle():一个必须养成的习惯

上面代码里我写的是collectAsStateWithLifecycle(),这个细节值得单独说一句。在早期Compose版本里,很多人用的是collectAsState()。两者最核心的区别在于:collectAsState()不会感知生命周期,就算App退到后台,它也会继续收集Flow里的数据,不仅浪费资源,还可能在后台触发UI更新导致崩溃。

collectAsStateWithLifecycle()是lifecycle-runtime-compose库提供的方法,它只在STARTED及以上状态收集数据,App进后台就自动停止收集,回到前台再恢复。这个差异在做过性能优化的人眼中简直是天上地下。用它应该像用LaunchedEffect一样自然,想都不用想。

我当时在项目里全面替换掉collectAsState()之前,电量和性能本来是困扰我们很久的问题,替换后明显改善——这就是生命周期感知的重要性。

4.3 延续View体系里的MVVM,要不要引入Flow

在Compose项目的架构选型上,有一条经常被问起:在Compose里用Flow合适,还是用MutableState合适?这两个我都用过,说下我的体会。

MutableState是Compose原生的状态容器,最大的特点是和Compose的读写追踪无缝配合,性能极好,适合“轻量级、即时性”的状态,比如用户的输入内容、下拉刷新标志等。但它不适合做跨页面共享的状态——你在两个页面间硬要传一个MutableState,就约等于制造了一个隐式的全局变量,代码一旦复杂起来就非常难看。

Flow则更适合承载“异步、事件流、数据源”类的状态,比如网络请求的结果、本地数据库的监听。它的操作符丰富,可以做mapfilterdebounce等操作,这是MutableState做不到的。把它和StateFlow结合,配合collectAsStateWithLifecycle(),在ViewModel里作为“真相来源”,是目前最主流的方案。

我的个人习惯是:页面内部、临时状态用MutableState,跨页面共享、异步数据用Flow/StateFlow。两者不是二选一,而是在不同场景下各司其职。

4.4rememberrememberSaveable:不要把“临时”和“持久”混为一谈

有次和同事做Code Review,看到他在一个登录表单页里用了remember来存用户输入的账号:

@Composable fun LoginScreen() { var username by remember { mutableStateOf("") } var password by remember { mutableStateOf("") } // ... }

这个写法的bug是:当用户切到后台,进程被系统回收,或者屏幕旋转后,账号密码就全丢了。用户可能已经填了半天的表单,一个转向电话或者横竖屏切换,全都白填了,体验非常糟糕。

如果还想维持合理的状态恢复体验,就应该用rememberSaveable——它会把状态保存进Bundle里,在Activity重建时恢复:

var username by rememberSaveable { mutableStateOf("") } var password by rememberSaveable { mutableStateOf("") }

能用rememberSaveable的地方就尽量用,它比remember多一个自动保存恢复的能力,成本几乎为零,却能在关键时刻救用户体验一把。当然,rememberSaveable只能存Bundle支持的类型,如果状态里有自定义对象,要么实现Parcelable/Serializable,要么用listSaver/mapSaver去转换。实在复杂的,就别省事了,放ViewModel里用SavedStateHandle。

5. 工具链与生态横向对比:Compose够不够“趁手”

5.1 Android Studio的Compose支持:多年的“亲儿子”待遇

工具链是衡量一个框架成熟度的硬指标。Android Studio对Compose的支持,从北极狐版本开始逐步加强,到目前的海豚、Electric Eel等版本,已经相当完善。

布局预览是Compose开发里我最依赖的功能之一。View体系的XML预览只能静态展示,Compose Preview则支持交互模式——你可以直接在预览里点击按钮、输入文字,实打实地操作UI,这在调试UI交互时能省下大量的构建和运行时间。多个不同状态的Preview用@Preview注解配合参数就能展示,一个列表的加载态、空态、有数据态可以并排放在一起看,一眼就能发现布局问题。

布局检查器是Compose调试的另一个杀手锏。在View体系时代,想看一个View为什么高度不对、margin为什么没生效,得一层层翻XML和代码。Compose的布局检查器里,你能直接看到每个Composable的边界、尺寸、padding,还能看到重组次数——那些“我明明没改数据,为什么这里还在重组”的问题,打开布局检查器一看就明白了。

不过说实话,Compose的工具链虽然“亲儿子”待遇拉满,但仍然有不如View体系的地方。比如复杂动画的调试,View体系有专门的动画预览工具,Compose目前只能靠肉眼看和录屏慢放。还有Lint检查,Compose的Lint规则比View体系少得多,很多性能隐患得靠开发者自己经验去发现。

5.2 编译期与运行时的优化:“打包时”和“运行时”两把尺子量到底

很多人担心Compose的编译时间和包体增量,我也实测过。一个中等规模的项目(大约50个页面),使用Compose后,冷启动的编译时间大约增加了15%到25%,全量构建大约增加几十秒。增量构建的影响相对较小,因为Compose编译器插件会做增量处理,但第一次全量构建确实会让人多等一会儿。

包体方面,Compose及其相关的库会增加大约4MB到6MB的体积(未压缩后大约9MB左右)。如果你的App对包体特别敏感——比如有些应用商店限制包体大小——这个增量是需要认真考虑的。但对大多数App来说,多几个MB换来的开发效率和可维护性,我个人认为是值得的。

运行时性能上,Compose本身并不慢。它的底层用了一个叫“组合线程(Compose Runtime)”的机制,把UI描述编译成一种高效的字节码指令,执行效率并不比传统的View绘制差。在正确使用@Stable/@Immutable/remember的前提下,Compose完全可以做到流畅的60fps甚至120fps。

但**“正确使用”这四个字是门槛**。View体系下,即使你写得再烂,RecyclerView的ViewHolder机制也能兜住一部分性能底线。而在Compose里,一个状态放错位置、一个@Stable标记缺失,就能让列表卡成PPT。它把性能优化的责任从框架转移到了开发者的手里——这既是好事(上限更高),也是坏事(下限更低)。

5.3 与Flutter、RN的横向视角:同为声明式,手感不太一样

因为项目经历过React Native、Flutter,再加上现在的Compose,我有机会横向比较几个主流的跨端/声明式框架,说几点直观感受。

Flutter的声明式做得非常彻底,整个渲染引擎都是自己的,性能一致性极好,“写一遍到处跑”体验最好。但它最大的问题是“住在自己的岛上”——它和Android原生的交互没有Compose这么顺滑,嵌入原生View或者被原生View嵌入都需要额外的channel桥接,而且Dart语言本身也是团队学习的一个门槛。

React Native这么多年了,生态依然是最丰富的,前端有大量成熟的库可以直接用。但在Android平台的性能一致性上,RN它还需要通过Bridge(现在是Fabric)和原生通信,复杂动画和高频操作下容易丢帧。而且RN写UI时仍然是“JS对象到原生组件”的映射,而不是像Compose/Flutter那样把渲染的关键环节握在自己手里。

Compose的好处在于它“生在Android,活在Android”,和Android生态的整合是最好的:可以无缝调用任何原生API,三方SDK用AndroidView包一下就行,不需要额外的桥接层。坏处也很明显——它没法跨端,iOS只能考虑Compose Multiplatform,但据我之前看过的资料和实验,Compose Multiplatform的成熟度目前还属于“能跑但别轻易上生产”的状态。

所以我的判断:如果你的项目是纯Android端,Compose是长期最优解;如果要跨端,Flutter依然是更稳的选择;RN则适合那种已经以Web技术栈为核心的团队。没有绝对的“最好”,只有“当前条件下最合适”。

5.4 我试过的Compose门下的性能瓶颈定位工具

前面说过Compose的调试工具不如View体系成熟,但这几年下来也积累了几个比较实用的定位手段,分享出来供参考。

手段一:布局检查器看重组次数。Android Studio的Layout Inspector可以实时显示每个Composable的重组次数。如果发现某个列表项的Composable重组次数异常高(比如每帧都在跳动),那基本可以确定有状态被无谓地共享了。

手段二:@Preview配合不同状态排查UI问题。以前在View体系里调UI,遇到问题得改完代码重新build、跑起来、手动触发状态,预览时还很容易遗漏异常状态。Compose Preview可以给同一个UI组件定义多组Preview参数,直接并行预览正常、空、加载、错误等状态,能在编码阶段就发现很多布局问题。

手段三:开启Compose编译器的“强模式”。在Compose编译器的gradle配置里,有个strongSkippingMode(不同版本名称略有差异),开启后编译器会做更多的跳过优化,这个模式下某些性能问题会自动消失。但它不是银弹,而且用了它后,有些原本依赖“人类惰性写法”的代码会被编译器当“不稳定”处理导致更多重组,所以开启前要评估好。

手段四:二分法和注释法。这是最笨但最有效的方式。当遇到“某个界面滑动卡顿”的问题,可以先把页面里的组件逐个注释掉,跑一遍确认卡顿是否消失,然后逐步缩小范围。性能问题往往不是由一个点引起的,但“定位到一个最可疑的点”比“一次全排查一遍”效率高得多。这个方法论在所有UI框架里都适用。

6. 团队实战:从接盘到上线的Compose经验

6.1 什么阶段什么项目适合切Compose:不盲从,不保守

关于“什么时候可以切Compose”,我给团队的建议是:按阶段来,别走极端

第一阶段的“探路期”,最适合的是一个中低复杂度、没有太多历史包袱的新页面。比如一个设置页、一个简单的详情页,数据量小、交互简单、没有太复杂的三方SDK。在这个页面里练手,把Compose的基本流程跑通,团队里可以建立初步的写法和规范。

第二阶段的“铺开期”,适合把已有项目的核心链路翻成Compose。比如一个用户从首页、列表、登录、个人中心的闭环。这个阶段会覆盖列表、状态管理、跨页面传参、网络请求状态处理等常用场景,能让团队对Compose的把握更扎实。

第三阶段的“深入期”,才会涉及到复杂动画、自定义布局、嵌套滑动这些硬核场景。我的原则是:如果一条路在View体系里已经走得很好,且没有强烈的重构必要性,就留着别动;如果View体系的实现已经影响到开发和迭代效率,“重构到Compose”才是值得的选项,别为了“跟上技术潮流”去硬重构一个稳定的模块

6.2 混用View与Compose,绕不开的几个架构决策

混用模式下,最容易犯的错误是把ComposeState到处传、把ViewModel拿到Composable里当全局变量用、或者试图在Compose里用原来的“接口回调式”的方法设计组件。

我在混用阶段踩过最大的坑和处理经验主要有这么几个:

坑一:从View发出事件到Compose。老页面里可能有一个用View实现的弹窗或输入框,用户操作的结果需要通过某种方式告诉Compose的某个Composable。刚开始我们是用接口回调一层层往外传,代码丑且难维护。最终换成EventBus/Flow共享事件,把跨体系通信收敛到“事件总线”一条路,两边都只管发和收,干净又清晰。

坑二:Compose嵌入View时,生命周期拿不准。在XML的View体系布局里嵌ComposeView时,很多人不理解ComposeView也是有生命周期的,它需要setViewTreeLifecycleOwner()等设置才能正常工作。实际上在常规的Activity/Fragment里,ComposeView会自动绑定好,但如果你在一些特殊的View子类里使用ComposeView,就得手动确认生命周期是否正确绑定,否则会出现“进入页面白屏、退出页面崩溃”之类的诡异问题。

坑三:Compose的Modifier和View的LayoutParams冲突。混合布局时经常要给ComposeView设置layout_widthlayout_height,如果你忘了给ComposeView设置宽高参数,会让一个本来应该match_parent的Compose区域,在XML里变成了wrap_content的默认尺寸,UI上的表现就是内容只占了一点点,剩下大片空白。

6.3 一次复杂重构的复盘:从RecyclerView的N个Type到LazyColumn

最后用一次实际重构来收尾这个章节。之前接手过一个IM聊天页面,原来的实现是RecyclerView + 多Type的ViewHolder,有文本、图片、语音、系统提示等七八种item类型。因为消息类型还在不断增加,每加一种新类型就要改Adapter、改ViewHolder、改item点击逻辑,代码维护成本越来越高,所以我决定用Compose重构。

重构后的核心结构大概是这样的:

@Composable fun ConversationScreen(messages: List<Message>) { val listState = rememberLazyListState() LazyColumn( state = listState, modifier = Modifier.fillMaxSize() ) { items( items = messages, key = { it.messageId } ) { message -> when (message.type) { MessageType.TEXT -> TextMessageItem(message) MessageType.IMAGE -> ImageMessageItem(message) MessageType.AUDIO -> AudioMessageItem(message) MessageType.SYSTEM -> SystemMessageItem(message) } } } }

重构完的直接收益有两个。第一是加新类型的成本大幅下降:以前要动Adapter、ViewHolder、item布局三处,现在只需要写一个新的MessageItemComposable,然后在when里加一个分支就行,新增一个类型的代码量从原来的上百行变成几十行。第二是整个页面的可测试性明显提升了:以前想测某个item类型在不同状态下的渲染,要先mock数据、跑App、手动滑到对应位置,现在直接写个@Preview就能看,写个本地测试也方便得多。

重构过程中踩的坑也有两个。一个是图片item在滑动时的闪烁——因为图片加载是异步的,Combposable在首次组合时图片还没加载完,会先显示占位图,等图片回来后,item又重组一次,看起来就是闪烁。解决方案是用Coil的rememberAsyncImagePainter配合Crossfade过渡动画来做占位到图片的平滑切换。另一个坑是列表的滚动位置恢复——切走再切回来时,列表位置通常会回到顶部,体验不好。需要在ViewModel里维护一个ScrollPosition,在onScroll事件里记录,通过rememberLazyListStatescrollToItem恢复。

这次重构的结论是:对于一个频繁增加消息类型、需要反复调整item交互的页面,Compose的收益远超成本,它让新增类型的成本变得极低,也让UI逻辑的维护从“改一处牵全局”变成了“改一个组件不动别的”。而如果是一个常年不变、需求稳定的普通列表页,就没必要折腾重构——用了也是用了个寂寞。

7. 一些个人建议和避坑心得

写了这么多,最后聊一点实操感受。Compose目前最大的两个问题,一个是对开发者的要求更高了——以前写View体系,你只要会布置控件、设监听就行;现在写Compose,你得理解状态管理、重组机制、稳定性和并发安全,不然代码很容易“跑着跑着就崩了,或者卡得不能自理”。另一个是三方SDK的适配还得靠包一层AndroidView——虽然不是没有好的方案,但无形中又增加了一层来回通信的复杂度。

但如果你问我“它成不成熟”,我的答案是:成熟到已经可以在生产环境大规模使用了,但还没成熟到“无脑梭哈”的程度。它就像五六年前的Kotlin化——有人观望、有人拥抱,三年后回头看,那些早就切过去的项目,积累的技术红利不是观望者能追得上的。

我的个人建议是这几点。如果你是一个还在用View体系的存量项目,从新页面开始试水,渐进式混用,不要一上来就全局重构;如果你是一个新项目,直接上Compose,前提是团队里至少有一个能看懂底层原理的人,不然前期排坑成本会很高;如果你还在纠结“要不要学”,不用纠结,学就是了——这个方向不会错,真正的问题只是“你的项目在什么时候把这套技术用起来最划算”。

最后再分享一个小技巧。如果你准备在项目里引入Compose,找一天时间,让团队里每个人都自己写一遍“一个列表页,点击item跳转详情页,详情页改状态后返回列表刷新”的完整流程。不要用讲师给的标准demo,就自己发挥,跑完一遍大概就知道团队在Compose上的基础水平了,也大概能预判项目接下来最大的坑会出在哪里。这个“摸底测试”的性价比,比任何PPT培训都高。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询