前言
本文把Jetpack Compose简称为Compose,在开始之前,先明确几个重要的概念。
- 被
@Composable注解标注的函数或者Lambda,称为可组合项。 - 由N个可组合项组成的树状结构,称为组合。
- 第一次渲染的组合,称为初始组合。
- 初始组合之后,重新渲染可组合项,称为重组。
- 一次完整的重组包括执行并渲染可组合项,如果只执行未渲染,称为跳过重组。
本文关注的是渲染UI的可组合项,Compose中还有一些不渲染UI的可组合项,不在本文讨论范围内。
重组范围
重组范围是指,重组时,从哪个restartable可组合项开始执行。
restartable表示可重启的,可组合项默认是restartable,除非它被标注为@NonRestartableComposable。
举个例子:
@Composable
private fun Content() {
var number by remember { mutableStateOf(0) }
Log.i("compose-demo", "execute content")
Column {
Log.i("compose-demo", "execute column lambda")
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,查看日志输出:
compose-demo I click
compose-demo I execute content
compose-demo I execute column lambda
compose-demo I execute text
可以看到,Content,Column lambda,Text,都被执行了。
Compose是这样确定重组范围的:查找读取State的可组合项,如果它是restartable则从它开始执行,否则继续往上一级查找,直到找到restartable的可组合项。
number的读取发生在Column lambda中,理论上应该是执行Column lambda,为什么Content被执行了?因为Column是一个inline的函数,它的lambda被inline了,所以找到的是Content。
用Layout Inspector查看Text的重组情况:
两列红框,左边一列显示重组的次数,右边一列显示跳过重组的次数。可以看到点击后,Text发生了1次重组。
既然Column是inline的,那我们写一个不是inline的MyColumn继续测试一下重组范围:
@Composable
private fun MyColumn(content: @Composable ColumnScope.() -> Unit) {
Column(content = content)
}
再把Content函数中的Column替换为MyColumn,运行代码点击Text,查看日志输出:
compose-demo I click
compose-demo I execute column lambda
compose-demo I execute text
可以看到,现在是从自定义的MyColumn lambda开始执行重组了。
跳过重组
如果重组范围内有多个可组合项,理想情况下,参数不变的可组合项应该跳过重组。
然而,并不是所有参数不变的可组合项都会跳过重组,举个例子:
@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的参数user是unstable,即不稳定的状态。
- 当可组合项有
unstable参数,它不会跳过重组 - 当可组合项的所有参数都是
stable,即稳定的,它才有机会跳过重组
什么情况下,Compose会认为可组合项的某个参数是unstable?重点来了:
当可组合项的参数内容可以修改,并且Compose不能确定参数内容是否被修改了,Compose就会认为这个参数是unstable。
在我们的例子中,User类的name属性是var的字符串,可以在任何地方被修改,并且Compose不知道什么时候会被修改,所以user参数是unstable。
如果参数被修改了,Compose又不知道参数被修改了,那Compose就不会及时渲染修改后的参数,导致数据变了,UI没及时变的bug。
所以遇到含有unstable参数的可组合项,Compose会做一下最后的挣扎,只要在重组范围内,每次重组都渲染。虽然没办法保证数据变了UI及时变,但至少渲染后是正确的。
参数不变的时候,这种多余的重组,会影响性能,所以要尽量让可组合项的参数stable,这样子可组合项才有机会跳过重组,提高性能。
我们修改代码,尝试让参数user变为stable:
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值一样,应该要跳过重组。
再优化一下代码:
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,以使对象可以在哈希算法的容器中被正确使用。
@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
字符串,基本数据类型以及Lambda被默认为stable,因为他们一旦被创建就无法修改。
Compose编译器支持输出日志,让我们可以直观的看到参数是stable还是unstable。
修改build.gradle.kts,添加以下配置:
// 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。
我们把User的name改回var,运行代码,查看输出结果:
class User(
var name: String = "default"
)
可以看到又变回unstable了。
unstable类型
在实际开发中,比较常见的unstable类型是一些集合接口,例如List,Set,Map。
修改一下代码:
@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,例如:
val cars = mutableListOf("BMW")
val user = User(cars = cars)
// 在外部直接修改
cars.add("911")
这种情况,又会导致数据变了,UI没有及时变的问题,所以Compose默认这些集合接口是unstable。
当然了,如果确定不会在外部去修改cars,可以给User加上@Immutable注解,像这样:
// 加上注解
@Immutable
data class User(
val cars: List<String> = listOf("BMW")
)
这个注解比较好理解,顾名思义,表示不可变的,有兴趣的读者可以看一下该注解的详细英文注释。
给某个Class加上@Immutable注解表示我们遵守约定:这个Class的对象一旦被创建,它所有public属性的内容都不会再改变了。
加上注解之后,再次运行代码,查看重组情况:
可以看到,现在UserInfo可以跳过重组了。
查看编译器生成的日志,确定一下是不是stable:
可以看到它确实被标记为stable了。
在实际开发中,如果给某个Class加上@Immutable注解,应该遵守上面提到的约定,否则可能会出现数据变了,UI没有及时变的问题。
@Stable注解
最后,我们单独看一下@Stable注解,这个注解也是和Compose约定状态为stable。
先看一下注解源码:
@MustBeDocumented
@Target(
AnnotationTarget.CLASS,
AnnotationTarget.FUNCTION,
AnnotationTarget.PROPERTY_GETTER,
AnnotationTarget.PROPERTY
)
@Retention(AnnotationRetention.BINARY)
@StableMarker
annotation class Stable
相较于@Immutable,@Stable注解的Target除了AnnotationTarget.CLASS之外,还支持AnnotationTarget.FUNCTION,AnnotationTarget.PROPERTY_GETTER以及AnnotationTarget.PROPERTY。
当Target同为AnnotationTarget.CLASS时,它们有什么区别?
@Stable放松了限制,允许Class拥有可变的public属性,前提是属性的变化能被Compose监测到。
先看一下@Stable错误的用法:
// 错误用法
@Stable
class User {
var name: String = "default"
}
这是一个错误的用法,因为它违反了约定:属性的变化能被Compose监测到。
修改代码,遵守这个约定:
// 正确用法
@Stable
class User {
// name的读写委托给了MutableState
var name: String by mutableStateOf("default")
}
此时,对name的读写,实际上是对MutableState的读写,因为这里使用了by委托。上文已经提到过了,State的变化,Compose可以监测到。
可变的属性也可以是val的,例如:
// 正确用法
@Stable
class User {
private var _name: String by mutableStateOf("default")
// val的可变public属性
val name: String get() = _name
}
这种用法也是正确的,虽然name是val,但是它读取的是可变的_name,它的变化也是可以被Compose监测到的。
最后,再看一下@Stable注解用在AnnotationTarget.CLASS之外的场景。其实就是用在函数上面的场景,因为Kotlin属性的本质在Java看来就是Getter和Setter。
如果要把函数标注为@Stable,必须满足以下条件:
当函数的输入参数相同时,返回结果总是相同,并且输入的参数类型和返回的类型都是stable。
对函数而言,满足条件并标注@Stable后会有什么优化,作者暂时未找到相关的信息,如果有知道的同学,麻烦评论告知,有兴趣的读者可以看一下该注解的详细英文注释。
关于注解,总结一下:
如果一个类被标记为@Immutable,那么它也可以被标记为@Stable。我们应该优先使用@Immutable,只有@Immutable不满足的时候,才考虑@Stable。
@Immutable和@Stable表示我们遵守Compose编译器stable的约定,而不是添加注解这个行为使类型变为stable。
结束
以上就是全部内容,如果有错误的地方,还请读者评论指出,一起学习,如果有任何问题,也可以加作者的微信探讨,感谢你的阅读。
作者微信:zj565061763