Jetpack Compose是如何决定哪块代码进行重组的?

Jetpack Compose重组的作用域

几个月前, 我开始在生产级应用中使用Jetpack Compose, 当然是在编写了一些"Jetpack Compose Hello World项目"作为示例应用之后, 当然之后我放弃了所有这些项目. 在生产级应用中使用Jetpack Compose相当具有挑战性, 因为我们需要真正理解我们正在编写的内容, 以及它对性能和优化的影响, 并确保它不会重现意想不到的行为/错误, 尽管在底层有很多东西我们可能还不知道Jetpack Compose是如何处理的, 其中之一就是重组.

重组是当输入发生变化时再次调用可组合函数的过程.

函数的输入发生变化时, 就会发生这种情况. 当Compose根据新的输入进行重组时, 它只会调用可能已发生变化的函数或lambdas, 而跳过其他函数或lambdas. 通过跳过所有参数未发生变化的函数或lambdas, Compose可以高效地进行重组.

简单地说, 重组就是重新渲染Compose组件的过程, 因为输入发生了变化, 我们需要向用户显示最新的输入. 如果我们经常在安卓视图上创建自定义视图, 我们可以使用invalidate()函数重新渲染整个自定义视图组件. 重组与invalidate()的功能类似, 都是重新渲染当前视图, 但重组的工作方式更智能, 只重组输入发生变化的组件, 而跳过其他组件, 这使得Jetpack Compose中的重组比Android视图中的invalidate()更有效.

invalidate() vs recomposition

当我们使用Compose构建用户界面时, 重组是不可避免的, 因为重组是Compose将当前数据状态更新到用户界面的机制, 但我们可以消除不必要的重组, 以优化我们的Compose代码. 通过了解重组的工作原理, 我们可以确保自己编写的代码不会导致Compose重复进行不必要的重组.

重组作用域

什么是重组作用域?

重组作用域是可以独立重组的最小代码块. 它意味着什么? 这意味着编译器能够找到状态或输入读取的位置, 并只重新编译组件的最小作用域, 而无需重新编译所有组件. 让我们看看下面的重组作用域示例:

重组作用域

重组作用域通常 以开头和结尾的函数括号标记, 在上面的示例中, 有两个重组作用域, 即Greeting作用域Button作用域. 在层次结构中, 我们可以看到Button作用域也在Greeting作用域的内部, 但在重组作用域中, Greeting作用域Button作用域可以独立重组, Compose可以重组Greeting作用域而不重组Button作用域, 也可以重组Button作用域而不重组Greeting作用域, 或者在一个重组过程中同时重组这两个作用域.

为了了解重组作用域是如何工作的, 让我们做一些实验.

第一部分 - 理解重组作用域

在开始之前, 我们需要创建一个函数来跟踪重组, 这个函数将在每次重组发生时调用, 并计算已经调用了多少次重组. 我们需要让这个函数内联, 以确保这个可组合函数没有自己的重组作用域.

kotlin 复制代码
class Ref(var value: Int)

@Composable
inline fun LogCompositions(msg: String) {
    val ref = remember { Ref(0) }
    SideEffect { ref.value++ }
    Log.d("RecompositionLog", "Compositions: $msg ${ref.value}")
}

在这一部分中, 我们将了解重组作用域是如何工作的. 让我们看看下面的示例, 当我们在Android Studio上创建撰写活动时, 我们修改了默认的"Greeting"屏幕, 以展示重组作用域是如何工作的.

kotlin 复制代码
@Composable
fun Greeting() {
    var state by remember {
        mutableStateOf("Hi Foo")
    }
    LogCompositions(msg = "Greeting Scope")
    Text(text = state)
    Button(
        onClick = { state = "Hi Foo ${Random.nextInt()}" },
        modifier = Modifier
            .padding(top = 32.dp)
    ) {
        LogCompositions(msg = "Button Scope")
        Text(
            text = "Click Me!"
        )
    }
}

在这个示例中, 我们创建了一个文本和一个按钮, 只要点击按钮, 就会更新文本读取的状态. 这里有两个组成作用域:"Greeting作用域"和"Button作用域". 正如我之前解释过的, 这两个作用域都可以独立执行. 让我们试着运行这个示例.

