全网最详细的Compose Stable讲解,你一定要看

对于已经用过 Compose 的你,一定碰到过 Compose 的重组问题------在测试可组合函数性能的时候,发现它重组的代码块、或者重组的次数比你想象的要多!

对于未来即将使用 Compose 的你来讲,Compose 的重组问题你也一定会遇到。

你可能会问:"我原以为 Compose 会在状态没有变化时智能地跳过可组合函数啊?" 或者,在阅读 Compose 代码时,你可能会看到用 @Stable@Immutable 注解的类,然后想知道它们是什么意思?

如果你已经看到这儿了,那么你一定不要错过这篇文章。

这些问题的核心,都指向 Compose 的稳定性机制。本文将围绕"稳定性"展开,结合重组原理、调试方法和实践建议,帮你彻底理解这一关键概念。

重组:高效更新的关键

在探讨稳定性之前,让我们快速回顾一下重组的定义:

重组是指当输入发生变化时,再次调用可组合函数的过程。当函数的输入改变时,就会发生这种情况。当 Compose 根据新的输入进行重组时,它只调用可能发生变化的函数或 lambda 表达式,并跳过其余部分。通过跳过所有参数未发生变化的函数或 lambda 表达式,Compose 可以高效地进行重组。

所以重组,是 Compose 性能优化的关键。

注意这里的关键词------"可能"。当状态发生变化时,Compose 会触发重组,并跳过任何未发生变化的可组合项。

重要的是,只有当 Compose 能够确定某个可组合项的所有参数都没有更新时,该可组合项才会被跳过。否则,如果 Compose 无法确定,那么当其父可组合项进行重组时,它也会始终进行重组。

如果 Compose 不这样做,就会导致很难诊断因重组未触发而产生的错误。与其错误但速度稍快,不如正确但性能稍低。

Compose 的默认重组策略,就是牺牲性能保证准确性!对于我们开发者来讲,这个策略,没毛病!

让我们以一个显示联系人详细信息的 Row 为例:

Kotlin 复制代码
fun ContactRow(contact: Contact, modifier: Modifier = Modifier) {
  var selected by remember { mutableStateOf(false) }
  Row(modifier) {
    ContactDetails(contact)
    ToggleButton(selected, onToggled = { selected = !selected })
  }
}

使用不可变对象

首先,假设我们将 Contact 类定义为一个不可变的数据类,这样在不创建新对象的情况下它就无法被更改:

kotlin 复制代码
data class Contact(val name: String, val number: String)

当切换按钮被点击时,我们会更改选中状态,即更新 selected 的值。

这会触发 Compose 来评估 ContactRow 内部的代码是否应该进行重组。

对于 ContactDetailsCompose 会跳过对其的重组。这是因为它可以看到在这种情况下,没有任何参数(这里指 contact)发生了变化。

另一方面,ToggleButton 的输入发生了变化,所以它会正确地进行重组。

使用可变对象

如果我们像这样定义 Contact 类会怎样呢?

kotlin 复制代码
data class Contact(var name: String, var number: String)

现在我们的 Contact 类不再是不可变的了,它的属性可以在 Compose 不知情的情况下被更改。

Compose 不再会跳过 ContactDetails 可组合项,因为这个类现在被认为是"不稳定的"(下面会详细说明这是什么意思)。

因此,只要 selected 发生变化, ContactDetails 也会重新组合。

Compose编译器中的实现

现在我们了解了 Compose 试图确定的理论,让我们来看看它在实践中实际是如何发生的。

首先,来看一下 Compose 是如何定义函数和类型的。

函数可以是可跳过的或可重启的:

  • 可跳过的(Skippable) :在重组期间调用时,如果所有参数与其先前的值相等,即入参无变化,Compose 能够跳过该函数。
  • 可重启的(Restartable) :此函数用作重组可以开始的"作用域"(换句话说,该函数可用作一个入口点,Compose 在状态变化后可从此处开始重新执行代码以进行重组)。

