Jetpack Compose 优化之可组合函数和Stable类型优化

Compose 是一种相对年轻的技术, 用于编写声明式UI. 许多开发人员甚至没有意识到, 他们在如此关键的部分编写了次优代码, 以致后来导致了意想不到的性能低下和指标下降.

我们的团队也面临着这个问题. 我们决定将编写优化Compose代码的所有技巧和窍门汇总在一起. 在优化现有页面和编写新页面时积极应用这些技巧, 这大大改善了我们的指标: 与滚动持续时间(滞后率, 越小越好)相关的列表屏幕滞后持续时间已从平均 15-19% 降至 5-7%(第 90 百分位数). 我们在本文中介绍了所有这些技巧和窍门. 这篇文章对初学者和有经验的开发人员都很有用, 它详细描述了优化和 Compose 机制, 还告诉你一些记录不全的功能, 并纠正了其他文章中的错误. 让我们开始吧!

可组合函数

首先, 让我们深入了解 Compose 是如何工作的以及它能够做些什么. 这将有助于我们理解为什么需要进行特定的优化以及这些特定的优化是如何起作用的.

主要思想

构建UI树是可组合函数的主要思想. 从User()函数的开头传到结尾, 我们会得到一个如图所示的树形结构:

scss 复制代码
@Composable
fun User() {
    Row {
        Image()
        Column {
            Text()
            Text()
        }
    }
}

UI元素树示例

要构建这样的树状结构, 你需要的不仅仅是声明式代码. Compose 编译器可以帮我们解决这个问题.

Compose 编译器是 Kotlin 编译器的一个插件. 这意味着, 与 kapt/ksp 插件不同, 它可以修改当前代码, 而不仅仅是生成新代码. 在编译时, 它会用新函数替换可编译函数, 并为其添加辅助构造和参数, 其中$composer尤为重要. 它可以被看作是一个调用上下文. 可组合函数的转换过程可以看作是 Kotlin 本身对 suspend 函数所做的工作.

Compose 编译器会在生成的可组合函数的开头和结尾添加$composer方法调用(见下面的代码). 这些方法开始和结束了一个组, 可以将其视为 Compose 构建的树中的一个节点. 也就是说, 函数的开始和结束就是节点描述的开始和结束. Restart一词指的是组的类型. 在本文中, 我们不会深入探讨组的类型, 但如果你感兴趣, 可以在《Jetpack Compose internals》一书中阅读(第 2 章."Compose 编译器", "控制流组生成"段落).

kotlin 复制代码
@Composable
fun User($composer: Composer) {
    $composer.startRestartGroup() // Start of group

    // Function body

    $composer.endRestartGroup() // End of group
}

根据函数体中的数据, $composer一步一步地构建树. 这是 Compose 的第一阶段--编译.

组合阶段

与大多数其他UI工具包一样, Compose 会通过几个不同的阶段来渲染帧.

如果我们看一下 Android View 系统, 它有三个主要阶段: 测量, 布局和绘制. Compose 也有类似的阶段:

  1. 组合 : 显示什么UI. Compose 运行可组合函数并创建UI描述.
  2. 布局 : 在哪里放置UI. 这一阶段包括两个步骤: 测量和放置. 布局元素会对布局树中的每个节点进行测量, 并在二维坐标中放置自己和任何子元素.
  3. 绘制 : 如何 渲染. UI元素绘制到Canvas上, 通常是设备屏幕.

这三个阶段几乎每帧都要执行, 但为了提高性能, 如果某些阶段的数据没有变化, Compose软件可以跳过这些阶段.

调用阶段的条件以及读取状态的位置示例见下图. 我们稍后会讨论读取状态, 现在只需将其视为获取muatbleStateOf()的值即可. 有关阶段的更多信息, 请访问 Android Developers.

Jetpack Compose 阶段.

可组合函数中的参数

Compose 鼓励我们编写 纯函数. 这使得它们更具确定性, 也允许 Compose 开发人员进行首次优化 -- 如果参数没有改变, 就不执行可组合函数.

让我们立即介绍一下组合重组 等概念, 组合 是指建立一棵可组合函数树, 重组是指在数据发生变化时更新这棵树.

我们还将看到 Compose 编译器为可组合函数添加的另一个参数--$changed. 它只是一个整数, 是一个位图, 其中的位负责可组合函数的参数信息及其变化.

