Compose 是一种相对年轻的技术, 用于编写声明式UI. 许多开发人员甚至没有意识到, 他们在如此关键的部分编写了次优代码, 以致后来导致了意想不到的性能低下和指标下降.
这是Jetpack Compose优化系列的第二文章, 主要是分享一下对于Skip优化, 长计算也叫耗时计算的优化, 以及Layout方面的优化.
Jetpack Compose 优化之可组合函数和Stable类型优化
Skip优化
可重启函数
首先, 让我们了解一下什么是可重启的可组合函数. 如上所述, $composer
会在函数开始和结束时启动和结束一个组(通常是树中的一个节点). 对于可重启函数, 可重启组被调用:
kotlin
@Composable
fun MyComposable($composer: Composer) {
$composer.startRestartGroup() // Group start
// Function body
$composer.endRestartGroup() // Group end
?.updateScope { $composer ->
MyComposable($composer)
}
}
在代码的末尾, 你可以看到在发生变化时重启函数的机制: 如果在组的开始和结束之间读取了一个状态, 该状态可以通知Compose其有关变化(State<T>
或CompositionLocal
), 那么$composer.endRestartGroup()
将返回非空, Compose将学会重启我们的函数. 如果有一个离状态读取位置更近的可重启组, 那么重启的将是这个组, 而不是外部组.
让我们看看这段代码:
kotlin
@Composable
fun MyComposable1() {
val counter: MutableState<Int> = remember { mutableStateOf(0) }
MyComposable2(counter)
}
@Composable
fun MyComposable2(counter: State<Int>) {
Text(text = "My counter = ${counter.value}")
}
其中, 当counter
发生变化时, 只有MyComposable2
会被重启, 因为值是在它的作用域中读取的. 同样的MutableState
可以被想象成MutableStateFlow
, 它在读写时执行必要的订阅和通知逻辑. 这是 Compose 工作方式的一个非常重要的逻辑, 因为MyComposable2
会在不触及其他父函数的情况下重新启动. 这就是重组机制的基础. 它与跳转机制一起提供了广泛的优化可能性, 尤其是对于UI中频繁变化的部分.
为了巩固本章的内容, 以下是更多示例, 这些示例将导致MyComposable2
成为重启(重新组合)点, 并遍历其所有子代, 而MyComposable1
则不受影响. 可以补充的是, animateColorAsState()
, rememberScrollState()
等内部也包含State<T>
, 更改时也会导致重新组合.
kotlin
val LocalContentAlpha = compositionLocalOf { 1f }
@Composable
fun MyComposable1() {
val counter1: MutableState<Int> = remember { mutableStateOf(0) }
var counter2: Int by remember { mutableStateOf(0) }
MyComposable2(counter1, { counter2 })
}
@Composable
fun MyComposable2(counter1: State<Int>, counterProvider2: () -> Int) {
Text("Counter = ${counter1.value}") // Reading the state
Text("Counter = ${counterProvider2()}") // Reading the state
Text("Counter = ${LocalContentAlpha.current}") // Reading the state
}
请注意, 如果你使用State<T>
作为委托, 那么请小心不要意外读取状态, 尤其是在状态经常变化的情况下.
kotlin
@Composable
fun MyComposable1()
var counter: Int by remember { mutableStateOf(0) }
// Reading the state will happen in MyComposable1, not in MyComposable2!!!
MyComposable2(counter)
}
Compose的开发人员建议传递lambda而非State<T>
, 因为如果需要硬编码或是在测试过程中, 传递State<T>
可能会有困难和不必要的代码. 但总的来说, 两者并无本质区别. 为什么需要这样做--你会在有关延迟状态读取的章节中阅读到相关内容.
还应注意的是, Slot API中经常使用的可组合 lambda 也是可重启和可跳过的.
可重启性和可跳过性
为了不让你对这两个术语感到困惑, 我在这里总结一下:
- 可重启函数可以重启, 是一个重启范围.
- 可跳过函数如果它的参数没有改变, 则可以跳过.
这就是 Compose 在其度量标准中对既可重启又可跳过的函数的描述:
less
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MyWidget(
stable widget: WidgetUiModel,
stable modifier: Modifier? = @static Companion
)
If at least one argument is unstable, the function will remain only restartable.
如果至少有一个参数是不稳定的, 则该函数将只保持可重启.
如果函数仅可重启, 则应使其成为可跳过函数或去掉可重启性. 注解@NonRestartableComposable
会删除可重启性和(如果存在)可跳过性.
无需重启和跳转功能的时机
所有内联可组合函数(Box
,Column
和Row
)都不可重启. 这意味着当状态发生变化时, 在其中一个函数内读取State<T>
会导致在最近的外部可重启函数中重新组合.
kotlin
@Composable
fun MyComposable() {
val counter: MutableState<Int> = remember { mutableStateOf(0) }
Box {
// The recomposition will affect the entire MyComposable()
// since the code will be inlined
Text(text = "My counter = ${counter.value}")
}
}
不返回Unit
的函数也不可跳转.
在某些情况下, 可跳过性和可重启性并不会带来真正的优势, 而只会导致资源的过度浪费:
- 可组合函数的数据很少或从不改变;
- 可组合函数简单地调用其他可跳过的可组合函数:
- 没有复杂逻辑且没有
State<T>
的函数, 调用最少的其他可组合函数; - 围绕另一个函数的包装器--作为一种参数映射器或隐藏不必要的参数.
- 没有复杂逻辑且没有
在这种情况下, 你可以使用@NonRestartableComposable
注解来标记可组合函数, 这样就可以移除可重启性(以及跳转性).
less
@Composable
@NonRestartableComposable
fun ColumnScope.SpacerHeight(height: Dp) {
Spacer(modifier = Modifier.height(height))
}
如果函数包含分支逻辑(if
, when
), 则在分支方面遵循上述规则. 是否添加注释取决于分支在使用过程中的变化频率以及每个分支中代码的复杂程度.
例如, Comose 开发人员使用@NonRestartableComposable
注解标记了Spacer
(无逻辑, 仅调用布局), 一些Image
和Icon
重载(参数映射到其重载),Card
(参数映射到 Surface). 不重启函数的好处微乎其微: 不会生成额外的代码, 也不会执行额外的逻辑, 但如果你正在设计一个UI套件, 这一点还是值得考虑的, 因为你的元素会被用在很多地方, 而且经常重复, 总的来说, 它会带来一些效果:
优化频繁变化的元素
只有在状态频繁变化并影响大量内容的情况下, 才应优化对State<T>
的读取. 否则, 整个代码将被过度优化, 无法用于阅读和开发.
派生状态
derivedStateOf
--- 派生(计算)状态, 它反映了主用例.
kotlin
val listState = rememberLazyListState()
val showButton by remember {
derivedStateOf { listState.firstVisibleItemIndex > 0 }
}
假设我们有一个列表状态, 我们读取列表中第一个可见元素的索引. 但我们并不需要它本身, 而是想知道是否向我们显示按钮. 为了只在按钮的可见性发生变化时重新组合, 而不是每次列表中第一个可见元素发生变化时都重新组合, 我们可以在derivedStateOf {}
中读取状态. 在这里, 派生状态会订阅第一次读取的状态变化, 并返回一个最终的State<T>
, 只有当最终状态发生变化时, 它才会导致重新组合.
重要的是, 派生状态只响应State<T>
的变化, 而不是普通变量的变化, 因为State<T>
具有向派生状态所使用的 Compose 快照系统订阅和通知变化的功能.
我们要强调的是, 你不应该使用一个频繁变化的状态的值作为remember
键, 否则派生状态的全部意义都将丢失, 在这个地方的重新组合将频繁发生, 派生状态的重新生成也将频繁发生:
scss
// Don't do
val listState = rememberMyListState()
val showButton by remember(listState.value) { // Reading the listState
derivedStateOf { listState.value > 0 }
}
// Don't do
val listState by rememberMyListState()
val showButton by remember(listState) { // Reading the listState
derivedStateOf { listState > 0 }
}
只有当派生状态的变化频率低于原始状态时, 才可以使用派生状态:
kotlin
// Don't do
val derivedScrollOffset by remember {
derivedStateOf { scrollOffset - 10f }
}
派生状态适用于包裹懒列表滚动, 轻扫和其他频繁变化的状态. 再举几个例子:
- 跟踪滚动是否越过阈值(
scrollPosition > 0
). - 列表中的条目数大于阈值(
items > 0
). - 表单验证(
username.isValid()
).
对于嵌套的衍生状态, 有必要定期指定突变策略, 以避免在第一个(嵌套的)derivedStateOf
发生变化时重新计算表达式.
javascript
val showScrollToTop by remember {
// Mutation policy --- structuralEqualityPolicy()
derivedStateOf(structuralEqualityPolicy()) { scroll0ffset > 0f }
}
var buttonHeight by remember {
derivedStateOf {
// By specifying a mutation policy in showScrollToTop,
// this calculation block will only be called when showScrollToTop changes
if (showScrollToTop) 100f else 0f
}
}
在该文章中阅读更多突变状态的内容.
在可组合函数中延迟读取状态
上一段描述了派生状态所使用的原则: 它在内部读取状态并防止其重新组合整个函数. 我们可以使用同样的原则, 但要将状态读取从父函数延迟到子函数. 这也只适用于频繁变化的状态. 你可以使用 lambda 或传递一个状态并在正确的地方读取它, 从而延迟读取.
kotlin
@Composable
fun MyComposable1() {
val scrollState = rememberScrollState()
val counter = remember { mutableStateOf(0) }
MyList(scrollState)
MyComposable2(counter1, { scrollState.value })
}
@Composable
fun MyComposable2(counter: State<Int>, scrollProvider: () -> Int) {
// Reading the state in MyComposable2
Text(text = "My counter = ${counter.value}")
Text(text = "My scroll = ${scrollProvider()}")
}
在上面的代码中, 只有MyComposable2
函数会因为快速计数器或滚动而重新组合, 而不是整个MyComposable1
.
在Compose阶段延迟读取状态
不仅在可组合函数之间可以延迟读取状态, 在Compose阶段(Composition → Layout → Drawing)之间也可以延迟读取状态. 例如, 如果我们需要频繁更改颜色, 最好使用drawBehind {}
而不是background()
修饰符, 后者需要一个 lambda, 并且只有在绘制阶段才会因状态更改而调用代码, 而不是像background()
那样在组合阶段.
滚动时也可以使用类似的方法: 使用带 lambda 的offset {}
修饰符, 而不是简单的offset(value)
. 这样, 我们就可以将读取状态的时间推迟到布局阶段.
kotlin
@Composable
fun Example() {
var state by remember { mutableStateOf(0) }
Text(
// Reading the state in Composition phase
"My state = $state",
Modifier
.layout { measurable, constraints ->
// Reading the state in Layout phase
val size = IntSize(state, state)
}
.drawWithCache {
// Reading the state in Drawing phase
val color = state
}
)
}
减少重组区域
你应该将部分代码拆分成小函数, 以避免重新组合. 如果你发现某个函数的一部分保持不变, 而另一部分经常变化, 那么最好将该函数一分为二. 这样, 一个函数将被跳过, 而经常变化的函数将在较小的范围内重组. 但你也不能得意忘形, 将Divider
移到一个单独的函数中.
下面是将定时器逻辑移到一个单独函数中的示例, 这样可以减少Promo
中的重组次数, 因为只有Timer
会被重启(注意调用timer.value
的位置, 它会在更改时重启):
kotlin
@Composable
fun Promo(timer: State<Int>) {
Text("Sample text")
Image()
// Old timer code right in the Promo function
// Text("${timer.value} seconds left")
// New timer code
Timer(timer)
}
@Composable
fun Timer(timer: State<Int>) {
// Timer code and reading the timer state (timer.value) inside
}
在列表中使用 key 和 contentType
在懒列表中, 你需要向item()
传递一个 key, 这样列表才能知道数据是如何变化的. 同时还要传递contentType, 以便列表知道哪些项目可以重复使用.
ini
LazyColumn {
items(
items = messages,
key = { message -> message.id },
contentType = { it.type }
) { message ->
MessageRow(message)
}
}
如果你通过forEach
生成列表, 你可以使用key() {}
, 这样当列表发生变化时, Compose就会知道元素移动到了哪里.
scss
Column {
widgets.forEach { widget ->
key(widget.id) {
MyWidget(widget)
}
}
}
修饰符
自定义修饰符
如果你编写了自己的修饰符, 那么:
- 如果是无状态的, 只需使用函数.
- 如果是有状态的, 则使用
Modifier.Node
(ModifierNodeElement
). 以前, 建议使用composed
来实现这一点. 通常情况下, 你能够现在就做, 因为依然没有详细的Modifier.Node
导引, 只有代码示例(示例 1, 示例 2). - 如果在修饰符中调用了可组合函数, 请使用
Modifier.composed
.
有关修饰符的更多信息, 请参阅该视频.
重复使用修饰符
如果在创建修饰符的区域经常出现重新组合的情况, 那么值得考虑将修饰符的创建移到该区域之外. 例如这个例子, 与动画有关:
kotlin
val reusableModifier = Modifier
.padding(12.dp)
.background(Color.Gray),
@Composable
fun LoadingWheelAnimation() {
val animatedState = animateFloatAsState(...)
LoadingWheel(
modifier = reusableModifier,
// Reading a frequently changing state
animatedState = animatedState.value
)
}
此外, 我们还建议从列表中提取修饰符, 这样所有元素都可以重复使用一个对象.
kotlin
val reusableItemModifier = Modifier
.padding(bottom = 12.dp)
.size(216.dp)
.clip(CircleShape)
@Composable
private fun AuthorList(authors: List) {
LazyColumn {
items(authors) {
AsyncImage(modifier = reusableItemModifier)
}
}
}
不仅可以从函数中提取修饰符, 还可以简单地从父级可组合函数中提取修饰符, 因为在父级可组合函数中重新组合的情况较少. 可以安全地附加更多修饰符.
scss
reusableModifier.clickable { /*...*/ }
otherModifier.then(reusableModifier)
重组过程中的长计算
只能在ViewModel和remember中进行长计算
几乎所有计算都应只在ViewModel
中进行. 在这种情况下, 请确保回调(onButtonClick
,onIntent
,onAction
,onEvent
,onMessage
...)不在主线程中执行繁重的工作. 如果只有一个函数在主线程中执行以处理用户操作, 那么可以对其工作持续时间进行测量, 并记录执行时间的临界值, 这样开发人员就不会忘记将复杂和冗长的计算转移到后台线程中.
最好从可组合函数中移除所有逻辑. 在其他情况下, 如果不方便将冗长或代价高昂的计算放到ViewModel
中, 可以使用remember
.
不在UI状态的getter中进行长计算
kotlin
data class MyUiState(
val list1: List<Int> = emptyList(),
val list2: List<Int> = emptyList(),
) {
// Don't do
val isTextVisible
get() = list1.any { it == 1 } || list2.any { it != 0 }
}
如果在UiState
中使用这样的方法, 从而避免每次都设置字段, 那么每次重新组合时都会发生重新计算, 因为它只是在执行getIsTextVisible()
方法. 因此, 要么删除getter(将字段留在类主体中或移到主构造函数中), 要么确保在调用获取器的地方有最少的重构次数.
何时使用
使用:
- 对于任何长操作或耗费内存的操作, 若其可以执行多次但在更改传递给
remember()
的键(如果需要)之前不应执行, 尤其是频繁的重组.
scss
val brush = remember(key1 = avatarRes) {
ShaderBrush(
BitmapShader(
ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
Shader.TileMode.REPEAT,
Shader.TileMode.REPEAT
)
)
}
- 用于用不稳定的外部变量包装 lambdas.
- 用于没有重载
equals
的类. - 用于需要在重构之间保留的对象(如
State<T>
).
如果remember
键经常更新, 则值得考虑在这种情况下是否需要remember
. 此外, 你不应该在键中添加remember
中的计算所依赖的任何内容: 如果你意识到这些值在可组合函数和remember
本身的生命周期内永远不会改变, 那么就不要把它们添加到键中.
布局
自定义布局
不要害怕制作自定义布局: 这比在 View 中要容易得多, 最重要的是要开始. 有关此主题的实用视频和文章在 Compose 中有: link1, link2, link3, link4.
不合理地改变大小和位置
避免不合理地调整 Compose 元素的大小, 尤其是列表中的元素. 如果你没有为图片设置固定大小, 而图片从互联网下载后改变了大小, 就会出现这种问题. 由于修饰符onGloballyPositioned()
, onSizeChanged()
和类似的修饰符, 可能会出现不合理的大小调整或位置. 因此可能会出现大量不必要的重新组合. 如果元素需要了解其他元素的位置和大小, 通常意味着要么使用了错误的布局, 要么需要自定义布局.
布局预计算
SubcomposeLayout
SubcomposeLayout
将组合延迟到布局阶段的测量, 这样我们就可以利用可用空间来组合子元素. 第二个有用的应用是条件组合. 例如, 根据应用窗口的大小, 我们可以以不同的方式排列元素(针对平板电脑或手机, 或一般针对可调整大小的窗口). 或者根据滚动状态, 调用特定元素的组合来实现懒列表. SubcomposeLayout
相当昂贵, 因此在任何其他情况下都不应使用它来预先计算布局.
Jetpack Compose 生命周期的 Subcomposition.
Also, using SubcomposeLayout
under the hood explains why LazyRow
and LazyColumn
lose out to Row
and Column
in performance with a small number of items. So if you have a small list, use Row
and Column
for it.
SubcomposeLayout
的主要实现--BoxWithConstraint
,LazyRow
和LazyColumn
--涵盖了大多数 Layout 无法满足的需求.
此外, 在内部使用SubcomposeLayout
也解释了为什么LazyRow
和LazyColumn
在处理少量项目时性能不如Row
和Column
. 因此, 如果你有一个小列表, 请使用Row
和Column
.
SubcomposeLayout
有时会被错误地用于实现 Slot API:
less
// Don't do!
@Composable
fun DontDoThis(
slot1: @Composable () -> Unit,
slot2: @Composable () -> Unit
) {
SubcomposeLayout { constraints ->
val slot1Measurables = subcompose("slot1", slot1)
val slot2Measurable = subcompose("slot2", slot2)
layout(width, height) {
...
}
}
}
对于 Slot API, 有一个更正确的选择: 通过layoutId()
修饰符, 并通过layoutId
字段或Layout
字段在可测量对象中搜索, 同时传递可组合列表, 并按顺序解构可测量对象列表.
less
@Composable
fun DoThis(
slot1: @Composable () -> Unit,
slot2: @Composable () -> Unit
) {
Layout(
contents = listOf(slot1, slot2)
) { (slot1Measurables, slot2Measurables), constraints ->
...
layout(width, height) {
...
}
}
}
本征(Intrinsics)测量
本征(Intrinsics)测量比SubcomposeLayout
更有效, 其内部的工作原理与LookaheadLayout
非常相似. 这两种方法都会在同一帧中以不同的约束调用测量 lambda(在LayoutModifiers
或MeasurePolicy
中传递). 但在Intrinsics的情况下, 这是一个预计算, 以便使用获得的值执行实际测量.
试想一个有三个子节点的Row
. 为了使自己的高度与最高的孩子的高度相匹配, Row
需要获取其所有孩子的本征测量值, 然后使用最大值来测量自己. 更多信息请访问 Android Developers.
本征测量值会对懒列表中的复杂布局产生负面影响, 但影响不大.
LookaheadLayout
用于精确预计算任何(直接或间接)子元素的大小和位置, 以实现自动动画(例如从一个元素过渡到另一个元素). 此外, LookaheadLayout
还采用了比固有技术更激进的缓存技术, 以避免在树发生变化前进行前瞻. 你可以在 Jorge Castillo 撰写的文章 中阅读更多内容.
今天主要分享了Jetpack Compose优化的Skip优化, 长计算优化及Layout优化. 感谢阅读, 感谢评论, 敬请期待下一篇!