类型可以是不可变的或稳定的:

  • 不可变的(Immutable) :表示一种类型,在对象构造后,其任何属性的值永远不会改变,并且所有方法在引用上是透明的。所有原始类型(如 StringIntFloat 等)都被视为不可变的。
  • 稳定的(Stable) :表示一种可变类型,但如果任何公共属性或方法行为产生的结果与先前调用不同时,Compose 运行时将会收到通知,即 Compose 会收到来自类型中值的变更。

Compose 编译器运行代码时,它会查看每个函数和类型,并标记任何符合这些定义的项目。

Compose 会查看传递给可组合函数的类型,以确定该可组合函数的可跳过性。需要注意的是,参数不一定必须是不可变的,只要向 Compose 运行时通知所有更改,它们就可以是可变的。

对于大多数类型而言,要遵守这个约定是不切实际的。

不过,Compose 提供了一些可变类来为你遵守此约定,例如 MutableStateSnapshotStateMap/List 等。

因此,对于可变属性使用这些类型将使你的类遵守 @Stable 的约定。在实践中,这看起来会像下面这样:

Kotlin 复制代码
@Stable
class MyStateHolder {
  var isLoading by mutableStateOf(false)
}

Compose 状态发生变化时,Compose 会在读取这些状态对象的树状结构中所有节点上方,寻找最近的可重启函数。理想情况下,这将是直接祖先,以便重新运行尽可能少的代码。

重组就是从这里重新开始的。在重新执行代码时,如果可跳过函数的参数没有变化,那么这些函数将被跳过。

让我们再次看看之前的示例:

Kotlin 复制代码
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 发生变化时,实际读取状态的位置最近的 "可重启" 函数/组合作用域是 ContactRow

你可能想知道为什么 Row 没有被选为最近的可重启作用域?

如果你查看源码,你会发现 Row(以及许多其他基础可组合函数,如 ColumnBox)是一个内联函数,内联函数不是可重启作用域,因为编译后它们实际上就是代码而不是函数。

所以 ContactRow 是下一个更高的作用域,因此 ContactRow 会重新执行。它遇到的第一个可组合函数是 Row,正如前面所述,这不是一个可重启作用域,这也意味着它不可跳过,并且在每次重组时都会重新执行。

下一个可组合函数是 ContactDetailsContactDetails 被标记为可跳过,因为 Contact 类被推断为不可变的,所以由 Compose 编译器添加的生成代码会检查这个可组合函数的任何参数是否发生了变化。由于 contact 保持不变,ContactDetails 被跳过。

接下来是 ToggleButtonToggleButton 是可跳过的,但在这种情况下这并不重要,它的一个参数 selected 已经发生了变化,因此它会被重新执行。

这样我们就遍历完了整个可重启函数,重组也就结束了。

此时你可能会想:"哥们儿,这太复杂了!我为什么需要了解这些?"

大多数时候你不必了解那么多。

Compose 的目标是让编译器优化你自然编写的代码,使其达到高效。

而实现高效运行 Compose 代码这一目标的重要首单就是跳过可组合函数,但这也必须做到 100% 安全,否则就会导致极难诊断的错误(想象一下你更新某个对象了,但是 UI 上却没有变化)。

出于这个原因,对于函数可被跳过的要求是很严格的。

Compose 正在努力改进编译器对可跳过性的推断能力,但总会存在一些编译器无法判断的情况。

在这种情况下,了解函数跳过机制的内部原理有助于你提高性能,但只有在你确有因稳定性导致的性能问题时才需要考虑这一点。

如果一个可组合函数很轻量级,或者它本身只包含可跳过的可组合函数,那么它不可跳过可能根本不会产生任何影响。

调试稳定性

如何判断你的可组合函数是否被跳过了呢?你可以在 Layout Inspector 中查看!Android StudioLayout Inspector 支持 Compose,它还会显示你的可组合函数重组和跳过的次数。

那么,如果你发现即使某个可组合函数的参数都没有改变,它却没有被跳过,该怎么办呢?

最简单的做法是检查它的定义,看看它的参数中是否有明显可变的。

例如你传入的类型中是否包含 var 属性,或者是否有 val 属性但类型已知是不稳定的?

如果是这样,那么这个可组合函数永远都不会被跳过!

但如果你找不出任何明显的问题,又该怎么办呢?

Compose编译器报告