kotlin 复制代码
// Parameters of a composable function after running the Compose compiler 
@Composable
fun Header(text: String, $composer: Composer<*>, $changed: Int)

如果父可组合函数中的某些参数发生了变化, 而某些参数保持不变, 那么有关比较的信息将作为$changed参数传递给子函数, 这样子函数就不会进行不必要的比较. 函数本身只比较父函数不确定的参数, 或默认设置的参数.

可变参数 - 可以更改(修改其数据)的对象 - 可能会扼杀整个比较过程. 为了解决这个问题, Compose软件的开发人员决定将所有类型分为稳定型和不稳定型. 如果函数的所有参数都是稳定的, 没有发生变化, 则跳过重组, 否则就必须重新启动函数.

支持跳过重组的函数是可跳过的. 我们应尽量使几乎所有的函数都可跳过. 这将对优化起到很好的作用.

稳定类型

根据稳定性对类型进行分类

Compose编译器会遍历所有类型, 并为它们添加稳定性信息: @StabilityInferred注解和包含类型稳定性信息的$stable静态字段.

类型稳定性意味着Compose运行时可以安全地读取和比较该类型的输入, 并在必要时跳过重组. 稳定性的最终目的是帮助Compose运行时.

稳定的类型包括:

  • 所有基础数据类型和字符串.
  • 函数式类型(lambdas)(这就是为什么"不稳定 lambdas"的概念并不完全正确的原因, 下文会详细说明).
  • 所有字段均为稳定类型并声明为 val 的类, 包括密闭类. 对类字段的稳定性进行递归检查, 直到找到稳定性已经明确的类型为止.
  • 枚举(即使你指定了一个 var 字段并对其进行了更改).
  • 标记为@Immutable@Stable的类型.

所有稳定类型都必须满足一定的协议, 这一协议我们将在下文讨论.

Compose 认为不稳定的类型:

  • 至少有一个字段为不稳定类型或声明为 var 的类.
  • 来自外部模块和库的所有类, 这些模块和库没有Compose编译器(List,Set,Map和其他集合,LocalDate,LocalTime,Flow...).

对于泛型 (MyClass<T>), 检查基于泛型本身的结构, 然后基于指定的类型. 如果泛型的结构不稳定(存在类型不稳定的字段或带有 var 的字段), 则会立即被视为不稳定. 如果我们立即指定泛型的类型, Compose将在编译阶段确定它是稳定的还是不稳定的:

kotlin 复制代码
// Stable
class MyClassStable(
    val counter: Pair<Int, Int>
)

// Unstable
class MyClassUnstable(
    val counter: Pair<LocalDate, LocalDate>
)

// Unstable
class MyClassUnstable(
    val counter: Pair<*, *>
)

如果我们创建一个可组合的泛型函数, 并将泛型作为参数传递给它(@Composable fun <T> Item(arg: Pair<T, T>)), 那么其行为将与具有可计算稳定性的类型相同, 这一点我们将进一步讨论.

Compose 开发人员还预定义了 外部类型, 这些类型将被视为稳定类型: Pair, Result, Comparator, ClosedRange, kotlinx.collection.immutable 库中的集合, dagger.Lazy以及其他. 这些类型中的大多数都是泛型, 因此这个列表只能告诉我们它们结构的稳定性. 也就是说, 如果我们向这些泛型传递一个稳定的类型, 它们就会稳定; 如果传递的是不稳定的类型, 它们就会不稳定. 可以说, "所有来自外部模块和没有Compose编译器的库的类都是不稳定的"这一原则, 根本不适用于这些类型.

还有一些类型具有可计算稳定性--对于这些类型, Compose无法在编译时明确判断它们是否是稳定的. 在运行时, 当接收到特定对象时, 就会对它们的稳定性进行检查. 这些类型包括:

  • 在其他启用了Compose编译器的模块中声明的类型. 如果我们在模块 1 中使用模块 2 中的一个类型, 而模块 1 中的 Compose 并未启用 , 那么 Compose 编译器根本无法检查该类型的稳定性, 因此会立即认为它不稳定. 而如果 Compose 在模块 2 中启用 了, 那么 Compose 编译器就会假定它将在模块 2 中检查该类型: 它将用@StabilityInferred对其进行注解, 并添加一个静态字段$stable. 然后它会在运行时读取这个字段, 而不是在编译期.
  • 接口(检查基于派生类, 其对象将作为参数传递).

