Jetpack Compose - 关于重组

前言

本文把Jetpack Compose简称为Compose,在开始之前,先明确几个重要的概念。

  • @Composable注解标注的函数或者Lambda,称为可组合项。
  • 由N个可组合项组成的树状结构,称为组合。
  • 第一次渲染的组合,称为初始组合。
  • 初始组合之后,重新渲染可组合项,称为重组。
  • 一次完整的重组包括执行并渲染可组合项,如果只执行未渲染,称为跳过重组。

本文关注的是渲染UI的可组合项,Compose中还有一些不渲染UI的可组合项,不在本文讨论范围内。

重组范围

重组范围是指,重组时,从哪个可组合项开始重组,了解重组范围很重要,举个例子:

kotlin 复制代码
@Composable
private fun Content() {
    var number by remember { mutableStateOf(0) }
    Log.i("compose-demo", "execute content")

    Column {
        Text(
            text = number.toString(),
            modifier = Modifier
                .clickable {
                    // 点击修改number,触发重组
                    Log.i("compose-demo", "click")
                    number++
                }
                .padding(10.dp)
        ).also {
            Log.i("compose-demo", "execute text")
        }
    }
}

代码很简单,Text显示点击的次数number,点击Text修改number值,number实际上是一个MutableState,通过by委托就好像一个普通的Int一样,可以直接读写。

在Compose中对State的修改,会触发读取State的可组合项发生重组。所以修改number会触发重组,顺便提一下,MutableState继承了State

运行代码并点击Text,看日志输出:

css 复制代码
compose-demo             I  click
compose-demo             I  execute content
compose-demo             I  execute text

可以看到,ContentText,这2个可组合项都被执行了。Compose是这样确定重组范围的:

  1. 查找读取State的可组合项
  2. 查找第1步中可组合项的父可组合项
  3. 调用父可组合项开始重组

父可组合项是指,离当前可组合项最近的上级可组合项,下面,我们把父可组合项简称为父项

在我们的例子中,第1步查找到了Text这个可组合项,第2步查找到了Content这个父项,第3步执行Content开始重组,最终Text发生了重组。

这里你可能会有疑问,Text的父项为什么是Content,而不是Column lambda,因为Column是一个inline的函数,它的lambdainline了,所以找到的是Content

Layout Inspector看看Text的重组:

两列红框,左边一列显示重组的次数,右边一列显示跳过重组的次数。可以看到点击后,Text发生了1次重组。

如果重组范围内有多个可组合项,理想情况下,参数不变的可组合项应该跳过重组。

修改代码,新增可组合项,测试一下跳过重组:

kotlin 复制代码
@Composable
private fun Content() {
    var number by remember { mutableStateOf(0) }
    Log.i("compose-demo", "execute content")

    Column {
        Text(
            text = number.toString(),
            modifier = Modifier
                .clickable {
                    Log.i("compose-demo", "click")
                    number++
                }
                .padding(10.dp)
        ).also {
            Log.i("compose-demo", "execute text")
        }

        // 新增一个Text
        Text(text = "new text").also {
            Log.i("compose-demo", "execute new text")
        }
    }
}

代码只新增了一个Text,它的参数是固定的字符串new text

运行代码,并点击上面的Text,查看重组情况:

从日志看,新增的Text在重组时被执行了,因为它在重组范围之内。注意,它只是被执行了,并没有重新渲染,在Layout Inspector中可以看到,它跳过了1次重组。

跳过重组

然而,并不是所有参数不变的可组合项都会跳过重组,举个例子:

kotlin 复制代码
@Composable
private fun Content() {
    var number by remember { mutableStateOf(0) }
    // 用户信息
    val user = remember { User() }

    Column {
        Text(
            text = number.toString(),
            modifier = Modifier
                .clickable {
                    number++
                }
                .padding(10.dp)
        )

        // 用户信息
        UserInfo(user = user)
    }
}

@Composable
private fun UserInfo(user: User) {
    Text(text = user.name)
}

class User(
    var name: String = "default"
)

代码比较简单,创建一个UserInfo函数,它的参数类型是User,用来显示用户信息。把UserInfo放在Text的下面,并且给它传一个user参数,user参数始终不变,是同一个对象。

运行代码,看看重组情况:

可以看到,虽然参数user不变,但UserInfo还是重组了,而不是跳过重组。这是为什么呢?因为Compose认为UserInfo的参数userunstable,即不稳定的状态。

  • 当可组合项有unstable参数,它不会跳过重组
  • 当可组合项的所有参数都是stable,即稳定的,它才有机会跳过重组

什么情况下,Compose会认为可组合项的某个参数是unstable?重点来了:

当可组合项的参数内容可以被修改,并且Compose不能确定参数内容是否被修改了,Compose就会把这个参数标记为unstable