Compose 编译器可以输出其稳定性推断结果以供检查。通过这些输出,你可以确定哪些可组合函数可被跳过,哪些不可被跳过。本文总结了如何使用这些报告,但有关这些报告的详细信息,请参阅技术文档。

⚠️ 警告:只有在你实际遇到与稳定性相关的性能问题时,才应使用此技术。试图让整个用户界面的可组合函数都能被跳过,属于过早优化,可能会在未来导致维护困难。在针对稳定性进行优化之前,请确保你遵循了我们关于Compose 性能的最佳实践。

编译器报告默认是不启用的。可通过编译器标志启用它们,具体设置会因项目而异。但对于大多数项目,你可以将以下脚本粘贴到 app 目录下的 build.gradle.kts 文件中。

Kotlin 复制代码
android {

    // other code
    
    kotlinOptions {
        if (project.findProperty("composeCompilerReports") == "true")

            if (project.findProperty("composeCompilerReports") == "true") {
                freeCompilerArgs += arrayOf(
                    "-P",
                    "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
                            project.buildDir.absolutePath + "/compose_compiler"
                )
            }

        if (project.findProperty("composeCompilerMetrics") == "true") {
            freeCompilerArgs += arrayOf(
                "-P",
                "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
                        project.buildDir.absolutePath + "/compose_compiler"
            )
        }
    }
}

调试可组合函数稳定性时,可按如下方式运行该任务:

sh 复制代码
./gradlew :app:assembleRelease -PcomposeCompilerReports=true

⚠️ 警告:务必始终在 Release 版本构建上运行此程序,以确保结果准确。

此任务将在 \app\build\compose_compiler 输出三个文件:

  • <模块名称>-classes.txt --- 关于此模块中类的稳定性报告。
  • <模块名称>-composables.txt --- 关于此模块中可组合项的可重启性和可跳过性的报告。
  • <模块名称>-composables.csv --- 上述文本文件的CSV版本,用于导入电子表格或通过脚本进行处理。

如果你运行 composeCompilerMetrics 任务,你将获得项目中可组合项数量的总体统计信息以及其他类似信息。本文不涵盖此内容,因为它对调试的作用没那么大。

打开 composables.txt 文件,你会看到该模块的所有可组合函数,并且每个函数都会标记其是否可重启、可跳过以及其参数的稳定性。

我们参考 Compose 官方示例应用之一 Jetsnack,自己做一个简单版本的实例(下面的代码在原有基础上做了大量删减,仅为了研究稳定性):

Kotlin 复制代码
data class Snack(
    val id: Long,
    val name: String,
    val imageUrl: String,
    val price: Long,
    val tagline: String = "",
    val tags: Set<String> = emptySet()
)

@Immutable
data class SnackCollection(val id: Long, val name: String, val snacks: List<Snack>)

@Composable
fun SnackCollection(
    modifier: Modifier = Modifier,
    snackCollection: SnackCollection,
) {
    Column(modifier = modifier) {
        Text(
            text = snackCollection.name,
            maxLines = 1,
        )
        HighlightedSnacks(index = 0, snacks = snackCollection.snacks, modifier = Modifier)
    }
}

@Composable
private fun HighlightedSnacks(
    index: Int,
    snacks: List<Snack>,
    modifier: Modifier = Modifier,
) {
    Text(text = "$index")
    LazyRow(
        modifier = modifier,
    ) {
        itemsIndexed(snacks) { index, snack ->
            Text(
                text = snack.name
            )
        }
    }
}

我们看下 SnackCollectioncomposables.txt 中的申明:

Kotlin 复制代码
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SnackCollection(
  stable modifier: Modifier? = @static Companion
  stable snackCollection: SnackCollection
)

这个 SnackCollection 可组合函数完全可重启、可跳过且稳定。在可能的情况下,这通常是你所期望的,不过并不是对所有函数都有这样的强制要求(本文末尾有更详细的说明)。

不过,我们来看看另一个例子:

Kotlin 复制代码
restartable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable modifier: Modifier? = @static Companion
)

HighlightedSnacks 可组合函数不可跳过。

在重组期间,无论何时调用此函数,它都会进行重组,即使其参数没有任何变化。