关于接口的稳定性:

其他一些文章和资料都说接口不稳定, 但是我们根据Compose源码, 输出指标和测试结果得出结论: 接口稳定性是在运行时计算的. 在测试中, 它们被标记为不确定(这与 Unknown)类相关). 同时, Compose 确定的类型与Certain类相关(Stableor Unstable). 综合所有这些论据, 我得出了这个结论.

此外, 你还可以在tests或可组合度量中了解类型的稳定性, 这在调试一章中有所描述.

@Immutable 和 @Stable

如果你确定一个类或接口及其所有子类都是稳定的, 你可以用@Immutable来注解它们(如果它们是不可变的), 或者用@Stable来注解它们(如果它们可以改变但会通知 Compose 它们的改变). 例如, 如果类中有一个State<T>MutableState<T>类型的字段, 就适合使用@Stable(mutableStateOf()会创建这样一个对象).

less 复制代码
@Immutable
data class MyUiState1(val items: List<String>)

@Stable
data class MyUiState2(val timer: MutableState<Int>)

此类注解的稳定性会被子类继承.

kotlin 复制代码
@Immutable
interface Parent // Stable type

class Child1(val age: Int) : Parent // Stable type

class Child2(var list: List<String>) : Parent // Also the stable type

@Immutable@Stable注解可用于标记Compose认为不稳定但实际上是稳定的类型, 或者你确信它们将被用作稳定类型.

这两个注解目前只是执行稳定类型声明的逻辑, 对 Compose 来说是一样的, 但最好还是按预期使用它们, 因为 Compose 开发人员将来可能会改变行为.

通过使用这些注解标记类型, 你向 Compose 承诺你的类型将履行以下协议:

  1. 对于相同的两个实例, equals的结果将始终返回相同的结果.
  2. 当类型的公共属性发生变化时, Compose 将收到通知.
  3. 所有公共属性类型都是稳定的.

这个协议只是你的承诺, 如果你违背了它, Compose 不会以任何方式进行检查. 但这样一来, 可组合函数就有可能出现意外行为. 让我们来详细看看这个协议.

第一点尤为重要, 即使你将所有参数都设置为稳定参数, 也有可能出现问题. 例如, 在下面的代码中, 我们看到MyUiState没有像数据类那样重载equals, 这意味着检查将通过引用完成. 如果MyComposable1被重组, 那么MyUiState将被重建. 在通过引用进行检查时, Compose 会将其视为一个完全不同的对象, 不会跳过MyComposable2, 尽管字段仍然相同.

kotlin 复制代码
class MyUiState(val name: String)

@Composable
fun MyComposable1() {
    val myState = MyUiState("Name")
    MyComposable2(myState)
}

@Composable
fun MyComposable2(uiState: MyUiState) {
    Text(uiState)
}

要解决这种情况, 你可以编写自己的equals实现(或使用数据类), 或者使用remember记住这个对象, 这样在重组时就不会重新创建(如果对象在业务逻辑中重新创建, 也可以在业务逻辑中执行类似操作).

第二点是在State<T>MutableState<T>(mutableStateOf)中实现的, 当它发生变化时, 在其内部会通知 Compose.

协议的第三点意味着要将公共字段用作稳定字段. 也就是说, 如果你有一个形式上不稳定的List<T>类型的字段, 而你又没有把它转换成MutableList<T>, 那么请随意把你的类标记为@Immutable@Stable.

@Stable也可以应用于常规的(不可组合的)函数和属性. 之后如果参数没有改变, Compose 会认为它们将返回相同的值. 该注解不会影响可组合函数. 它主要用于优化生成的代码, 以处理可组合函数中的默认参数.

标有@Stable的函数和属性示例: Modifier.padding(), Modifier.width(), Int.dp; 动画中的转化函数: fadeIn(), fadeOut(), slideIn().

关于@Stable对函数和属性的影响:

在可组合度量中, 你可以了解注解的影响, 主要是通过Compose编译器如何标记默认参数: @dynamic @static进行. 简而言之, 默认参数中有 @static , 可组合函数就不会被调用, 也不会读取可触发重组的状态(下文将详细介绍). 你可以在链接阅读更多关于 @dynamic @static的内容.