结果:Greeting作用域

"Greeting作用域"被调用, 但"Button作用域"没有被调用. 为什么? 因为state是在第7行读取的, 而该LoC是 Greeting作用域的一部分. 为什么Button作用域没有被调用? 因为在Button作用域上没有任何组成组件读取可观察的状态.

使状态声明更接近调用者

在上一个示例中, Compose重组了"Greeting作用域", 结果是每次点击按钮时都会调用"Greeting作用域"的 LogComposition. 如果我们移动状态和LogComposition的顺序呢? 会不会因为我们在LogComposition行之后才定义和读取状态而导致LogComposition不被调用? 让我们试试看.

kotlin 复制代码
@Composable
fun Greeting() {
    LogCompositions(msg = "Greeting Scope")
    var state by remember {  //We move this line of code after log recomposition and closer to its caller and 
        mutableStateOf("Hi Foo")
    }
    Text(text = state)
    Button(
        onClick = { state = "Hi Foo ${Random.nextInt()}" },
        modifier = Modifier
            .padding(top = 32.dp)
    ) {
        LogCompositions(msg = "Button Scope")
        Text(
            text = "Click Me!"
        )
    }
}

结果:

结果:Greeting作用域--使状态更接近调用者

结果保持不变, 因为Compose不是按代码行调用的, 而是按组成作用域调用的, 其中LogRecomposition和Text在同一个组成作用域中读取状态, 移动顺序不会产生任何影响.

将读取状态的组件移到另一个作用域

现在让我们把读取状态的组件移到另一个作用域, 从Greeting作用域移到Button作用域如下所示:

kotlin 复制代码
@Composable
fun Greeting() {
    var state by remember {
        mutableStateOf("Hi Foo")
    }
    LogCompositions(msg = "Greeting Scope")
    Text(text = "Hi Foo")
    Button(
        onClick = { state = "Hi Foo ${Random.nextInt()}" },
        modifier = Modifier
            .padding(top = 32.dp)
    ) {
        LogCompositions(msg = "Button Scope")
        Text(
            text = state
        )
    }
}

结果:

结果 : Greeting作用域 - 将状态移动到另一个组成作用域

是的, 正如预期的那样:Button作用域被调用, 因为我们将读取状态的组件移动到了Button作用域. 尽管根据层次结构, Button作用域 也是Greeting作用域 的一部分, 但Compose能够调用它而不调用其父作用域组件. 要进一步了解这种跳转过程, 您可以阅读更多关于"Donute Hole Skipping" 的文章.

第二部分 - 内联可组合函数

我们在此添加新组件, 即Column. 我相信大多数人在Jetpack Compose中创建用户界面时都会用到这个组件. 在我们尝试运行这个示例之前, 根据第一部分的示例, 这段代码将只调用Column作用域, 对吗? 因为在第9行读取状态的组件位于Column作用域. 让我们来看看.

kotlin 复制代码
@Composable
fun Greeting() {
    var state by remember {
        mutableStateOf("Hi Foo")
    }
    LogCompositions(msg = "Greeting Scope")
    Column { // We add new component here
        LogCompositions(msg = "Column Scope")
        Text(text = state)
        Button(
            onClick = { state = "Hi Foo ${Random.nextInt()}" },
        ) {
            LogCompositions(msg = "Button Scope")
            Text(
                text = "Click Me"
            )
        }
    }
}

结果:

结果:内联可组合函数

好吧, 这是不应该发生的. Column作用域被调用了, 但为什么Greeting作用域也被调用了呢? 要回答这个问题, 让我们打开Column的定义

内联Column函数

Column(以及Compose中的大多数容器, 如方框、行、约束布局)都是内联函数. 我们知道, 当我们将内联函数编译到 Java 代码中时, 该函数并不真正存在, 它指示编译器将完整的主体函数带给其调用者, 而无需创建新函数. 这就使得内联可编译函数(如Column)没有自己的重构作用域, 而是遵循父作用域. 这就是为什么在上面的示例中,Column作用域Greeting作用域一起被调用, 因为Column作用域实际上并不存在.

您可以在 Kotlin 官方文档或这篇文章中阅读更多关于内联函数的内容:Kotlin中的内联函数

本文的简要说明