这是由不稳定的参数 snacks 导致的。

现在我们可以切换到 classes.txt 文件,以检查 Snack 的稳定性。

Kotlin 复制代码
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 是不稳定的。它的大多数参数是稳定的,但 tags 集合被认为是不稳定的。

但这是为什么呢?Set 看起来是不可变的,它又不是 MutableSet

遗憾的是,Set(以及 List 和其他标准集合类,稍后会详细介绍)在 Kotlin 中被定义为接口,这意味着其底层实现可能仍然是可变的。例如,你可以这样写:

Kotlin 复制代码
val set: Set<String> = mutableSetOf("foo")

该接口虽然是不可变的,但它的实现却是可变的。


Compose 编译器无法确定这个类的不可变性,因为它只看到声明类型,因此将其声明为不稳定的。现在让我们来看看如何使其稳定。

从不稳定到稳定

当遇到一个导致性能问题的不稳定类时,尝试让它变得稳定是个不错的主意。首先要尝试的就是让这个类完全不可变。

  • 不可变(Immutable) --- 指一种类型,其任何属性的值在对象构造之后都不会改变,并且所有方法都具有引用透明性。所有基本类型(如 StringIntFloat 等)都被视为不可变的。

换句话说,将所有 var 属性改为 val,并且让所有这些属性都是不可变类型。

如果这不可行,那么对于任何可变属性,你将不得不使用 Compose 状态。

  • 稳定(Stable) --- 指一种可变的类型,但是如果任何公共属性或方法行为产生的结果与之前的调用不同时,Compose 运行时将会收到通知。

这在实际中意味着,任何可变属性都应该由 Compose 状态支持,例如 mutableStateOf(...)

回到 Snack 的示例,这个类看似不可变,那么我们该如何修复它呢?

你可以采取多种方法。

Kotlinx-immutable-collection

Compose 编译器 1.2 版本支持不可变集合。这些集合保证是不可变的,并且编译器也会如此推断。

tags 的声明改为如下形式,可使 Snack 类变得稳定。

Kotlin 复制代码
val tags: ImmutableSet<String> = persistentSetOf() 

如此更改之后,我们再看 Snack

Kotlin 复制代码
stable class Snack {
  stable val id: Long
  stable val name: String
  stable val imageUrl: String
  stable val price: Long
  stable val tagline: String
  stable val tags: ImmutableSet<String>
  <runtime stability> = 
}

此时,Snack 已经是稳定的了。

使用 @Stable 或 @Immutable 进行注解

根据上述规则,类也可以用 @Stable@Immutable 进行注解。

⚠️ 警告:非常重要的一点是,这是一份遵循注解相应规则的契约。它本身并不会使类成为不可变/稳定类。对类进行错误的注解可能会导致重组失败 。

对类进行注解是在覆盖编译器对类的推断结果,从这个角度讲,这类似于 Kotlin 中的 !! 运算符。

使用这些注解时要格外小心,因为如果错误地覆盖了编译器的行为,可能会导致不可预见的错误。

如果不使用注解就能使类保持稳定,那么就应该努力通过那种方式实现稳定性。

Snack 示例进行注解的方式如下:

Kotlin 复制代码
@Immutable
data class Snack(
  val id: Long,
  val name: String,
  val imageUrl: String,
  val price: Long,
  val tagline: String = "",
  val tags: Set<String> = emptySet()
)

输出:

Kotlin 复制代码
stable 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>
}

Snack 稳定了,不过细心的你可能发现了,tags 依然是不稳定的(不过这个并不影响什么)。


上述方法无论选择哪种,Snack 类都会被推断为 Stable

然而,回过头来看 HighlightedSnacks 可组合函数,HighlightedSnacks 仍未标记为可跳过:

Kotlin 复制代码
restartable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  unstable snacks: List<Snack>
  stable modifier: Modifier? = @static Companion
)

看看 HighlightedSnacks 的第二个参数。

在集合类型方面,参数和类面临同样的问题。即使 List 是由稳定类型组成的集合,它也总是被判定为不稳定。

