文章作者:Ben Trengrove
原文链接:Jetpack Compose Stability Explained | by Ben Trengrove | Android Developers | Medium
©️一切版权归作者所有,本译文仅用于技术交流请勿用于商业用途,违者后果自负
你是否曾经测量过可组合项的性能并发现它重组的次数比你预期的要多?你可能会问:“难道Compose的意义不就是状态没有发生变化的时候智能地跳过那些重组吗”。或者在阅读代码时,你可能会看到使用了@Stable
或者@Immutable
注释的类,并且想知道这是什么意思?这些概念都可以使用Compose的稳定性(Stable)来解释。在这篇博文中,我们将了解Compose稳定性的实际含义、如何调试它以及你是否应该担心它。
摘要
- Compose 查看可组合项的每个参数的稳定性,以确定在重组期间是否可以跳过它。
- 如果你注意到你的可组合项没有被跳过并且它导致了性能问题,你应该首先检查不稳定的明显原因,例如 var 参数。
- 你可以使用编译器报告来确定所推断的关于你的类的稳定性。
- 像
List
、Set
和Map
这样的集合类总是被确定为不稳定的,因为不能保证它们是不可变的。你可以改用 Kotlinx 不可变集合,或将你的类注释为@Immutable
或@Stable
- 来自未运行 Compose 编译器的module的类始终被确定为不稳定。添加 compose 运行时的依赖,并在你的模块中将它们标记为稳定,或根据需要将类包装在 UI model类中
- 每个可组合项都应该是可跳过的吗?不。
什么是重组(recomposition)?
在讨论稳定性之前,让我们快速回顾一下重组的定义:
重组是当入参发生变化时再次调用可组合函数的过程。当函数的入参发生时,就会发生这种情况。当Compose根据新输入进行重组(recomposition)时,它只会调用可能已更改的函数或lambda,并跳过其余部分。通过跳过所有没有更改参数的函数或 lambda,Compose 可以高效地重组。
注意那里的关键词——“可能”。 Compose 将在快照状态更改时触发重组,并跳过任何未更改的可组合项。重要的是,只有当 Compose 可以确定可组合项的所有参数都没有更新时,才会跳过可组合项。否则,如果 Compose 不能确定所有参数都没有更新时,它总是会在其父可组合项被重组时被重组。如果 Compose 不这样做,可能会导致不能正确触发重组的错误。正确但性能稍差比不正确但性能稍快要好得多。
让我们使用一个显示联系人详细信息的Row
示例:
fun ContactRow(contact: Contact, modifier: Modifier = Modifier) {
var selected by remember { mutableStateOf(false) }
Row(modifier) {
ContactDetails(contact)
ToggleButton(selected, onToggled = { selected = !selected })
}
}
使用不可变(immutable)对象
首先,假设我们将 Contact
类定义为不可变数据类,因此如果不创建新对象就无法更改它的数值:
data class Contact(val name: String, val number: String)
单击ToggleButton
按钮时,我们会更改选择状态。这会触发 Compose 评估是否应重构 ContactRow
中的代码。当涉及到 ContactDetails
可组合项时,Compose 将跳过重新组合它。这是因为它可以看到没有任何参数(在本例中为联系人)发生变化。另一方面,ToggleButton
的输入已更改,因此可以正确重组。
使用可变(mutable)对象
如果我们的 Contact
类是这样定义的呢?
data class Contact(var name: String, var number: String)
现在我们的 Contact
类不再是不可变的,它的属性可以在 Compose 不知道的情况下改变。 Compose 将不再跳过 ContactDetails
可组合项,因为该类现在被视为“不稳定”(下文将详细介绍这意味着什么)。因此,只要所选内容发生更改,ContactRow
也将重新组合。
Compose 编译器中的实现
现在我们知道了Compose试图在确认什么(译者:指的是对象的可变性),让我们看看这实际是如何实现的。
首先,这是Compose 文档 (1, 2) 中的一些定义。
方法(Functions)可以可跳过(skippable)或者可重启(restartable):
可跳过(Skippable)——在重组期间调用时,如果所有参数都等于它们之前的值,则 Compose 能够跳过该函数。 。
可重启(Restartable)——此函数可以作为重组作用域(换句话说,此函数可以用作 Compose 可以在状态更改后开始重新执行代码以进行重组的入口点)。
类型可以是不可变(Immutable)的或者稳定(Stable)的
不可变——表示一种类型,其中任何属性的值在构造对象后都不会改变,并且所有方法都是引用透明的。所有基本类型(
String
、Int
、Float
等)都被认为是不可变的。稳定——表示一种类型是可变的,但如果任何公共属性或方法行为会产生与先前调用不同的结果,Compose 运行时将收到通知(译者:虽然对象内部的数值虽然会发生变化,但是这种变化可以被Compose识别)。
当 Compose 编译器在你的代码的编译阶段时,它会查看每个函数和类型并标记任何与这些定义匹配的函数和类型。 Compose 查看传递给可组合项的类型以确定该可组合项的可跳过性(skippability)。重要的是要注意参数不必是不可变(Immutable)的,只要将所有更改通知 Compose 运行时,它们就可以是可变的(译者:即类也可以是稳定的)。对于大多数类型来说,这将是一个没什么意义的约定,但是 Compose 提供了可变类来为你维护这个约定,例如 MutableState
、SnapshotStateMap
/List
/等。因此,将这些类型用于可变属性将允许您的类维护 @Stable 的契约。在实践中,这看起来像下面这样:
@Stable
class MyStateHolder {
var isLoading by mutableStateOf(false)
}
当Compose状态变化时,Compose会在树中读取这些状态对象的点上寻找最近的可组合函数。理想情况下,这将是重新运行尽可能小的代码的直接祖先。正因为这样,重组重启时,如果参数未改变,任何可跳过的函数都将被跳过。让我们重新看看之前的例子:
data class Contact(val name: String, val number: String)
fun ContactRow(contact: Contact, modifier: Modifier = Modifier) {
var selected by remember { mutableStateOf(false) }
Row(modifier) {
ContactDetails(contact)
ToggleButton(selected, onToggled = { selected = !selected })
}
}
代码中,当 selected
发生变化时,距离被读取的状态(stable)最近的重组作用域是ContactRow
。你可能想知道为什么 Row
没有被选为最近的重组作用域?Row
(以及许多其他基础可组合项,如 Column
和 Box
)实际上是一个内联函数(inline function),内联函数不是重组作用域,因为它们在编译后实际上并没有最终成为函数。 因此ContactRow
顺位成为最小的重组范围。因为Contact
被推断为不可变,所以ContactDetails
被标记为可跳过,Compose编译器添加的代码会检查任何可组合项参数已更改。
当contact
保持不变时,ContactDetails
会跳过重组。接下来,点击ToggleButton
,虽然ToggleButton
是可以被跳过的,但是这种情况下就不会被跳过了,因为其中一个参数,selected已经改变了,因此会导致ToggleButton
被重新执行。这会整个重组作用域被重新执行,完成了一次重组。
重组图解:miro.medium.com/v2/resize:f…
你可能会觉得,“这真的很复杂!为什么我需要知道这个?!”答案是,大多数时候你不应该这样做,我们的目标是让编译器优化您自然编写的代码以提高效率。跳过可组合函数是实现这一点的重要因素,但它也需要 100% 安全,否则会导致很难确定的bug。为此,对要跳过的可组合项的要求是很强的。我们正在努力改进编译器对可跳过性的推断,但总会有编译器无法解决的情况。了解在这种情况下跳过可组合项的工作原理可以帮助您提高性能,但只有在您遇到由稳定性(stability)引起的明显的性能问题时才应考虑。如果可组合项是轻量级的或本身仅包含可跳过的可组合项,则不可跳过的可组合项可能根本没有任何效果。(译者:如果不是遇到了很严重的性能问题,或者可组合项很轻量,则不必考虑稳定性带来的问题)
调试稳定性
如何知道你的可组合项是否被跳过?你可以在Layout Inspector中看到它! Android Studio Dolphin 在 Layout Inspector 中包含对 Compose 的支持,它还会显示您的可组合项被重组和跳过的次数。
Layout Inspector中的重组次数
那么,如果你看到你的可组合项没有被跳过,即使它的参数都没有改变,您会怎么做?最简单的方法是检查它的定义,看看它的任何参数是否明显可变。你是否传递了具有 var 属性或 val 属性但具有已知不稳定类型的类型?如果是,那么该可组合项将永远不会被跳过!
但是,当你无法发现任何明显错误时,你会怎么做?
Compose编译器报告
Compose编译器可以输出其稳定性推断的结果以供检查。结合检查报告,你可以确定哪些可组合项是可跳过的,哪些不是。这篇文章总结了如何使用这些报告,但有关这些报告的详细信息,请参阅 技术文档。
⚠️ 警告:只有当你确实遇到与稳定性相关的性能问题时,才应使用此技术。试图让你的整个 UI 都可以跳过是过早优化,可能会导致未来的维护困难。在针对稳定性进行优化之前,请确保你遵循我们关于 Compose 性能的 最佳实践。
默认情况下不启用编译器报告。通过使用compiler flag来开启Compose编译器报告,具体设置因项目而异,但对于大多数项目,你可以将以下脚本粘贴到根 build.gradle 文件中。
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
if (project.findProperty("composeCompilerReports") == "true") {
freeCompilerArgs += [ "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" + project.buildDir.absolutePath + "/compose_compiler" ]
}
if (project.findProperty("composeCompilerMetrics") == "true") {
freeCompilerArgs += [ "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" + project.buildDir.absolutePath + "/compose_compiler" ]
}
}
}
}
要调试可组合项的稳定性,你可以运行以下task:
./gradlew assembleRelease -PcomposeCompilerReports=true
⚠️ 警告:确保始终在发布版本(release build )上运行它以确保准确的结果。
此任务将输出三个文件。 (包括来自Jetsnack的示例输出)
-classes.txt — 关于此模块中类的稳定性的报告。 案例。
-composables.txt — 关于此模块中可组合项的可重启性和可跳过性的报告。案例.
-composables.csv — 上述文本文件的 csv 版本,用于导入电子表格或通过脚本处理。 案例.
如果你改为运行 composeCompilerMetrics
任务,你将获得项目中可组合项数量的总体统计信息和其他类似信息。这在这篇文章中没有涉及,因为它对调试没有那么有用。 打开 composables.txt
文件,你将看到该模块的所有可组合函数,并且每个函数都将标记它们是否可重新启动、可跳过及其参数的稳定性。这是来自Jetsnack 的一个假设示例,它是 Compose 示例应用程序之一。
restartable skippable scheme(“[androidx.compose.ui.UiComposable]”) fun SnackCollection(
stable snackCollection: SnackCollection
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
stable index: Int = @static 0
stable highlight: Boolean = @static true
)
此 SnackCollection
可组合项完全可重启、可跳过且稳定。在可能的情况下,这通常是你想要的,尽管远非强制性的(博文末尾有更多详细信息)。
但是,让我们看另一个例子。
restartable scheme(“[androidx.compose.ui.UiComposable]”) fun HighlightedSnacks(
stable index: Int
unstable snacks: List<Snack>
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
)
HighlightedSnacks
可组合项是不可跳过的——只要在重组期间调用它,即使它的参数都没有改变,它也会重组。 这是由不稳定的参数snacks
引起的。
现在我们来到 classes.txt 文件来检查 Snack
的稳定性。
unstable class Snack {
stable val id: Long
stable val name: String
stable val imageUrl: String
stable val price: Long
stable val tagline: String
unstable val tags: Set<String>
<runtime stability> = Unstable
}
作为参考,这是 Snack
的声明方式
data class Snack(
val id: Long,
val name: String,
val imageUrl: String,
val price: Long,
val tagline: String = "",
val tags: Set<String> = emptySet()
)
snacks
是不稳定的。它的绝大部分参数都是稳定的,但tags
被认为是不稳定的。但这是为什么呢? Set
看起来是不可变的,它不是 MutableSet
。 不幸的是,Set(以及 List
和其他标准集合类,稍后会详细介绍)在 Kotlin 中被定义为接口,这意味着底层实现可能仍然是可变的。例如,你可以写:
val set: Set<String> = mutableSetOf(“foo”)
变量是常量,它声明的类型不是可变的,但它的实现仍然是可变的。 Compose 编译器无法确定此类的不变性,因为它只看到声明的类型,因此将其声明为不稳定的。现在让我们看看如何使它稳定。
让不稳定稳定(Stabilizing the unstable)
当不稳定类导致了性能问题时,尝试使其稳定是个好主意。首先要尝试的是让类完全不可变。
不可变——表示一种类型,其中任何属性的值在构造对象后都不会改变,并且所有方法都是引用透明的。所有基本类型(
String
、Int
、Float
等)都被认为是不可变的。
换句话说,将所有 var 属性设为 val,并将所有这些属性设为不可变类型。
如果你无法实现上述要求,那你将不得不对任何可变属性使用 Compose State。
稳定——表示一种类型是可变的,但如果任何公共属性或方法行为会产生与先前调用不同的结果,Compose 运行时将收到通知(译者:虽然对象内部的数值虽然会发生变化,但是这种变化可以被Compose识别)。
这意味着在实践中,任何可变属性都应该由 Compose 状态支持,例如 mutableStateOf(…)
。
回到 Snack
示例,该类看起来是不可变的,那么我们如何解决它呢?
你可以采取以下方法:
Kotlinx 不可变集合(Immutable Collections)
Compose 编译器的 1.2 版包括对 Kotlinx Immutable Collections的支持。这些集合保证是不可变的,并且将由编译器推断为不可变的。该库仍处于 alpha 阶段,因此预计其 API 可能会发生变化。你应该评估这对你的项目是否可以接受。
将tags
的类型改变为下面这种类型可以让Snack
稳定
val tags: ImmutableSet<String> = persistentSetOf()
使用Stable或者Immutable注释
根据上述规则,类也可以使用 @Stable 或 @Immutable 进行注释。
⚠️ 警告:非常需要注意的是,这是一个约定,要遵循相应的注解规则。它本身不会使类不可变/稳定。错误地使用注释可能会导致重组失败。
注释一个类会覆盖编译器对你的类的推断,这样它类似于kotlin的!!运算符。你应该非常小心这些注释的使用,因为如果你弄错了,覆盖编译器行为可能会导致你出现无法预料的错误。如果可以在没有注释的情况下使您的类稳定,那么你应该努力以这种方式实现稳定。
正确注释Snack
的方式如下:
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
@Immutable
data class Snack(
val id: Long,
val name: String,
val imageUrl: String,
val price: Long,
val tagline: String = "",
val tags: Set<String> = emptySet()
)
无论选择哪种方法,Snack
类都将被推断为稳定的。
但是,回到 HighlightedSnacks
可组合项,HighlightedSnacks
仍未标记为可跳过:
unstable snacks: List<Snack>
当涉及到集合类型时,参数面临与类相同的问题,List
总是被确定为不稳定的,即使它是稳定类型的集合。 你也不能将单个参数标记为稳定,也不能将可组合项注释为始终可跳过。所以,你可以做什么?同样,也有很多种方法解决这个问题。
使用Kotlinx 不可变集合而不是List
:
/* Copyright 2022 Google LLC.
SPDX-License-Identifier: Apache-2.0 */
@Composable
private fun HighlightedSnacks(
index: Int,
snacks: ImmutableList<Snack>,
onSnackClick: (Long) -> Unit,
modifier: Modifier = Modifier
)
如果你不能使用不可变集合,你可以在最简单的情况下将List
包装在带注释的稳定类中,以将其标记为对 Compose 编译器不可变。
@Immutable
data class SnackCollection(
val snacks: List<Snack>
)
然后,你可以将其用作可组合项中的参数类型。
@Composable
private fun HighlightedSnacks(
index: Int,
snacks: SnackCollection,
onSnackClick: (Long) -> Unit,
modifier: Modifier = Modifier
)
在采用其中任何一种方法后,HighlightedSnacks
可组合项现在既可以跳过也可以重新启动。
restartable skippable scheme(“[androidx.compose.ui.UiComposable]”) fun HighlightedSnacks(
stable index: Int
stable snacks: ImmutableList<Snack>
stable onSnackClick: Function1<Long, Unit>
stable modifier: Modifier? = @static Companion
)
HighlightedSnacks
现在将在其入参均未更改时跳过重组。
多模块
你可能遇到的另一个常见问题与多模块架构有关。 Compose 编译器推断一个类是否稳定,前提是它引用的所有非原始类型都被显式标记为稳定的,而且位于Compose 编译器构建的模块中。如果你的数据层(data layer)和UI层(UI layer,)是分开的(这是推荐的方法),这可能是你会遇到的问题。要解决此问题,你可以:
- 在你的数据层模块上启用 Compose 编译器,或在适当的地方使用 @Stable 或 @Immutable 标记你的类。
- 这将涉及向数据层添加 Compose 依赖项,你只需要添加Compose运行时的依赖而不用添加Compose-UI依赖。
- 将你的数据层的类包装在你的UI层的特定包装类中。
同样的问题也会发生在外部module上,除非它们使用的是 Compose 编译器。
这是一个已知的限制,我们目前正在研究针对多模块架构和外部库的更好解决方案。
所有的重组都应该被跳过吗?
不。
追求应用中每个可组合项的完全可跳过性是不成熟的优化。可跳过实际上会增加其自身的少量开销,这可能不值得,如果你确定可重启的开销大于其价值,你甚至可以将可组合项注释为 不可重启。在许多其他情况下,可跳过不会有任何实际好处,只会导致难以维护代码。例如:
- 不经常重组或根本不重组的可组合项。
- 只是被称为可跳过但是实际上项目中没有跳过的场景的可组合项。
总结
这篇博文中有很多信息,所以让我们总结一下。
- Compose 查看可组合项的每个参数的稳定性,以确定在重组期间是否可以跳过它。
- 如果你注意到你的可组合项没有被跳过并且它导致了性能问题,你应该首先检查不稳定的明显原因,例如 var 参数。
- 你可以使用编译器报告来确定所推断的关于你的类的稳定性。
- 像
List
、Set
和 Map 这样的集合类总是被确定为不稳定的,因为不能保证它们是不可变的。你可以改用 Kotlinx 不可变集合,或将你的类注释为@Immutable
或@Stable
- 来自未运行 Compose 编译器的module的类始终被确定为不稳定。添加 compose 运行时的依赖,并在你的模块中将它们标记为稳定,或根据需要将类包装在 UI model类中
- 每个可组合项都应该是可跳过的吗?不。