一、回顾与引入前十五课我们走完了Compose开发的完整旅程从基础概念、布局、状态、列表、导航、主题、动画手势、自定义绘制到View互操作、测试调试、架构分层、协程深度结合、Compose Multiplatform最后用一个生产级项目把知识全部串了起来。第10课我们聊过性能优化但当时更多是从“怎么用”的角度切入的——derivedStateOf怎么用、remember怎么用、LazyColumn怎么传key。这一课我们要从“为什么”的角度深入把Compose的性能模型彻底讲透。你可能会问“Compose不是默认性能就很好吗”没错Compose 1.9及之后的版本滚动性能以卡顿为指标已经与View持平。但这不意味着你不需要理解性能。同样的功能有人写出来丝滑流畅有人写出来掉帧卡顿差别就在于是否理解了Compose的渲染管线以及是否知道哪些做法会让框架“帮不了你”。这一课的内容会涉及一些原理性的东西但每个点都会配上可操作的优化手段和诊断方法。学完这一课你不仅能写出跑得快的Compose代码还能在遇到性能问题时精准定位原因而不是盲目猜测。二、理解Compose的渲染管线三个阶段2.1 三个阶段是什么Compose更新一帧时会经历三个阶段组合CompositionCompose确定要显示什么。它运行可组合函数构建界面树。布局LayoutCompose确定界面树中每个元素的尺寸和位置。绘制DrawingCompose实际渲染各个界面元素。Compose的聪明之处在于它可以智能地跳过不需要的阶段。举个例子假设一个图形元素在两个尺寸相同的图标之间切换。由于尺寸没变界面树中也没有元素增减Compose可以跳过组合和布局阶段只重新绘制那个元素。但前提是你的代码要让Compose能判断出“哪些阶段可以安全跳过”。如果编码方式让Compose无法确定它就会运行所有三个阶段性能自然下降。所以大多数性能优化的本质就是帮助Compose跳过不需要的阶段。2.2 每个阶段做什么组合阶段执行可组合函数读取状态构建/更新界面树。这是最“重”的阶段。如果组合阶段执行了不必要的计算就会拖慢性能。布局阶段测量每个元素的尺寸决定它们在屏幕上的位置。这一阶段的开销与界面树的复杂度成正比。绘制阶段把像素画到屏幕上。这一阶段最“轻”但如果有大量复杂绘制比如没缓存的Path也会成为瓶颈。优化的核心思路尽量让状态变化只触发“最轻”的阶段。比如如果一个动画只改变颜色而不改变尺寸它应该只触发绘制阶段跳过组合和布局。三、诊断工具找到性能瓶颈在优化之前你需要先知道问题在哪里。Compose提供了一套完整的诊断工具。3.1 Layout InspectorAndroid Studio的Layout Inspector可以实时查看运行中应用的Compose布局。它能显示每个可组合项的重组次数和跳过次数。怎么用运行App → 打开 Tools Layout Inspector → 在Compose层级里查看每个组件的重组计数。怎么看如果一个组件的重组次数很高但它的参数并没有变化说明它无法被跳过——这就是优化目标。3.2 Compose编译器报告Compose编译器可以输出稳定性推断结果。启用后编译时会生成三个文件模块名-classes.txt类稳定性的报告。模块名-composables.txt每个可组合函数的可重启性、可跳过性、参数稳定性。模块名-composables.csvCSV版本方便脚本处理。启用方式android{composeCompiler{reportsDestinationlayout.buildDirectory.dir(compose_compiler)metricsDestinationlayout.buildDirectory.dir(compose_compiler)}}在composables.txt里你会看到类似这样的输出restartable skippable fun SnackCollection( stable snackCollection: SnackCollection stable onSnackClick: Function1Long, Unit stable modifier: Modifier? static Companion stable index: Int static 0 )restartable可以独立重组。skippable参数没变时可以跳过。stable参数是稳定的。如果看到unstable说明参数不稳定可能导致无法跳过重组。3.3 组合跟踪Android Studio的Profiler可以录制System Trace追踪重组事件。打开Profiler → CPU时间轴 → System Trace → Record操作应用触发重组后停止记录。Trace里会显示每次重组在哪个可组合函数中发生以及耗时多少。3.4 发布模式与R8这是最容易被忽略的一步。Debug模式下Compose会做额外的检查性能远不如Release。在优化之前确保你在Release模式下测试。同时启用R8代码混淆和优化它能显著提升运行时性能。3.5 Baseline ProfilesBaseline Profiles通过预编译关键用户路径的代码让App启动更快、交互更流畅。Compose包含一个默认的Profile但强烈建议你为自己的App生成一个——根据Google的数据Baseline Profiles可以在首次启用后让代码执行速度提高30%。四、稳定性让Compose能够“跳过”4.1 什么是稳定性第10课我们讲过稳定性的概念这里从“为什么”的角度再深入一层。Compose判断一个可组合函数是否可以跳过依赖于它的参数是否稳定。稳定的含义是Compose能确定这个值在重组之间是否发生了变化。基本类型是稳定的String、Int、Float、Boolean等。函数类型是稳定的。data class如果所有属性都是稳定的它就是稳定的。List、Map、Set是不稳定的。因为Compose编译器无法确定这些集合接口背后的实现是否真的不可变——一个List可能是MutableList内容可能在重组之间被修改了。4.2 不稳定带来的代价当一个可组合函数的参数不稳定时Compose无法安全地跳过它。即使两次传入的参数“看起来一样”Compose也不敢跳过只能每次都重新执行。这会导致不必要的重组 → 不必要的布局 → 不必要的绘制。在列表中滚动时这种代价会被放大因为每一帧都可能触发大量重组。4.3 解决方案方案一使用不可变集合Compose编译器支持kotlinx.collections.immutable库。把List换成ImmutableList编译器就会把它当作稳定类型。ImmutabledataclassArticle(valid:Long,valtitle:String,valtags:ImmutableListString)方案二用 Immutable 标记如果确定一个类的所有属性在创建后不会变化用Immutable标记。这会覆盖编译器的推断结果。注意Immutable是一个承诺。如果你标记了但实际会变就会出现界面不更新的Bug。所以优先通过设计让类天然稳定注解是最后手段。方案三启用强跳过模式强跳过Strong Skipping是Compose编译器提供的一种模式。启用后所有可重启的可组合函数都变为可跳过——无论它们的参数是否稳定。启用方式composeCompiler{enableStrongSkippingModetrue}强跳过还带来了另一个好处可组合函数内的lambda会被自动记忆减少因lambda创建导致的不必要重组。强跳过是目前最简单、最有效的稳定性问题解决方案。如果你的项目还在用旧版Compose升级到支持强跳过的版本并启用它是最值得做的性能优化之一。五、延迟读取让状态变化只影响必要的阶段5.1 什么是延迟读取Compose的一般原则是尽可能延后读取状态。状态读取发生的阶段决定了哪些阶段需要重新执行如果在组合阶段读取状态 → 状态变化触发组合 布局 绘制。如果在布局阶段读取状态 → 状态变化只触发布局 绘制。如果在绘制阶段读取状态 → 状态变化只触发绘制。读取越晚需要重新执行的阶段越少。5.2 用lambda替代状态值这是一个非常实用的技巧。不要把经常变化的状态值直接传给子组件而是传一个读取该状态的lambda// 不推荐状态值在组合阶段被读取ComposablefunCounter(count:Int){Text($count)// count变化 → 整个Counter重组}// 推荐传lambda延迟读取ComposablefunCounter(count:()-Int){Text(${count()})// 只有Text内部读取时才触发重组}当count是lambda时Counter的参数本身一个函数引用没有变化所以Compose可以跳过Counter的重组。只有当Text内部真正调用count()读取值时才会触发那个点的重组。5.3 用基于lambda的ModifierCompose提供了基于lambda的Modifier版本让状态读取发生在布局或绘制阶段// 不推荐offset在组合阶段读取Modifier.offset(xanimatedX.dp)// 推荐lambda版本在布局阶段读取Modifier.offset{IntOffset(animatedX.roundToInt(),0)}Modifier.offset { }的lambda在布局阶段执行状态变化只触发布局和绘制跳过了组合阶段。类似的还有// graphicsLayer的lambda版本在绘制阶段读取Modifier.graphicsLayer{translationXanimatedX// 只触发绘制}5.4 在子组件中读取状态如果父组件需要在某个状态下做决策但决策只影响子组件可以把状态读取“下沉”到子组件// 不推荐父组件读取状态整个父组件重组ComposablefunParent(scrollState:ScrollState){valisAtTopscrollState.value0Child(isAtTopisAtTop)OtherContent()// 也会被重组}// 推荐状态读取下沉到子组件ComposablefunParent(scrollState:ScrollState){Child(scrollStatescrollState)// 不读取只传递OtherContent()// 不会因scrollState变化而重组}ComposablefunChild(scrollState:ScrollState){valisAtTopscrollState.value0// 在这里读取// ...}六、LazyColumn深度优化LazyColumn是性能问题最集中的地方因为滚动时每一帧都可能触发大量的组合和布局。6.1 key不只是正确性更是性能第4课讲过key保证状态正确。从性能角度看key的作用更大没有key时列表增删改查后Compose按位置比较。位置0原来的内容是A现在变成了BCompose认为“位置0变了”于是重组位置0。但实际上可能只是A和B交换了位置内容本身没变。有key时Compose按key识别每一项。A的key是A不管它排第几Compose都能找到它对应的组合如果内容没变就跳过重组。关键区别没有key时列表插入一个项会导致后续所有项的重组有key时只有新插入的项需要组合。6.2 contentType按类型复用当列表里有多种类型的项时contentType帮助Compose按类型复用组合项。Lazy布局内部维护一个按类型分类的组合缓存类似于RecyclerView的view type。items(itemsfeed,key{it.id},contentType{it.type}// article / video / ad){item-...}什么时候需要contentType当不同项的结构差异很大时比如高度不同、内部布局不同。如果所有项结构完全一样不需要传。6.3 避免在items里做耗时计算// 不推荐每次项被组合都计算items(users){user-valdisplayNameexpensiveFormat(user)Text(displayName)}// 推荐提前算好或remember缓存items(users,key{it.id}){user-valdisplayNameremember(user.id){expensiveFormat(user)}Text(displayName)}6.4 列表项高度尽量确定如果每项高度可以是任意值LazyColumn需要更复杂的计算来确定可见项。如果高度固定或可预测性能更好。对于高度不固定的项考虑用Modifier.animateItem()配合contentType减少布局跳动。6.5 derivedStateOf处理滚动监听// 不推荐滚动时每帧重组valisAtToplistState.firstVisibleItemIndex0// 推荐只在结果变化时重组valisAtTopbyremember{derivedStateOf{listState.firstVisibleItemIndex0}}七、常见的性能陷阱7.1 在组合阶段读取频繁变化的状态这是最容易被忽视的陷阱。listState.firstVisibleItemIndex在滚动时每帧都在变。如果在组合阶段直接读取它整个组件每帧都会重组。解法用derivedStateOf把高频状态派生为低频结果。7.2 painterResource加载大图阻塞主线程painterResource在组合阶段在主线程上同步加载图片。如果图片很大会阻塞主线程。解法用Coil的AsyncImage异步加载或者把图片压缩成更小的尺寸/使用vector drawable。7.3 在组合中做耗时操作网络请求、数据库查询、JSON解析等操作绝对不要在可组合函数或组合阶段执行。它们应该放在LaunchedEffect、ViewModel或Repository中。7.4 不稳定的lambda// 可能导致重组每次重组创建新lambdaButton(onClick{viewModel.doSomething()})Compose编译器在强跳过模式下会自动记忆lambda。如果没启用强跳过手动用remember稳定化。7.5 忘记consume事件导致重复处理在手势处理中如果不调用change.consume()事件可能被多个处理者重复处理。7.6 手动动画循环绕过Compose渲染管线用while(true) delay手动驱动动画会绕过Compose的帧同步机制导致卡顿。解法用rememberInfiniteTransition、animateFloatAsState等Compose动画API。八、最佳实践清单把这一课的核心要点浓缩成一份可操作的清单配置层面在Release模式下测试性能启用R8优化。启用Baseline Profiles首屏和交互性能提升30%。启用强跳过模式enableStrongSkippingMode true。代码层面用Immutable或ImmutableList让数据类稳定。用derivedStateOf处理高频状态。用remember缓存耗时计算。用lambda替代状态值延迟状态读取。用Modifier.offset { }等lambda版本。列表永远传key多种类型时传contentType。不在组合阶段做耗时操作。诊断层面开启Compose编译器报告检查可跳过性。用Layout Inspector查看重组次数。用Profiler的System Trace追踪重组事件。九、综合实战优化一个卡顿的列表页假设我们有一个商品列表页滚动时卡顿。我们按这一课的方法一步步诊断和优化。症状滚动时掉帧Layout Inspector显示ProductCard重组次数异常高。第一步检查编译器报告Product类包含ListString tags编译器标记为unstable。ProductCard没有skippable标记。第二步修复稳定性// 修复前dataclassProduct(valid:Long,valname:String,valtags:ListString// unstable)// 修复后ImmutabledataclassProduct(valid:Long,valname:String,valtags:ImmutableListString)第三步检查列表配置// 修复前items(products){product-ProductCard(product)}// 修复后items(itemsproducts,key{it.id},contentType{product}){product-ProductCard(product)}第四步延迟状态读取如果ProductCard内部有动画状态确保用lambda版本的Modifier// 修复前Modifier.offset(xanimatedX.dp)// 修复后Modifier.offset{IntOffset(animatedX.roundToInt(),0)}第五步验证重新编译查看编译器报告确认ProductCard变为skippable。用Layout Inspector确认重组次数下降。十、常见陷阱速查陷阱后果解决方案组合阶段读高频状态每帧重组derivedStateOfList参数不稳定无法跳过重组ImmutableList或Immutable列表不传key增删改时全部重组key { it.id }painterResource大图主线程阻塞异步加载或压缩组合中做耗时操作掉帧移到LaunchedEffect/ViewModel手动动画循环绕过渲染管线用Compose动画API不启用强跳过不稳定参数无法跳过enableStrongSkippingModeDebug模式测性能误判性能用Release模式十一、小结这一课我们从“为什么”的角度深入了Compose的性能模型。核心要点Compose的三阶段模型组合、布局、绘制。优化的本质是帮助Compose跳过不必要的阶段。诊断工具Layout Inspector看重组计数编译器报告看可跳过性System Trace追重组事件。稳定性不稳定参数导致无法跳过重组。强跳过模式是解决稳定性问题的最简单方式。延迟读取状态读取越晚需要重新执行的阶段越少。用lambda替代状态值用lambda版Modifier。LazyColumn优化key、contentType、避免耗时计算、列表项高度确定。性能陷阱组合阶段读高频状态、painterResource加载大图、不稳定的lambda、手动动画循环。配置优化Release模式 R8 Baseline Profiles 强跳过模式。性能优化不是一次性的工作而是一个持续的过程。先用工具找到瓶颈再针对性地优化最后用基准测试验证效果。不要凭感觉优化要凭数据优化。课后练习找一个你之前写的Compose页面按这一课的流程走一遍——开启编译器报告检查哪些可组合函数不可跳过用Layout Inspector看哪些组件重组次数高然后按这一课的方法优化。你会发现同样的功能优化后代码可能更少但跑得更快。