在我们的例子中,User类的name属性是var的字符串,可以在任何地方被修改,并且Compose不知道什么时候会被修改,所以user参数是unstable

如果参数被修改了,Compose又不知道参数被修改了,那Compose就不会及时渲染修改后的参数,导致数据变了,UI没及时变的bug。

所以遇到含有unstable参数的可组合项,Compose会做一下最后的挣扎,只要在重组范围内,每次重组都渲染。虽然没办法保证数据变了UI及时变,但至少渲染后是正确的。

参数不变的时候,这种多余的重组,会影响性能,所以要尽量让可组合项的参数stable,这样子可组合项才有机会跳过重组,提高性能。

我们修改代码,尝试让参数user变为stable

kotlin 复制代码
class User(
    val name: String = "default"
)

name属性由var改为val,不允许修改。运行代码,看看重组情况:

可以看到UserInfo已经可以跳过重组了,此时参数user已经是stable了。

为什么改为val就可以了呢?因为此时User对象一旦被创建就无法修改了,也就是说UserInfo渲染之后,要改变它,只能传一个新的User对象给它,这样就做到了数据变化,UI也及时变化。

如果可组合项的所有参数都是stable的,在重组发生时,Compose会用上一次渲染UI的参数和本次的参数一一比较。

怎么比较参数呢?

先比较是否同一个对象,如果是同一个对象,继续比较下一个参数,否则调用equals比较,返回true继续比较下一个参数,返回false,停止比较,开始重组。

目前User是一个普通类,没有重写equals,所以默认equals比较的是对象的引用。这样会导致两个不同对象他们的name值一样,也会发生重组。如果两个不同对象的name值一样,应该要跳过重组。

再优化一下代码:

kotlin 复制代码
class User(
    val name: String = "default"
) {
    override fun equals(other: Any?): Boolean {
        Log.i("compose-demo", "$this equals")
        return if (other is User) {
            this.name == other.name
        } else {
            false
        }
    }

    init {
        Log.i("compose-demo", "$this init")
    }
}

重写了equals,并打印日志,此时即使两个不同的对象,只要他们的name值一样,就可以跳过重组。

注意:实际开发中应该同时重写hashCode,以使对象可以在哈希算法的容器中被正确使用。

kotlin 复制代码
@Composable
private fun Content() {
    var number by remember { mutableStateOf(0) }
    // number作为key
    val user = remember(number) { User() }

    Column {
        Text(
            text = number.toString(),
            modifier = Modifier
                .clickable {
                    number++
                }
                .padding(10.dp)
        )
        UserInfo(user = user)
    }
}

修改代码,把number当作remember的key,当number变化时,会创建一个新的User对象赋值给user参数,但是对象的name值不变,都是default

测试一下对象变化,属性值不变的情况下,是否会跳过重组。

从日志可以看出,第一次创建的对象是User@d372b4c,点击之后创建了新对象,并且User@d372b4c对象的equals被调用了,由于他们的name值相同,所以equals返回true,跳过了重组。

实际开发中,为了方便,我们可以直接使用data class

stable vs unstable

字符串和基本数据类型被默认为stable,因为他们一旦被创建就无法修改,接口被默认为unstable

Compose编译器支持输出日志,让我们可以直观的看到参数是stable还是unstable

修改build.gradle.kts,添加以下配置:

kotlin 复制代码
// build.gradle.kts
kotlinOptions {
    freeCompilerArgs += listOf(
        "-P",
        "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.buildDir.absolutePath}/compose_compiler"
    )
}

重新运行代码,在配置的目录下会生成日志文件:

可以看到UserInfo函数被标记为skippable,表示支持跳过重组,同时它的参数user被标记为stable,因为User类被标记为stable

我们把Username改回var,看看输出的结果:

kotlin 复制代码
class User(
    var name: String = "default"
)

运行代码,查看输出结果:

可以看到又变回unstable了。

unstable的接口

在实际开发中,比较常见的unstable参数类型是接口,例如ListSetMap等。

修改一下代码:

kotlin 复制代码
@Composable
private fun Content() {
    var number by remember { mutableStateOf(0) }
    val user = remember { User() }

    Column {
        Text(
            text = number.toString(),
            modifier = Modifier
                .clickable {
                    number++
                }
                .padding(10.dp)
        )
        UserInfo(user = user)
    }
}

@Composable
private fun UserInfo(user: User) {
    Text(text = user.cars.toString())
}

data class User(
    val cars: List<String> = listOf("BMW"),
)

User类现在只有一个属性cars,表示用户拥有的车辆,它是List类型。

运行代码,看看重组情况:

可以看到,虽然cars一旦创建就无法修改,但最终UserInfo还是发生了重组。

