当大量团队开始使用Jetpack Compose的时候, 团队中的大多数人最终会发现还缺少一块拼图: 测量Composable函数的性能.
在Jetpack Compose 1.2.0中, Compose编译器添加了一项新功能, 它可以在构建时输出各种与性能相关的指标, 让我们可以窥探幕后, 了解潜在的性能问题所在. 在这篇文中, 我们将探讨新指标, 看看能发现什么.
在开始阅读之前, 有一些事情需要了解:
- 这将是一篇很长的文章, 涵盖了大量Compose的工作原理. 请慢慢阅读.
- 为了让大家有所期待, 在这篇文章的最后, 我们并没有取得任何成果😅. 不过, 希望你能更好地理解你的设计选择会如何影响Compose的工作方式.
- 如果你不能马上理解这里的所有内容, 也不要难过, 因为这是一个高级话题! 如果你有任何不确定的地方, 我将为你提供进一步阅读的资源.
- 我们在这里讨论的一些内容可以看作是
微优化
. 与任何涉及优化的任务一样: 首先进行配置文件和测试! 新的JankStats库是一个很好的切入点. 如果你对真实设备的性能不觉着有问题, 那么你在这里可能没有太多事情要做.
说完这些, 我们开始吧... 🏞
启用度量指标
第一步是通过一些编译器标记启用新的编译器指标. 对于大多数应用程序来说, 在所有模块上启用它的最简单方法是使用全局开关.
你可以在根build.gradle
中粘贴以下内容:
groovy
subprojects {
tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
kotlinOptions {
if (project.findProperty("myapp.enableComposeCompilerReports") == "true") {
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
]
freeCompilerArgs += [
"-P",
"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
project.buildDir.absolutePath + "/compose_metrics"
]
}
}
}
}
这样, 只要运行启用了myapp.enableComposeCompilerReports
属性的 Gradle 构建, 就会启用必要的Kotlin编译器标记, 就像这样:
terminal
./gradlew assembleRelease -Pmyapp.enableComposeCompilerReports=true
一些注意事项:
- 请务必在发布版本上运行此操作. 稍后我们将了解原因.
- 你可以随心所欲地重命名
myapp.enableComposeCompilerReports
属性. - 你可能会发现需要使用
--rerun-tasks
运行上述命令, 以确保即使在缓存的情况下也能运行Compose编译器.
度量指标和报告将被写入每个模块构建目录下的compose_metrics
文件夹. 对于典型的设置, 该文件夹位于<module_dir>/build/compose_metrics
中. 如果打开其中一个文件夹, 你会看到类似下面的内容:
编译器度量指标的输出结果
*注意: 从技术上讲, 报告(module.json
)和指标(其他3个文件)是分开启用的. 为了方便起见, 我将它们合并为一个标记, 并设置输出到同一目录. 如果需要, 也可以将它们分开. *
解读报告
如上所示, 每个模块都会输出 4 个文件:
module-module.json
, 其中包含一些总体统计数据.module-composables.txt
, 包含每个函数声明的详细输出.module-composables.csv
, 这是文本文件的表格版本.module-classes.txt
, 包含从Composable函数中引用的类的稳定性信息.
本文将不深入介绍所有文件的内容. 为此, 我建议你阅读"解读Compose编译器指标"文档, 我们将在本文中引用该文档:
相反, 我将逐步查看上述文档中需要注意的事项部分列出的金块👑信息, 看看我的Tivi应用的模块中会出现什么.
我要研究的模块是ui-showdetails
模块, 它包含Show Details
页面的所有用户界面. 这是我在2020 年4月转换到Jetpack Compose的第一批模块之一, 因此我确信还有一些地方需要改进!
好了, 首先要注意的是...
可重启
但不可跳过
的函数
首先, 让我们来定义可组合函数中的restartable
和skippable
这两个术语.
在学习Compose的过程中, 你会了解到重组, 因为它是 Compose工作的基础:
重组是在输入发生变化时再次调用可组合函数的过程. 当函数的输入发生变化时, 就会发生这种情况. 当Compose根据新的输入进行重新组合时, 它只会调用可能发生变化的函数或lambdas, 而跳过其他函数或lambdas...
Restartable
restartable
函数是重组的基础. 当Compose检测到函数的输入发生变化时, Compose会根据新的输入重新启动(重新调用)函数.
深入探讨一下Compose的工作原理, 可重启函数标志着组合的边界. 读入快照
(比如MutableState
)的作用域
非常重要, 因为它定义了当快照发生变化时, 哪些代码块会被重启. 理想情况下, 快照变化会在最接近的函数/lambda中触发重启, 从而允许重新运行最少的代码. 如果宿主代码块不可重启, 那么 Compose 就需要在代码树中向上遍历, 找到最近的可重启的祖先作用域
. 这可能意味着很多函数都需要重新运行. 实际上, 几乎所有@Composable函数都是可重启的(restartable
).
Skippable
如果Compose认为自上次调用后函数参数没有发生变化, 就可以完全跳过调用该函数, 那么该可组合函数就是skippable
的. 这对top-level
可组合函数的性能尤为重要, 因为它们往往位于函数调用大树的顶端. 如果Compose可以跳过top-level
调用, 那么下面的函数也无需调用.
在实践中, 我们的目标是尽可能多地跳过可组合函数, 让Compose能够智能重组
.
在Compose中, 如何定义参数值是否发生了变化是一个比较棘手的问题, 在这里我们又引入了两个术语: 稳定性和不变性.
不变性和稳定性
可重启和可跳过是函数的Compose属性, 而不变性和稳定性则是对象实例的属性, 特别是传递给可组合函数的对象.
对象不可变意味着"所有可公开访问的属性和字段在实例构建后不会改变". 这一特性意味着Compose可以非常容易地检测到两个实例之间的"变化".
另一方面, 稳定的对象并不一定是不可变的. 一个稳定的类可以容纳可变数据, 但所有可变数据在发生变化时都需要通知Compose, 以便在必要时进行重组.
当Compose检测到所有函数参数都是稳定或不可变的, 它就能在运行时进行一系列优化, 这也是函数能够跳过的关键所在. Compose会尝试自动推断类是否不可变或稳定, 但有时它无法正确推断. 遇到这种情况, 我们可以在类上使用@Immutable
和@Stable
注解.
简单解释了这些术语后, 让我们开始探索度量标准.
探索度量标准
我们将从module.json
文件开始, 了解整体统计数据:
json
{
"skippableComposables": 64,
"restartableComposables": 76,
"readonlyComposables": 0,
"totalComposables": 76
}
我们可以看到该模块包含76个可组合函数: 其中所有函数都可重启, 64个可跳过, 剩下12个可重启但不可跳过.
现在我们需要找出这些函数. 我们有两种方法: 一是查看composables.txt
文件, 二是导入composables.csv
文件并将其作为电子表格查看. 我们稍后再看文本文件, 现在先看电子表格.
将CSV文件导入电子表格工具后, 最终结果如下所示:
过滤可组合函数列表后(工作表上有一个不可跳过
过滤视图), 我们可以轻松找到不可跳过的函数:
kotlin
ShowDetails()
ShowDetailsScrollingContent()
PosterInfoRow()
BackdropImage()
AirsInfoPanel()
Genres()
RelatedShows()
NextEpisodeToWatch()
InfoPanels()
SeasonRow()
跳过函数
现在我们的工作是依次检查每个函数, 并确定它们不可跳过的原因. 如果我们回到文档中, 它是这样说的:
如果你看到一个函数可重启但不可跳转, 这并不总是一个坏兆头, 但有时是做以下两件事之一的机会:
1. 确保函数的所有参数都稳定, 由此该函数可跳过. 2. 通过将函数标记为
@NonRestartableComposable
使其不可重启
现在, 我们将专注于第一点. 让我们继续查看composables.txt
文件, 找到其中一个不可跳转的可重组函数, 即AirsInfoPanel()
:
kotlin
restartable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
unstable show: TiviShow
stable modifier: Modifier? = @static Companion
)
我们可以看到, 该函数有两个参数: modifier
参数是稳定
的 (👍), 但show
参数是不稳定
的 (👎), 这很可能导致Compose确定该函数不可跳转. 但现在问题来了: 为什么Compose编译器会认为TiviShow
是不稳定?TiviShow
只是一个只包含不可变数据的数据类. 🤔
classes.txt
理想情况下, 我们可以在这里引用module-classes.txt
文件, 以深入了解该类被推断为不稳定的原因. 遗憾的是, 该文件的输出似乎有点零散. 在某些模块中, 我可以看到必要的输出, 但在另一些模块中, 输出则完全为空(本模块就是如此).
不过, 我们可以看到另一个模块的示例, 它看起来非常有用:
kotlin
unstable class WatchedViewState {
unstable val user: TraktUser?
stable val authState: TraktAuthState
stable val isLoading: Boolean
stable val isEmpty: Boolean
stable val selectionOpen: Boolean
unstable val selectedShowIds: Set<Long>
stable val filterActive: Boolean
stable val filter: String?
unstable val availableSorts: List<SortOption>
stable val sort: SortOption
unstable val message: UiMessage?
<runtime stability> = Unstable
}
从输出的classes.txt
来看, Compose编译器似乎只能对启用Compose编译器编译的类推断出不变性和稳定性. Tivi中的大多数模型类都是在标准Kotlin模块中构建的(即不使用Android或Compose), 然后在整个应用程序中使用. 外部库中使用的类(如ViewModel
)也是类似情况.
遗憾的是, 如果不做额外的工作, 我们现在似乎没有太多办法解决这个问题. 理想情况下, Compose使用的注解(即@Stable
)会被分离到一个纯Kotlin库中, 这样我们就能在更多地方使用它们(必要时甚至可以在Java库中使用).
封装类
如果你发现你的可组合物件处于性能的热门路径中, 而启用可跳过性是实现无抖动性能的关键, 那么你可以将错误推断的稳定对象封装在一个封装类中, 例如:
kotlin
@Stable
class StableHolder<T>(val item: T) {
operator fun component1(): T = item
}
@Immutable
class ImmutableHolder<T>(val item: T) {
operator fun component1(): T = item
}
缺点是你之后需要在可组合函数声明中使用这些类:
kotlin
@Composable
private fun AirsInfoPanel(
show: StableHolder<ShowUiModel>,
modifier: Modifier = Modifier,
)
我们可以更进一步, 探索一种被许多团队推荐的模式: UI专用模型类.
UI模型类
这些模型类专为每个界面
而建, 包含UI显示所需的最基本内容. 通常情况下, 你的ViewModel
会将数据层模型映射到这些UI模型中, 以方便UI使用. 更重要的是, 它们可以建立在可编译模型的旁边, 这意味着 Compose编译器可以推断出它所需要的一切, 或者如果一切都失败了, 我们可以根据需要添加@Immutable
或@Stable
.
这正是下面的PR中实现的:
Enable Compose Compiler metrics by chrisbanes · Pull Request #910 · chrisbanes
TiviShow
在我的数据层(数据库等)中用作模型, 我们现在不再直接使用TiviShow
, 而是将节目数据映射到ShowUiModel
中, 后者仅包含用户界面所需的必要信息.
不幸的是, 这还不足以让Compose编译器将ShowUiModel
推断为可跳过😔:
kotlin
restartable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
unstable show: ShowUiModel
stable modifier: Modifier? = @static Companion
)
不幸的是, 在度量指标中没有任何明显的信息可以解释为什么该类会被推断为不稳定. 在查看了 composables.txt
文件的其余部分后, 我注意到另一个函数也被认为是不稳定的:
kotlin
restartable scheme("[androidx.compose.ui.UiComposable]") fun Genres(
unstable genres: List<Genre>
)
新ShowUiModel
类是一个数据类, 其中包含许多基础数据类型和枚举类, 但有一个属性略有不同, 因为它包含一个枚举列表: genre: List<Genre>
. Compose编译器似乎无法推断稳定类型列表的稳定性(公开问题).
我发现迫使Compose确定ShowUiModel
是稳定类型的唯一方法是使用@Immutable
或@Stable
注解之一. 我使用了@Immutable
, 因为没有一个属性是可变的:
kotlin
@Immutable
internal data class ShowUiModel(
// ...
)
之后, AirsInfoPanel()
最终被确定为可跳过😅:
kotlin
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun AirsInfoPanel(
stable show: ShowUiModel
stable modifier: Modifier? = @static Companion
)
重复
做完所有这些工作后, 你可能会认为我们在模块的整体统计数据上有了很大的改进. 不幸的是, 情况并非如此:
json
{
"skippableComposables": 66,
"restartableComposables": 76,
"readonlyComposables": 0,
"totalComposables": 76,
"knownStableArguments": 890,
"knownUnstableArguments": 30,
"unknownStableArguments": 1
}
要提醒大家的是, 我们一开始使用的是64个可跳过的可组合函数, 这意味着我们将这一数字增加了.. 2个 , 达到了66个🙃.
大型应用程序可能包含数百个UI模块, 因此在每个模块中创建本地UI模型是不可持续的.
此外, 还有一些其他有趣的统计数据. Compose确定有890个稳定的可组合函数参数(这很好), 但还有30个参数被Compose确定为不稳定参数.
在对这些不稳定
参数进行检查后, 我发现几乎所有参数都可以安全地用作不可变状态. 问题似乎与之前的一样, 但难度更大: 大多数类型都来自外部库.
对于来自外部库的简单数据类, 我们可以 完成与之前相同的任务, 将它们映射到本地UI模型类(尽管这很费力). 不过, 大多数应用程序最终都会发现, 有些类在本地无法轻松映射. 在ui-showdetails
模块中, 我就遇到了来自ThreeTen-bp的一些时间/日期类: OffsetDateTime
和LocalDate
. 我并不特别想在本地重写一个日期/时间库!
值得注意的是, 我们现在讨论的只是一个模块的快照. Tivi是一个相当小的应用程序, 但它仍然包含12个用户界面模块. 大型应用程序可能包含数百个用户界面模块, 因此在每个模块中创建本地用户界面模型是不可持续的. 不过, 正如我们在本文开头提到的, 你只需在确认性能是个问题的地方考虑这个问题.
跳绳断了
此时, 我回到了文档中的建议, 开始研究第二个建议:
通过将函数标记为
@NonRestartableComposable
使其不可重启
乍一看, 与第一个修复类稳定性的建议相比, 这个建议更像是一个变通办法(或逃生门). 让我们看看注解的文档是怎么说的:
此注解[防止]生成允许跳过或重启此函数执行的代码. 这对于那些只是直接调用另一个可组合函数的小函数来说可能是可取的, 因为这些函数中直接包含的机制很少, 而且本身也不太可能失效.
回想一下, 我们的目标是让我们的可组合函数可以重启
和 跳过
, 因此根据我的理解, 这个注解并不能立即帮助我们. 不过, Compose Metrics指南提供了更多信息:
如果可组合函数不直接读取任何状态变量, 那么[使用该注解]是个好主意, [因为]这种重启作用域不太可能被使用.
那么, 这个注解对我们有帮助吗?有, 也没有. 该注解似乎让Compose编译器完全省略了可编译函数的所有自动重启功能, 因此否定了让我们的函数可重启
和可跳过
的初衷. 我认为这意味着任何状态变化都需要Compose运行时找到一个祖先重启作用域, 这就是为什么上面的文档说要避免对读取状态的函数使用这些注解.
接下来该怎么办?本地 UI 模型类的添加工作非常繁琐, 因此对很多团队来说都是不可行的. 不过, 我在Compose问题跟踪器上发现了一个我非常喜欢的解决方案: 将函数参数标记为@Stable
. 这样, 开发人员就可以强制可组合函数参数本身的稳定性/不变性, 甚至是外部参数类型:
kotlin
@Composable
fun AirsInfoPanel(
@Stable show: TiviShow,
modifier: Modifier = Modifier,
)
@dynamic
默认参数表达式
从Metrics文档中需要注意的第二件事是@dynamic
默认参数表达式. 可组合程序大量使用默认参数值来提供灵活的API. 我最近写过一篇关于Slot API模式的文章, 它就依赖于默认参数值:
默认参数值既可以来自可组合代码, 也可以来自不可组合代码. 使用可组合代码中的值意味着你调用的代码很可能是可重启的, 并且返回值可以更改. 这就是我们所说的@dynamic
默认参数值. 如果默认参数值是@dynamic
, 那么调用函数也可能需要重新启动, 这就是为什么要避免使用非预期动态参数值的原因.
度量标准将非@dynamic
参数值称为@static
, 这些参数可能占composables.txt
文件的绝大部分. 不过, @dynamic
即便在必要的时候, 也有一些例外情况:
你在明确读取可观测的动态变量
最常见的例子是将MaterialTheme.blah
用作可组合函数的默认值. 这里我们有一个可组合函数, 它有3个标记为动态的参数.
kotlin
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun TopAppBarWithBottomContent(
stable backgroundColor: Color = @dynamic MaterialTheme.colors.primarySurface
stable contentColor: Color = @dynamic contentColorFor(backgroundColor, $composer, 0b1110 and $dirty shr 0b1111)
stable elevation: Dp = @dynamic AppBarDefaults.TopAppBarElevation
)
前两个参数(backgroundColor
和contentColor
)是动态的, 因为我们正在间接读取托管在MaterialTheme
中的本地组成. 由于主题是相对静态的(从字典意义上来说), 返回值实际上不会经常变化, 所以它是动态的并不是什么问题.
至于elevation
参数, 我不知道为什么要标记为动态. 它使用的是 Material 提供的AppBarDefaults.TopAppBarElevation
属性的值, 其定义如下:
kotlin
object AppBarDefaults {
val TopAppBarElevation = 4.dp
}
dp
属性被标记为@Stable
, 而Dp
类被标记为@Immutable
, 因此从我的阅读来看, 这似乎是一个错误?
我在另一个函数中也发现了类似的问题:
kotlin
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun SearchTextField(
stable keyboardOptions: KeyboardOptions? = @dynamic Companion.Default
keyboardActions: KeyboardActions? = @dynamic KeyboardActions()
)
keyboardOptions
引用的是KeyboardOptions
的同伴对象(== 一个单例), 而keyboardActions
创建的是KeyboardActions
的一个新的空实例, 根据我的阅读, 这两个实例都应被推断为@static
.
与本文的第一部分类似, 我不确定我们在这里能做什么来影响Compose编译器. 我们可以在自己的类中添加@Stable
和@Immutable
, 但从上面的dp
示例来看, 这似乎并不总是有效.
发布构建?
在本文的开头, 我们提到需要在发布构建中启用Compose Compiler度量指标. 在调试模式下构建应用程序时, Compose编译器会启用一系列功能, 以加快开发迭代速度. Live Literals就是其中之一, 它能让Android Studio为某些参数值注入
更新值, 而无需重新编译可编译程序.
为此, Compose编译器会用生成的代码替换某些默认参数值. 然后, Android Studio可以调用这些代码来设置新值. 最终的结果是, 生成的 Live Literal 代码会导致默认参数变成@dynamic
, 而实际上它们并不是动态的.
下面是一个例子. ---
为debug
模式输出, +++
为release
版本输出. 在发布模式下, expanded
参数变为@static
:
json
--- debug.txt 2022-04-06 14:43:16.000000000 +0100
+++ release.txt 2022-04-06 14:43:24.000000000 +0100
@@ -1,11 +1,11 @@
restartable skippable scheme("[androidx.compose.ui.UiComposable, [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable], [androidx.compose.ui.UiComposable]]") fun ExpandableFloatingActionButton(
stable text: Function2<Composer, Int, Unit>
stable onClick: Function0<Unit>
stable modifier: Modifier? = @static Companion
stable icon: Function2<Composer, Int, Unit>
- stable shape: Shape? = @dynamic MaterialTheme.shapes.small.copy(CornerSize(LiveLiterals$ExpandingFloatingActionButtonKt.Int$arg-0$call-CornerSize$arg-0$call-copy$param-shape$fun-ExpandableFloatingActionButton()))
+ stable shape: Shape? = @dynamic MaterialTheme.shapes.small.copy(CornerSize(50))
stable backgroundColor: Color = @dynamic MaterialTheme.colors.secondary
stable contentColor: Color = @dynamic contentColorFor(backgroundColor, $composer, 0b1110 and $dirty shr 0b1111)
stable elevation: FloatingActionButtonElevation? = @dynamic FloatingActionButtonDefaults.elevation(<unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), <unsafe-coerce>(0.0f), $composer, 0b1000000000000000, 0b1111)
- stable expanded: Boolean = @dynamic LiveLiterals$ExpandingFloatingActionButtonKt.Boolean$param-expanded$fun-ExpandableFloatingActionButton()
+ stable expanded: Boolean = @static true
)
我学到了什么?
说到这里, 你可能会觉得我花了大约30分钟指出了一大堆可能存在的问题...你说得基本没错😅. 不过, 我仍然认为团队在这里有一些行动项目:
- 开始剖析和跟踪性能统计. 如果不这样做, 任何与性能相关的工作都是空中楼阁.
- 尽早更新到新版本的Compose! 这将允许你尝试并获得性能更新(并报告可能出现的问题).
- 留意标记为
@Composable
的小型实用函数/lambdas. 这些函数往往会返回一个值(而不是发出UI), 而且通常是可组合的, 这样它们就可以引用本地组成(根据我的经验,LocalContext
是一个常见的罪魁祸首).你可以通过传递依赖关系来轻松解除可组合性.
最后的一些想法
正如我在上文提到的, 我认为这些新度量标准在了解可组合元素的实际推论方面迈出了✨惊人的✨一步.
我指出的问题其实是件好 事, 说明这些指标和输出是有效的. 如果没有这些, 我们就不知道推断出了什么, 也无法看到推断出的结果是否与预期不符. 有了这些信息, 在Compose问题跟踪器上创建一个包含可调试信息的问题就变得容易多了.
现在的指标显然还很原始, 但我知道Compose + Android Studio工具团队是多么优秀, 我相信对应的Android Studio GUI离我们并不遥远. 我很期待看到Google团队在这方面的进展!