前言
本文把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
可以看到,Content
,Text
,这2个可组合项都被执行了。Compose是这样确定重组范围的:
- 查找读取
State
的可组合项 - 查找第1步中可组合项的父可组合项
- 调用父可组合项开始重组
父可组合项
是指,离当前可组合项最近的上级可组合项,下面,我们把父可组合项简称为父项
。
在我们的例子中,第1步查找到了Text
这个可组合项,第2步查找到了Content
这个父项,第3步执行Content
开始重组,最终Text
发生了重组。
这里你可能会有疑问,Text
的父项为什么是Content
,而不是Column lambda
,因为Column
是一个inline
的函数,它的lambda
被inline
了,所以找到的是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
的参数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
:
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
。
我们把User
的name
改回var
,看看输出的结果:
kotlin
class User(
var name: String = "default"
)
运行代码,查看输出结果:
可以看到又变回unstable
了。
unstable的接口
在实际开发中,比较常见的unstable
参数类型是接口,例如List
,Set
,Map
等。
修改一下代码:
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.FUNCTION
,AnnotationTarget.PROPERTY_GETTER
以及AnnotationTarget.PROPERTY
。
当Target
为AnnotationTarget.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
看来就是Getter
和Setter
。
当函数的输入参数相同时,返回结果总是相同,并且输入的参数类型和返回的类型都是stable
的。如果满足这个条件,就可以把该函数标注为@Stable
。
至于满足条件,标注@Stable
后会有什么优化,作者暂时也不清楚,如果有知道的同学,麻烦评论告知。
总结一下:
如果一个类被标记为@Immutable
,那么它也可以被标记为@Stable
。我们应该优先使用@Immutable
,只有@Immutable
不满足的时候,例如有var
的属性,且属性变化可以被Compose监测到,此时我们才可以用@Stable
。
结束
以上就是全部内容,如果有错误的地方,还请读者评论指出,一起学习,如果有任何问题,也可以加作者的微信探讨,感谢你的阅读。
作者微信:zj565061763