再看看编译器生成的日志,确定一下是不是unstable

可以看到它确实被标记为unstable

在Kotlin中,虽然List接口没办法直接修改,但是它可能指向MutableList,例如:

kotlin 复制代码
val cars = mutableListOf("BMW")
val user = User(cars = cars)

// 在外部直接修改
cars.add("911")

这种情况,又会导致数据变了,UI没有及时变的问题,所以Compose默认接口的参数是unstable的。当然了,如果确定不会在外部去修改cars,可以给User加上@Immutable注解,像这样:

kotlin 复制代码
// 加上注解
@Immutable
data class User(
    val cars: List<String> = listOf("BMW")
)

这个注解比较好理解,顾名思义,表示不可变的,有兴趣的读者可以看一下该注解的详细英文注释。

简单来说就是:如果某个Class被标记为@Immutable,意味着这个Class的对象一旦被创建,它所有public属性的内容都不会再改变了。

加上注解之后,再次运行代码,看看重组情况:

可以看到,现在UserInfo可以跳过重组了。

再看看编译器生成的日志,确定一下是不是stable

可以看到它确实被标记为stable了。

在实际开发中,如果给某个Class加上@Immutable注解,应该遵守不变的约定。

@Stable注解

最后,我们单独看一下@Stable注解,这个注解也是和Compose约定状态为stable

先看一下注解源码:

kotlin 复制代码
@MustBeDocumented  
@Target(  
    AnnotationTarget.CLASS,  
    AnnotationTarget.FUNCTION,  
    AnnotationTarget.PROPERTY_GETTER,  
    AnnotationTarget.PROPERTY  
)  
@Retention(AnnotationRetention.BINARY)  
@StableMarker  
annotation class Stable

相较于@Immutable@Stable注解的Target除了AnnotationTarget.CLASS之外,还支持AnnotationTarget.FUNCTIONAnnotationTarget.PROPERTY_GETTER以及AnnotationTarget.PROPERTY

TargetAnnotationTarget.CLASS时,它们有什么区别?

@Stable放松了限制,允许Class拥有可变的public属性,前提是属性的变化能被Compose监测到。

先看看@Stable错误的用法

kotlin 复制代码
// 错误用法
@Stable
class User {  
    var name: String = "default"
}

这是一个错误的用法 ,因为它违反了约定:属性的变化能被Compose监测到

修改代码,遵守这个约定:

kotlin 复制代码
// 正确用法
@Stable
class User {
    // name的读写委托给了MutableState
    var name: String by mutableStateOf("default")
}

此时,对name的读写,实际上是对MutableState的读写,因为这里我们使用了by委托。上文我们已经提到过了,State的变化,Compose可以监测到。

最后,我们再来看看@Stable注解用在AnnotationTarget.CLASS之外的场景。其实就是用在函数上面的场景,因为Kotlin属性的本质在Java看来就是GetterSetter

当函数的输入参数相同时,返回结果总是相同,并且输入的参数类型和返回的类型都是stable的。如果满足这个条件,就可以把该函数标注为@Stable

至于满足条件,标注@Stable后会有什么优化,作者暂时也不清楚,如果有知道的同学,麻烦评论告知。

总结一下:

如果一个类被标记为@Immutable,那么它也可以被标记为@Stable。我们应该优先使用@Immutable,只有@Immutable不满足的时候,例如有var的属性,且属性变化可以被Compose监测到,此时我们才可以用@Stable

结束

以上就是全部内容,如果有错误的地方,还请读者评论指出,一起学习,如果有任何问题,也可以加作者的微信探讨,感谢你的阅读。

作者微信:zj565061763

相关推荐
雨白8 小时前
Jetpack系列(二):Lifecycle与LiveData结合,打造响应式UI
android·android jetpack
kk爱闹9 小时前
【挑战14天学完python和pytorch】- day01
android·pytorch·python
每次的天空11 小时前
Android-自定义View的实战学习总结
android·学习·kotlin·音视频
恋猫de小郭12 小时前
Flutter Widget Preview 功能已合并到 master,提前在体验毛坯的预览支持
android·flutter·ios
断剑重铸之日12 小时前
Android自定义相机开发(类似OCR扫描相机)
android
随心最为安12 小时前
Android Library Maven 发布完整流程指南
android
岁月玲珑13 小时前
【使用Android Studio调试手机app时候手机老掉线问题】
android·ide·android studio
还鮟17 小时前
CTF Web的数组巧用
android
小蜜蜂嗡嗡18 小时前
Android Studio flutter项目运行、打包时间太长
android·flutter·android studio
aqi0018 小时前
FFmpeg开发笔记(七十一)使用国产的QPlayer2实现双播放器观看视频
android·ffmpeg·音视频·流媒体