关于 jetpack-compose-performance
jetpack-compose-performance 是用于改善 Jetpack Compose 性能的代码审查技能。它依据细则检查 Composable、修饰符和惰性布局模型,并要求在报告或修改问题前阅读对应参考资料。
主要功能
- 01审查七类优化问题
- 02检查状态读取与稳定性
- 03检查布局测量和放置
- 04审视惰性布局数据模型
- 05修改前必须阅读参考文件
- 06强调发布构建与基准测试
适用场景
- 01审查 Compose 代码中的性能问题
- 02排查重组或滚动开销
- 03指导布局与状态处理的修复
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ android/ skills --skill jetpack-compose-performance安装后在 Claude Code 中输入/jetpack-compose-performance确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
references/animation.mdeffects-and-threading.mdgraphics-performance.mdlayouts.mdlazy-layouts.mdstate-and-stability.mdreferences/android/develop/ui/compose/custom-modifiers.mdreferences/android/develop/ui/compose/performance/backwards-write.mdreferences/android/develop/ui/compose/performance/stability/strongskipping.md详细介绍
基础前提
在对可组合项进行微优化之前,请确保已启用标准生产环境标志:
- 发布构建:切勿分析或基准测试可调试构建;ART 运行时验证会严重降低性能。
- R8 混淆:保持启用 R8,以优化字节码和协程调度。
- 基线配置文件:为关键用户流程(冷启动、关键滚动信息流)生成基线配置文件,让 ART VM 提前预编译并预加载类。
优化审计工作流
- 全面扫描:根据以下部分中的全部 7 个核心类别扫描目标代码。根据所有检查清单规则评估每个
@Composable函数、修饰符链和惰性布局数据模型,不要在发现第一个问题后就停止。 - 标记或编辑前必须阅读参考资料:每当某个文件符合类别 1--7 中的某条规则时,你必须先打开并阅读对应的
references/*.md文件,了解经认可的修复模式和完整的范围限制,之后才能报告发现或编辑代码。编写修复方案时,切勿只依赖摘要要点。
1. 状态、快照和稳定性(references/state-and-stability.md)
强制要求:在标记或修复此类别中的任何问题之前,你必须阅读 references/state-and-stability.md。
- 延迟到后续阶段(Lambdas):将频繁变化的状态读取包装在 lambda 中(例如
Modifier.offset或Modifier.graphicsLayer),使读取发生在 Layout 或 Draw 阶段,而不是 Composition 阶段。 - 禁止反向写入(验证读写顺序):绝不要在后续阶段修改状态,也不要在同一 composition pass 中读取状态后再修改它(
Read -> Write,或Composition Read -> Layout or Draw Write)。避免跨阶段反向写入,以免出现首帧跳变、卡顿和无限重组循环(反向写入)。不要将 composable 开始处的状态赋值(Write -> Read或Composition Write -> Draw Read)标记为反向写入——反向写入严格来说要求在写入之前读取该状态。 - 避免在 Composition 阶段进行冗余状态包装(
Write -> Read):不要仅为了在 composition 开始处立即覆盖同步值,然后再读取它们,而将其包装在remember { mutableStateOf(...) }中(例如,var path by remember { mutableStateOf(Path()) }; path = viewModel.getPath())。这虽然不属于反向写入,但会增加不必要的 slot table 和MutableState快照开销(如果之后在赋值之前添加读取,还可能变成反向写入);应使用普通局部val,或直接在目标阶段读取该值(例如Modifier.drawWithCache)。 - 显式指定
remember键和 Slot 开销:始终将remember中引用的所有输入变量和 composable 参数作为计算键传入。请记住,remember会在 slot table 中存储keys + 1个对象,并在每次重组时检查键是否相等;只有在分配开销较大或必须保留对象标识时才使用remember。包装derivedStateOf时,不要将快照状态值/属性(例如state.value)作为键传入(如果State实例的引用可能变化,则传入该实例本身没有问题)。 computedStateOf与derivedStateOf(Compose 1.13+):在 Compose 1.13+ 中,对于简单条件检查和从快速变化的输入派生的筛选状态(例如scrollState.value > 0),使用computedStateOf,其失效速度明显快于derivedStateOf(在 Compose< 1.13中,使用remember { derivedStateOf { ... } })。将derivedStateOf留给能从多次读取缓存中受益的复杂计算,或在每次 composition 中读取频率足够高、可抵消失效开销的简单计算(在读取之间不发生失效时,大约达到10 / numberOfStatesReadPerCalculation次)。- 同步派生状态:检查是否有异步
produceState+snapshotFlow块在观察 Compose 状态(例如滚动位置或集合大小);将其替换为同步的remember { derivedStateOf { ... } }(Compose 1.13+ 中使用computedStateOf)。 - 简单基本类型:对于简单的布尔表达式和属性查找,优先使用普通自定义 Kotlin getter(
get()),而不是derivedStateOf或computedStateOf。 - 集合状态跟踪:对于整个列表的更新,使用标准的
mutableListOf()和mutableStateOf(list, neverEqualPolicy()),并通过实际赋值触发更新,而不是使用mutableStateListOf()。 - 快照集合:重复查询或迭代时,使用
SnapshotStateList.toList(),以避免逐元素读取带来的观察开销。 - 重复读取状态:将重复读取的 Compose
State属性提取到代码块顶部的局部变量中。 - Setter 中的状态:避免在自定义属性 setter 中读取 Compose
State;触发变更副作用时使用原始后备字段,并批量更新多个属性。 - 稳定性与跳过的权衡(跳过并非免费):不要一味追求稳定性,也不要将
@Immutable、@Stable或ImmutableList视为默认解决方案。跳过 composable 并非没有成本:每个参数都会像被记住一样存储在 slot table 中,并且每次重组都必须比较所有输入。强跳过模式 已会在实例相等(===)时跳过带有不稳定参数(包括标准kotlin.collections.List)的 composable。添加注解或使用ImmutableList会强制进行结构相等比较(.equals());对于大型集合或复杂类,这可能比重组本身开销更大。 - 绝不要给集合模型添加
@Stable、@Immutable或ImmutableList:编写或优化代码时,绝不要给包含集合(List、Set、Map)的模型添加@Immutable或@Stable,也绝不要建议将kotlin.collections.List<T>替换为ImmutableList<T>(或在stability_config.txt中添加ImmutableList)。在强跳过模式下,每次重组都对集合执行O(N)的.equals()比较,比O(1)的实例相等比较(===)开销更大。 - 移除现有
@Stable/@Immutable注解的适用范围(仅限大型惰性数据集):- 何时移除:仅当同时满足以下两个条件时,才建议从数据模型中移除现有的
@Stable或@Immutable注解:(1) 该模型用作惰性布局(LazyColumn、LazyRow、LazyGrid、Pager、ScalingLazyColumn)中的输入数据源或条目模型,且 (2) 它包含大型数据集或深层嵌套集合层级(例如信息流、大量标签/条目,或多层嵌套列表,如包含List<Paragraph>和List<Markup>的Post),其中递归的O(N).equals()检查会降低滚动和重组性能。 - 何时保持不变:对于代码库中其他所有现有的
@Stable、@Immutable或ImmutableList用法——例如非惰性 UI 模型、独立组件,或集合规模较小/有界的模型(例如带有少量徽章、缩略图或元数据行的卡片),其中数据集较小或规模未知——保留现有注解,不要标记。绝不要不加判断地在整个代码库中搜索并标记所有@Stable/@Immutable类;必须先确认它们是否用于包含大型数据集的惰性布局。
- 何时移除:仅当同时满足以下两个条件时,才建议从数据模型中移除现有的
- 强跳过模式下何时添加
@Stable/@Immutable:Compose 模块中属性为val,且只包含基本类型或String的扁平data class已被编译器推断为稳定,并会自动使用.equals()——不要给它们添加冗余的@Immutable注解。仅当编译器无法推断稳定性(例如来自非 Compose 模块的类或接口),且数据源(例如 Room 或 MVI reducer)会在每次发射时为未发生变化的条目分配新的对象实例时,才添加@Stable或@Immutable;前提是该类规模较小、结构扁平、不包含集合(List、Set、Map),并且其.equals()检查为简单的O(1)。 - 让 Composable 更轻量,而不是强行跳过:不要过度关注重组次数。在一次正在进行的重组过程中跳过一个 composable,节省的时间很少,因为一旦开始调度 composition,调度和启动重组过程才是主要开销。重点应放在降低 composable 的执行成本、将状态读取延迟到 Layout/Draw 阶段,并通过基准测试进行验证,而不是想当然地认为跳过能提升性能。
2. 布局和测量优化(references/layouts.md)
必须:在指出或修复此类别中的任何问题之前,你必须阅读 references/layouts.md。
- 坐标与放置:避免使用
onGloballyPositioned;使用Modifier.onVisibilityChanged跟踪视口,使用Modifier.onPlaced或Modifier.onLayoutRectChanged获取局部边界,并在Modifier.Node事件处理程序中按需查询requireLayoutCoordinates()。 - 通过自定义
Layout锚定浮动内容:将浮动标签或工具提示附加到组件时,请使用自定义Layout测量并将两个子项相对放置。切勿将onGloballyPositioned坐标跟踪到MutableState中,再通过offset定位浮动内容;这会导致 1 帧延迟,并触发第二次组合过程。 - 偏移的阶段延迟:将动态偏移包装在 lambda 中(
Modifier.offset { IntOffset(...) }),以跳过组合阶段,直接进入布局阶段。 - 树深度与装饰性包装器:避免冗余的包装
Box和装饰性Canvas节点;直接附加修饰符,或使用Modifier.drawBehind。 - 子组合开销:避免在热路径和惰性列表项中使用
BoxWithConstraints/SubcomposeLayout;优先使用标准修饰符、Modifier.layout或LayoutModifierNode。 - 固有测量:谨慎使用固有测量,并避免深度嵌套的固有测量链。
3. 惰性布局优化(references/lazy-layouts.md)
强制要求:在指出或修复此类别中的任何问题之前,你 必须阅读 references/lazy-layouts.md。
- 惰性布局范围限制:关于
key和contentType的指令仅严格适用于惰性容器(LazyColumn、LazyRow、LazyGrid)。绝不要指出在集合上迭代的标准非惰性容器(Column、Row、Box)存在问题。 - 必须设置
contentType和基本类型的key:惰性布局(LazyColumn、LazyRow、LazyGrid)中的所有项都必须指定基本类型(Int、Long、String)的key和contentType。使用items或itemsIndexed时,绝不要省略contentType:contentType = { _, item -> item.type }。省略contentType会导致 Compose 在滚动期间无法复用组合槽。 - 项内索引查找:将
list.indexOf(item)替换为itemsIndexed,以消除布局阶段的O(N²)查找。 - 可观察的选择和状态查询:将选择跟踪从
mutableStateListOf改为MutableState中的不可变Set(mutableStateOf(emptySet<String>()))、mutableStateSetOf()或 Map,以实现O(1)查找。将 lambda 提供器(isSelected: () -> Boolean)传递给叶级可组合项,避免父级items作用域触发重组。 - 在惰性项中传递 CompositionLocals:避免在单个惰性项可组合项中查询
CompositionLocal(LocalContext.current、LocalDensity.current、LocalSharedTransitionScope.current、作用域等);应在父容器或屏幕中解析一次,并将解析后的值作为参数向下传递。 - 延迟惰性项中的共享转场:将惰性项上的
Modifier.sharedBounds或Modifier.sharedElement放在可见性指示器(如Modifier.onVisibilityChanged或视口可见性检查)之后,以免屏幕外或预取的项实例化转场作用域。 - 项数的推导:直接读取集合的
.size,不要将列表计数包装在derivedStateOf中。 - 小型且有界的列表:对于小型或有界集合(例如 1 到 10 个项),使用标准
Row或Column配合for或forEach循环,消除惰性虚拟化、测量和预取开销。 - 提升共享 Flow:在惰性布局之前的父容器中收集一次共享
Flow;绝不要对每个可见项调用collectAsStateWithLifecycle()。 - 避免在预取项中执行副作用:绝不要在惰性项内通过
LaunchedEffect(Unit)触发分析;应使用带有停留时长阈值的Modifier.onVisibilityChanged。
4. 动画和共享转场(references/animation.md)
强制要求:在指出或修复此类别中的任何问题之前,你 必须阅读 references/animation.md。
- 动画属性的阶段延迟:将快速变化的动画状态读取(alpha、scale、rotation、color)包装在
Modifier.graphicsLayer { ... }中,以绕过组合阶段,直接在绘制阶段求值。 - 偏移和尺寸的阶段延迟:将动画偏移量包装在
Modifier.offset { IntOffset(...) }中,并使用Modifier.layout { ... }或Modifier.animateBounds为约束添加动画,以避免尺寸转场期间发生重组。 - 在快速滚动列表中延迟共享转场:仅在可见时通过
Modifier.onVisibilityChanged附加Modifier.sharedBounds或Modifier.sharedElement,避免为被丢弃的预取项实例化修饰符和观察对象所产生的开销。 - Lambda 提供器:向子可组合项传递 lambda 提供器(
() -> Float),而不是原始动画值,以便将状态读取延迟到绘制或布局阶段。 - 在节点中持久化
Animatable:将Animatable存储为Modifier.Node的持久成员属性,并在onAttach()中使用coroutineScope.launch启动。
5. 副作用、生命周期与线程处理(effects-and-threading.md)
强制要求:在标记或修复此类别中的任何问题之前,必须阅读 references/effects-and-threading.md。
- 禁止在主体中直接产生副作用:组合期间,切勿在 Composable 主体中直接执行副作用;请将其包装在
SideEffect、LaunchedEffect或用户事件回调中。 - 禁止使用非挂起的
LaunchedEffect和空的DisposableEffect:切勿使用LaunchedEffect执行同步操作(例如记录视图日志或更新对象)。切勿将带有空onDispose {}块的DisposableEffect作为变通方法。请改用SideEffect(在 Compose 1.12+ 中,SideEffect(keys) { ... }直接支持键;在 Compose< 1.12中,SideEffect不接受参数,因此请检查项目的 Compose 版本,并在SideEffect { ... }中使用remember的持有对象跟踪键的变化)。 - 缓存主体中的分配:将开销较大的对象分配(例如日期格式化器或复杂路径)和耗时的计算包装在
remember中。 - 合并相关的
LaunchedEffect:将多个LaunchedEffect(Unit)调用合并为一个LaunchedEffect,并在其中嵌套launch { ... }块,以减少 effect 节点分配和协程启动器开销。 - 副作用的执行顺序与清理开销:了解应用组合变更时副作用的执行顺序(先运行
DisposableEffect,再运行SideEffect;而LaunchedEffect会在组合的CoroutineContext上启动一个协程,该协程以异步方式分派,且不保证在 Draw 之后才执行)。请注意,取消LaunchedEffect会产生协程取消和清理开销;在动画或滚动期间避免频繁切换LaunchedEffect的键。 - 将任务移出主线程:使用
produceState将阻塞式 I/O、繁重的文件解析和数据库操作移至withContext(Dispatchers.IO)。 - 延迟布局中禁止在单个条目上使用曝光效果:切勿在延迟布局的条目中使用
LaunchedEffect或DisposableEffect记录曝光日志。延迟布局会在条目进入视口前预先组合它们,导致曝光记录过早或错误。请改用带有停留时长阈值的Modifier.onVisibilityChanged。 - 以生命周期感知的方式收集 Flow:对于会主动产生后台工作流的 Android UI Flow 收集,请使用
collectAsStateWithLifecycle();对于轻量级的内存内 StateFlow 或非 Android 作用域,请使用collectAsState()。 - 提升接收器与监听器的作用域:使用
DisposableEffect在屏幕级别注册一次系统监听器和BroadcastReceiver,而不是在延迟列表条目中注册。 - 为可选副作用添加保护:在启动副作用前使用
if (callback != null)进行保护,以避免分配空协程和 effect 节点。 - 防止捕获过时值:如果
LaunchedEffect中的长时间运行或循环协程引用了回调或参数,请使用rememberUpdatedState。 - 替换旧式计时器:使用
LaunchedEffect和delay()替换Handler.postDelayed或TimerTask。
6. 图形与自定义绘制(references/graphics-performance.md)
强制要求:在标记或修复此类别中的任何问题之前, 必须阅读 references/graphics-performance.md。
- 缓存绘制分配:使用
Modifier.drawWithCache替换装饰性Canvas节点,以缓存Path、Paint和Brush分配。 - 优先使用 AsyncImage,而非 Image:使用 Coil 的
AsyncImage加载互联网图片,并使用painterResource加载可绘制资源。Image未针对图片缓存和加载进行优化,可能会导致性能问题。 - 限定快速变化的统一变量作用域:在组合中使用
remember记住RuntimeShader和ShaderBrush,在drawWithCache中更新尺寸统一变量,并在onDrawBehind中更新逐帧统一变量。 - 复用图形路径对象:在热点绘制循环中,优先使用
Path.rewind()而不是reset(),以保留已分配的缓冲区,避免重新分配。 - 值类和原始类型集合:避免使用泛型元组(
Pair、Triple)和泛型集合(HashSet<Long>);改用androidx.collection.MutableLongSet和androidx.compose.ui.util.packInts。
7. 自定义修饰符:Modifier.Node(自定义修饰符指南)
-
迁移到 Modifier.Node:将旧版
Modifier.composed迁移到ModifierNodeElement+Modifier.Node(委托给子节点时,例如SuspendingPointerInputModifierNode,使用DelegatingNode),并采用能力 接口(DrawModifierNode、LayoutModifierNode、SemanticsModifierNode、使用currentValueOf(...)的CompositionLocalConsumerModifierNode),以消除每个节点的子组合开销,并 支持节点复用(参见使用 Modifier.Node 创建自定义修饰符):// Modifier factory fun Modifier.circle(color: Color) = this then CircleElement(color) // ModifierNodeElement private data class CircleElement( val color: Color ) : ModifierNodeElement<CircleNode>() { override fun create() = CircleNode(color) override fun update(node: CircleNode) { node.color = color } } // Modifier.Node private class CircleNode( var color: Color ) : DrawModifierNode, Modifier.Node() { override fun ContentDrawScope.draw() { drawCircle(color) } }
-
按需获取坐标:在
Modifier.Node的指针或绘制处理程序中按需调用requireLayoutCoordinates(),而不是实现GlobalPositionAwareModifierNode.onGloballyPositioned。
重要:如需了解如何将常见修饰符迁移到 Modifier.Node 的更多示例,必须阅读使用 Modifier.Node 创建自定义修饰符。