你既不能将单个参数标记为稳定,也不能对可组合函数进行注解使其始终可跳过。那么该怎么做呢?同样,有多种解决办法。

使用 kotlinx 的不可变集合,而不是直接使用 List

diff 复制代码
  @Composable
  private fun HighlightedSnacks(
    index: Int,
-   snacks: List<Snack>,
+   snacks: ImmutableList<Snack>,
    onSnackClick: (Long) -> Unit,
    modifier: Modifier = Modifier
  )

如果你不能使用不可变集合,在最简单的情况下,你可以将列表包装在一个带有注解的稳定类中,以便向 Compose 编译器将其标记为不可变的。不过,根据你的需求,你很可能需要为此创建一个通用的包装器:

Kotlin 复制代码
@Immutable
data class SnackCollection(
  val snacks: List<Snack>
)

采取上述任何一种方法后,HighlightedSnacks 可组合函数现在既可以跳过也可以重启:

Kotlin 复制代码
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun HighlightedSnacks(
  stable index: Int
  stable snacks: ImmutableList<Snack>
  stable modifier: Modifier? = @static Companion
)

现在,当 HighlightedSnacks 的输入都没有变化时,它将跳过重组。

跳过是万能药吗?

或者这么问,每个可组合函数都应该是可跳过的吗?

不!

为应用中的每个可组合函数追求完全可跳过性属于过早优化。可跳过性实际上本身也会带来一点小开销,可能并不值得。

在你确定可重启性带来的开销超过其价值的情况下,你甚至可以将可组合函数标注为不可重启。还有很多其他情况,可跳过性不会带来任何实际好处,只会导致代码难以维护。

例如:

  • 很少重新组合或根本不会重新组合的可组合函数。
  • 本身仅调用可跳过的可组合函数的可组合函数 。

新版本的改动

如果你在发出这篇文章的时候,尝试自己测试上面的代码,你可能会发现 HighlightedSnacks 一开始就是可重启可跳过的,原因就是新版本的 Compose 编译器自动启用了 强制跳过(strong skipping) 模式。

强制跳过模式会放宽 Compose 编译器通常在跳过和可组合函数方面应用的一些稳定性规则。默认情况下,如果可组合函数的所有参数都具有稳定的值,Compose 编译器会将其标记为可跳过。强力跳过模式会改变这一点。

启用强制跳过后,所有可重启的可组合函数都将变为可跳过。无论这些广告系列是否包含不稳定的参数,都适用此规则。不可重启的可组合函数仍然无法跳过。

由于篇幅原因,这里不做过多的展开,如果你想自己测试上面的代码,记得手动关闭这个模式:

Kotlin 复制代码
composeCompiler {
    enableStrongSkippingMode = false
}

总结

这篇文章信息量过高,下面我们来总结一下。

  • Compose 会判断可组合函数每个参数的稳定性,以确定在重组期间它是否可以跳过。
  • 如果你发现你的可组合函数没有被跳过,并且这导致了性能问题,你应该首先检查像 var 参数这类明显的不稳定因素。
  • 你可以使用编译器报告来确定你的类被推断为何种稳定性。
  • ListSetMap 这样的集合类总是被判定为不稳定,因为无法保证它们是不可变的。你可以改用 Kotlinx 不可变集合,或者将你的类标注为@Immutable@Stable
  • 每个可组合函数都应该是可跳过的吗?不。
相关推荐
yeziyfx24 分钟前
kotlin中集合的用法
android·开发语言·kotlin
EngZegNgi2 小时前
安卓应用启动崩溃的问题排查记录
android·crash·启动崩溃
火柴就是我3 小时前
每日见闻之Container Decoration
android·flutter
天枢破军3 小时前
【AOSP】解决repo拉取提示无法连接android.googlesource.com
android
whysqwhw3 小时前
OkHttp之AndroidPlatform类分析
android
XiaolongTu3 小时前
Kotlin Flow详述:从一个“卡顿”问题到线程切换的本质
android·面试
solo_993 小时前
使用Android Studio 聊微信
android
whysqwhw3 小时前
OkHttp PublicSuffix包的平台化设计分析
android
whysqwhw3 小时前
Conscrypt 源码分析全图解(附精要讲解)
android