当遇到导致性能问题的不稳定类时,您应优化 Compose 比较或实例化该类的方式。本文档概述了您可以使用的几种方法。
启用强力跳过
从 Kotlin 2.0.20 开始,强力跳过模式默认处于启用状态。
强力跳过模式允许通过使用实例相等性 (===) 比较不稳定参数来跳过具有不稳定参数的可组合项,而稳定参数则继续使用结构相等性 (Object.equals()) 进行比较。
如果您使用的 Kotlin 版本低于 2.0.20,请先启用强跳过模式。如果已启用强跳过模式,但可组合项仍会不必要地重组,请检查是否在每次重组传递时都分配了不稳定形参的新实例。
如需了解详情,请参阅强跳过。
使类不可变
您还可以使不稳定的类完全不可变,以便编译器可以自动推断其稳定性。
- 不可变:表示一种类型,其中任何属性的值在构造该类型的实例后都无法更改,并且所有方法都具有引用透明性。
- 确保该类的所有属性都是
val(而非var),并且属于不可变类型。 - 原始类型(例如
String、Int和Float)始终不可变。 - 在通过 Compose 编译器构建的模块中,具有
val基元或String属性的扁平data class类型已被推断为稳定,并会自动与.equals()进行比较,您无需使用@Immutable为其添加注解。 - 如果无法实现不可变性,则必须对任何可变属性使用 Compose 状态。
- 确保该类的所有属性都是
- Stable:表示可变的类型。Compose 运行时不会知道该类型的任何公共属性或方法行为是否会产生与之前调用不同的结果,以及何时会产生不同的结果。
集合和稳定性
Compose 认为类不稳定的一个常见原因是集合。如诊断稳定性问题页面中所述,Compose 编译器无法完全确定 List、Map 和 Set 等集合接口是否真正不可变,因此会将它们标记为不稳定。
请考虑诊断稳定性问题指南中的以下类:
unstable class Snack {
…
unstable val tags: Set<String>
…
}
启用强力跳过模式(Kotlin 2.0.20 及
更高版本中的默认模式)后,只要传递相同的引用
,不稳定的集合和包含集合的类就会使用实例相等性 (===) 跳过
重组。
在将 kotlin.collections.List、Set 或 Map 替换为 Kotlinx 不可变集合(ImmutableList、ImmutableSet、ImmutableMap)或向包含集合的模型添加 @Immutable、@Stable 或 stability_config.conf 规则之前,请务必谨慎。在强跳过模式下,标准 List 会使用引用相等性 (===) 跳过,而 ImmutableList 和带注解的集合模型会在每次重组传递时运行逐元素 .equals() 比较。
对于大型集合或延迟布局(LazyColumn、LazyRow、LazyGrid),检查每个项的 .equals() 所需的时间可能比重组更长。如果您将 ImmutableList 属性从 data class 传递给子可组合项,此费用会翻倍:
data class SnackCategory(
val name: String,
val snacks: ImmutableList<Snack>
)
@Composable
fun CategorySection(category: SnackCategory) {
Text(category.name)
// Compose compares category.snacks twice: once in SnackCategory.equals()
// for CategorySection, and again for HighlightedSnacks.
HighlightedSnacks(snacks = category.snacks)
}
请勿对集合强制执行结构相等性检查,而应在多次发出数据时保留集合的实例引用(例如,通过在 ViewModel 中或使用 remember 缓存转换),以便 Compose 可以使用实例相等性 (===) 跳过重组。只有在无法保留集合的实例引用,且基准测试确认结构相等性检查比重组更快时,才考虑对包含集合的模型使用 ImmutableList 或稳定性注解。
如果您使用的 Kotlin 版本低于 2.0.20,请先启用强跳过模式。
使用 Stable 或 Immutable 添加注解
对于具有低成本 .equals() 检查的小型类,解决其稳定性问题的一种可能途径是使用 @Stable 或 @Immutable 为不稳定类添加注解。
为类添加注解会替换编译器原本会针对您的类推断的内容。它类似于 Kotlin 中的 !! 运算符。您应非常谨慎地使用这些注解。替换编译器行为可能会导致出现不可预见的 bug,例如可组合项未按预期重组。
启用强跳过模式后,请遵循以下准则来决定是否添加 @Stable 或 @Immutable:
- 不要为已被推断为稳定的类添加注解:在通过 Compose 编译器构建的模块中,具有稳定类型的
val属性的类已被推断为稳定。 - 仅在重新创建实例时,使用开销较低的
.equals()检查来注解类:当编译器无法推断稳定性(例如,来自非 Compose 模块的接口或类)且数据源在每次发出时为未更改的项分配新的对象实例时,添加@Stable或@Immutable,前提是该类较小且具有开销较低的.equals()检查。 - 谨慎处理包含集合的模型:避免向包含大型集合(
List、Set、Map)的数据模型添加@Stable或@Immutable,尤其是用于延迟布局(LazyColumn、LazyRow、LazyGrid、Pager)的模型。不为这些模型添加注解可让 Compose 使用实例相等性 (===) 来比较它们,而不是对每个元素运行结构化.equals()检查。
以下代码段提供了一个简单示例,展示了在编译器无法推断稳定性的模块中定义时,如何将扁平数据类(不含集合属性)注解为不可变:
@Immutable
data class SnackSummary(
val id: Long,
val name: String,
val price: Long
)
无论您使用 @Immutable 还是 @Stable 注解,Compose 编译器都会将 SnackSummary 类标记为稳定,并使用 Object.equals() 进行比较。
集合中的带注解类
假设有一个可组合项,其中包含一个类型为 List<Snack> 的形参:
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
stable index: Int
unstable snacks: List<Snack>
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
)
即使 Snack 是稳定的或带有 @Immutable 注解,Compose 编译器仍会将 HighlightedSnacks 中的 snacks 参数标记为 unstable,因为 List 是一个接口。
启用强力跳过模式后,HighlightedSnacks 仍会被标记为 skippable,并在传递相同的 List 实例时使用实例相等性 (===) 跳过重组。您可以通过以下方式使 snacks 参数stable:将 List<Snack> 封装在 @Immutable 封装容器类中、将 List<Snack> 替换为 ImmutableList<Snack>,或将 kotlin.collections.* 添加到稳定性配置文件中。
不过,这样做会导致 Compose 在每次重组时对元素逐个进行 .equals() 比较。只有在无法保留列表的实例引用且基准测试显示有益时,才应使用这些方法。
稳定性配置文件
从 Compose 编译器 1.5.5 开始,可以在编译时间提供一个配置文件,其中包含要视为稳定的类。这样,您就可以将不受您控制的类(例如 LocalDateTime 等标准库类)视为稳定类。
配置文件是一个纯文本文件,每行包含一个类。支持注释、单通配符和双通配符。
配置示例:
// Consider LocalDateTime stable
java.time.LocalDateTime
// Consider my datalayer stable
com.datalayer.*
// Consider my datalayer and all submodules stable
com.datalayer.**
// Consider my generic type stable based off it's first type parameter only
com.example.GenericClass<*,_>
如需启用此功能,请将配置文件的路径传递给 Compose 编译器 Gradle 插件配置的 composeCompiler 选项块。
composeCompiler {
stabilityConfigurationFile = rootProject.layout.projectDirectory.file("stability_config.conf")
}
由于 Compose 编译器会分别在项目中的每个模块上运行,因此您可以根据需要为不同的模块提供不同的配置。或者,在项目的根级别设置一个配置,并将该路径传递给每个模块。
多个模块
另一个常见问题涉及多模块架构。只有当类引用的所有非基元类型都明确标记为稳定,或者位于同样使用 Compose 编译器构建的模块中时,Compose 编译器才能推断该类是否稳定。
如果您的数据层位于与界面层不同的模块中(这是推荐的方法),您可能会遇到此问题。在强跳过模式下,非 Compose 模块中的类会被视为不稳定,并且在传递同一实例时,仍会使用实例相等性 (===) 跳过重组。不过,如果您的数据层经常分配具有相同值的小型扁平模型的新实例,您可以在每次发出时选择启用结构相等性 (Object.equals())。
解决方案
如需解决此问题,您可以采取以下任一方法:
- 将具有低成本
.equals()检查的小型扁平类添加到编译器配置文件中。 - 在数据层模块上启用 Compose 编译器,或在适当的位置使用
@Stable或@Immutable标记类。- 这涉及向数据层添加 Compose 依赖项。不过,它只是 Compose 运行时的依赖项,而不是
Compose-UI的依赖项。
- 这涉及向数据层添加 Compose 依赖项。不过,它只是 Compose 运行时的依赖项,而不是
- 在界面模块中,将数据层类封装在界面专用封装容器类中。
如果外部库不使用 Compose 编译器,那么在使用外部库时也会出现同样的问题。
并非每个可组合项都应可跳过
在尝试解决稳定性问题时,您不应尝试让每个可组合项都可跳过,也不应强制每个参数都具有结构相等性。跳过可组合项并非没有代价:可跳过的可组合项的每个形参都会由 Compose 运行时存储,就像被记住了一样,并且每次函数重组时,Compose 都必须将每个形参与其先前的值进行比较,以确定是否应跳过。尝试在所有位置强制跳过可能会导致过早优化,从而引入比节省的开销更多的开销。
在许多情况下,强制实现结构稳定性没有任何实际好处,反而会降低性能或可维护性。例如:
- 不经常重组或根本不重组的可组合项。
- 一个可组合项,其本身仅调用可跳过的可组合项。
- 具有大量参数的可组合项或具有开销较大的
.equals()实现的集合模型。在这种情况下,检查是否有任何参数发生更改的费用可能会超过低成本重组的费用。
请勿仅根据重组次数进行过度索引。重点是使可组合项轻量化,以便于执行,将快速变化的状态读取操作推迟到布局或绘制阶段,并使用基准测试验证更改。如果可组合项可跳过,则会增加少量开销,这可能不值得。如果您确定可重启或可跳过带来的开销大于其价值,甚至可以将可组合项注解为不可重启或不可跳过。