在我们的实验中, 我们无法从标记*@Stable的函数中获得特定的效果(例如跳转), 只有从 @dynamic变为 @static*而来的度量值变更.

kotlin 复制代码
@Composable
fun MyWidget(param1: String, param2: String = testStable()) {
    Text(param1 + param2)
}

@Stable
fun testStable() = "test"

// Composable metric for the function
restartable skippable scheme("[androidx.compose.ui.UiComposable]") fun MyWidget(
  stable param1: String,
  stable modifier: String? = @static testStable()
)

函数的可跳过性

只有当一个可组合函数的所有参数都是稳定类型且函数返回Unit时, Compose 才会使该函数可跳过. 如果函数体中没有使用不稳定的参数, 这些参数将被忽略.

对于跳过的函数, 如果输入数据没有改变, Compose会专门生成代码以防止再次调用这些函数.

kotlin 复制代码
@Composable
fun Header(text: String, $composer: Composer<*>, $changed: Int) {
    if (/* Skip check logic */) {
        Text(text) // Function body is executed
    } else {
        $composer.skipToGroupEnd() // Letting Compose know that we skipped a function
    }
}

在Compose常用的类型中也有不稳定类型, 例如Painter, 因此应谨慎使用, 以免在函数中失去可跳过性.

如果存在运行时计算稳定的参数, 函数仍可跳过, 但如果运行时发现参数属于不稳定类型, 则会生成额外的代码来避免跳过.

基于以上所述, 我们团队同意将所有UI模型和状态标记为@Immutable@Stable, 因为我们最初就是这样设计它们的. 在开发UI工具包项目时, 我们会特别关注稳定性, 因为出错的代价会更高. 要检查类型的稳定性, 可以使用 Compose 度量指标(我们将在文章末尾再次讨论).

你还可以简单地向函数传递尽可能少的不必要数据. 这非常简单: 数据越少, 函数发生变化的可能性就越小.

如果需要使用标准集合或外部类, 并希望函数可以跳过, 该怎么办? 到目前为止, 可能性非常有限: 要么制作一个封装类(值类也可用作封装类), 并将其标记为@Immutable@Stable, 要么干脆避免使用. 对于标准集合, 在 UI 模型中可以选择从kotlinx.collections.immutable切换到集合. 声明外部类型的稳定性是Compose开发人员的计划之一.

Lambda表达式

让我们来谈谈Lambda表达式在 Compose 中是如何工作的, 以及如何正确地准备它们. 这篇文章给出了一个有趣的示例: 在 lambda 中调用ViewModel方法会导致不必要的重组.

简而言之, 这种情况可归纳如下:

kotlin 复制代码
@Composable
fun MyScreen() {
    val viewModel = remember { MyViewModel() }
    val state by viewModel.state.collectAsState()
 
    MyComposableItem(
        name = state.name,
        onButtonClick = { viewModel.onAction() }
    )
}

要了解情况和原因, 让我们来看看 Compose 是如何处理Lambda表达式的. Compose 将Lambda表达式分成不可组合和可组合两种类型, 前者不执行可组合代码, 后者则执行可组合代码. 让我们详细看看第一种类型.

在可组合函数中创建的非可组合Lambda表达式会在编译时用remember { }包装. 所有捕获的变量都会作为 key 放在 remember(key) 中:

kotlin 复制代码
// Before compilation
val number: Int = 6
val lambda = { Log.d(TAG, "number = $number" }

// After compilation
val number: Int = 6
val lambda = remember(number) { { Log.d(TAG, "number = $number" } }

How to solve this problem? Method reference (viewModel::onAction) used to work, but since Compose 1.4 it stopped working due to using reference comparison instead of the custom equals that Kotlin generates. You can read more in this thread, as well as in this video from 32:50.

如果 lambda 捕获的变量类型不稳定 (即不稳定或在运行时可计算: 该条件比可跳过性更严格), 或者该变量被声明为 var, 那么 Compose 不会用remember对其进行封装, 这会导致该变量在重新组合时被重新创建. 此外, 在比较过去和当前 lambda 时, Compose 会发现它们不相等, 因此即使是可跳过的函数也会开始重新组合(假定MyViewModel是不稳定的类型).

如何解决这个问题? 方法引用(viewModel::onAction)曾经有效, 但自 Compose 1.4 起, 由于使用了引用比较而非 Kotlin 生成的自定义equals, 它就不再有效了. 你可以在 这条推文以及这条视频 从 32:50 开始阅读更多内容.

有效的方法:

  • 记住你自己的 lambda(在这种情况下, 键本身不能随着每次重新组合而改变):
ini 复制代码
val onAction = remember { { viewModel.onAction() } }

你可以这样做(为什么要记住 lambda 而不是方法引用, 你可以阅读这里):

kotlin 复制代码
@Composable
inline fun <T : Any> MviViewModel.rememberOnAction(): ((T) -> Unit) {
    return remember { { this.onAction(it) } }
}

val onAction = viewModel.remberOnAction()
  • 使用顶级(静态)函数和变量. 在这里, Kotlin 编译器会直接调用它们, 因为它们是静态的, 而不是通过在编译期为 lambda 创建的类构造函数来传递.
  • 只在 lambda 内部使用稳定的外部变量.
  • 使用从 lambda 内部接收的参数. 这可能无济于事, 只能延缓或减少问题的出现, 但如果你以前捕获的是一个列表, 而现在什么也捕获不到, 那么这肯定会有帮助.
kotlin 复制代码
@Composable
fun MyComposableItem(items: List<MyClass>) {
    // Instead of this
    ItemWidget { items[5].doSomething() } 
 
    // Do this
    ItemWidget(item[5]) { item -> item.doSomething() }
}

如果你在 lambda 中调用了可组合函数中的片段的函数, 那么 lambda 也可以隐式地捕获外部变量. 这样, lambda 构造函数就会将片段作为参数, 而不会生成 lambda 周围的remember:

kotlin 复制代码
class MyFragment : Fragment {
    fun onButtonClick() { ... }

    @Composable
    fun Screen() {
        MyButton(onClick = { onButtonClick() })
    }
}

你可能还会有一个问题, 如果remember { }接受一个 lambda, 那么它是如何处理 lambda 本身的呢? 事实上, remember是一个内联函数, 它的 lambda 变成了一个普通的代码块. 因此, 函数:

javascript 复制代码
val resultValue = remember(key1, key2) {
    // Our calculations (e.g. creating a lambda)
}

将会变成下面的代码:

kotlin 复制代码
// Retrieving a remembered value
val rememberedValue = composer.rememberedValue()

val needUpdate = /* Checking if our keys key1 and key2 have changed,
    or the value has not yet been initialized */

if (needUpdate) {
    // Our calculations. Inline lambda will turn into a code block
    val value = calculation() 

    // Updating the remembered value
    composer.updateRememberedValue(value) 

    return value // Returns the calculated and remembered value
} else {
    return rememberedValue // Returns the remembered value
}

上面的代码只反映了逻辑, 有遗漏.

此外, 你还可以在这个视频 25 分钟开始看到在Compose上下文内部更多关于 lambda 的内容.

今天跟大家分享了对于Composable函数和Stable类型的优化, 鉴于篇幅原因, 今天只讨论这两个主题. 接下来我会向大家分享在Skip优化, 长计算和布局方面的优化, 敬请期待~

相关推荐
深海呐2 小时前
Android AlertDialog圆角背景不生效的问题
android
ljl_jiaLiang2 小时前
android10 系统定制:增加应用使用数据埋点,应用使用时长统计
android·系统定制
花花鱼2 小时前
android 删除系统原有的debug.keystore,系统运行的时候,重新生成新的debug.keystore,来完成App的运行。
android
落落落sss3 小时前
sharding-jdbc分库分表
android·java·开发语言·数据库·servlet·oracle
消失的旧时光-19436 小时前
kotlin的密封类
android·开发语言·kotlin
服装学院的IT男7 小时前
【Android 13源码分析】WindowContainer窗口层级-4-Layer树
android
CCTV果冻爽8 小时前
Android 源码集成可卸载 APP
android
码农明明8 小时前
Android源码分析:从源头分析View事件的传递
android·操作系统·源码阅读
秋月霜风9 小时前
mariadb主从配置步骤
android·adb·mariadb
Python私教10 小时前
Python ORM 框架 SQLModel 快速入门教程
android·java·python