内联函数与非内联函数

在对第1部分和第2部分进行抽查后, 我有一个问题:

"如果重组在作用域组件内重组了全部代码, 那么为什么我们不能将每一个组件分块/包裹成更小的作用域来获得更小的重组作用域呢?"

在回答这个问题之前, 让我们跳到第三部分.

第三部分--重构作用域和可跳过组件

Android Studio为Jetpack Compose提供了布局检查器(Layout Inspector), 有了这个布局检查器, 我们就可以跟踪重组的调用次数, 并查看哪些组件被重组或跳过.

使用布局检查器获取重组次数*

Layout Inspector

让我们修改代码示例, 现在我们添加了新的状态和读取状态的组件, 但该状态永远不会发生变化, 让我们看看下面的代码:

kotlin 复制代码
@Composable
fun Greeting() {
    var state by remember {
        mutableStateOf("Hi Foo")
    }
    var staticState by remember {
        mutableStateOf("This state never changes")
    }
    LogCompositions(msg = "Greeting Scope")
    Column {
        LogCompositions(msg = "Column Scope")
        Text(text = state)
        Text(text = staticState)
        Button(
            onClick = { state = "Hi Foo ${Random.nextInt()}" },
        ) {
            LogCompositions(msg = "Button Scope")
            Text(
                text = "Click Me"
            )
        }
    }
}

在第6行, 我们有一个名为staticState的状态. 这个状态是可变的, 但永远不会发生变化, 这个状态在第13行被文本组件读取. 因此, 我们有两个状态, 并在同一作用域内被两个文本组件读取. 现在让我们运行上面的代码, 比较LogComposition和Layout Inspector的结果.

LogComposition和布局检查器的结果

我们可以看到, 第一个文本和第二个文本位于同一作用域, 即"Greeting作用域". 每次我们点击按钮时都会调用该Greeting作用域, 但它只会更新state变量, 而不会更新statisState.

请仔细观察我们的布局检查器, 即使调用了Greeting作用域上的全部代码, 金豪也知道第二个文本读取的状态没有更新, 因此金豪直接跳过了重新组成这个组件. 通过跳过不必要的重新编译组件, Compose可以高效地工作.

现在我们知道, 没有必要对重组作用域进行分块或将每个组件封装到更小的重组作用域中, 更重要的是确保重组作用域中的每个组件都是可跳过的, 因此即使在单个重组作用域中存在大量组件, 也不会影响性能, 因为Compose只重组必要的组件, 其他组件则不会重组.

要获得可跳过和不可跳过组件的完整报告, 我们可以使用Composable衡量指标或第三方库, 如Mendable.

结论

  • Compose通过调用读取状态的最小重组作用域进行智能重组
  • 与其担心一个作用域中的代码行, 不如确保重组作用域中的组件是可跳过的, 这将提高Compose的工作效率.
  • 一般来说, 在不知道函数是否内联的情况下, 很难确定哪些作用域将被重组, 因此应使用辅助工具(如Layout Inspector)来跟踪重组情况.
  • 出于优化目的, 重组规则可能会在未来的开发中发生变化
相关推荐
拭心8 小时前
Google 提供的 Android 端上大模型组件:MediaPipe LLM 介绍
android
带电的小王10 小时前
WhisperKit: Android 端测试 Whisper -- Android手机(Qualcomm GPU)部署音频大模型
android·智能手机·whisper·qualcomm
梦想平凡10 小时前
PHP 微信棋牌开发全解析:高级教程
android·数据库·oracle
元争栈道11 小时前
webview和H5来实现的android短视频(短剧)音视频播放依赖控件
android·音视频
阿甘知识库12 小时前
宝塔面板跨服务器数据同步教程:双机备份零停机
android·运维·服务器·备份·同步·宝塔面板·建站
元争栈道12 小时前
webview+H5来实现的android短视频(短剧)音视频播放依赖控件资源
android·音视频
MuYe12 小时前
Android Hook - 动态加载so库
android
居居飒13 小时前
Android学习(四)-Kotlin编程语言-for循环
android·学习·kotlin
Henry_He16 小时前
桌面列表小部件不能点击的问题分析
android
工程师老罗16 小时前
Android笔试面试题AI答之Android基础(1)
android