前言
在上一篇文章Compose编程思想 -- 深入理解声明式UI的状态订阅与自动更新 中,我详细介绍了Compose作为声明式UI框架,如何完成数据的自动刷新,常用的就是通过mutableStateOf
、mutableStateListOf
的订阅能力实现对数据变化的监听,那么本节将会介绍从触发重组到重组操作的执行过程。
1 重组的风险和优化
来看一个非常简单的例子,通过Text
显示一个文案,当点击文案时,刷新为最新的数据。
kotlin
setContent {
ComposeStudyTheme {
// 重组作用域
var name by remember {
mutableStateOf("Alex")
}
Text(text = name, Modifier.clickable {
name = "Alex ++"
})
}
}
那么在这个过程中,当点击Text时,将name设置为新值,此时Text使用了name,那么Text所在的作用域,也就是ComposeStudyTheme
会触发重组,当屏幕下一帧刷新的时候,重组作用域内的所有代码都会执行。
接下来,我们使用Column
替换ComposeStudyTheme
,如果按照我们的之前的设想,当name发生变化时,那么在只有Column
大括号内部的代码会重组,然而是这样吗?
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
Column {
Log.d("TAG", "onCreate: composition 2")
// 重组作用域
var name by remember {
mutableStateOf("Alex")
}
Text(text = name, Modifier.clickable {
name = "Alex ++"
})
}
}
通过打印日志发现,当name发生变化之后,在Column
外层的组件也发生了重组,其实我们只需要Text刷新即可,但是换了Column之后,导致重组作用域变成了setContent,其实处理一些不必要的刷新,带来系统资源的浪费。
js
2024-03-19 15:35:45.339 20899-20899 TAG com.lay.composestudy D onCreate: composition 1
2024-03-19 15:35:45.340 20899-20899 TAG com.lay.composestudy D onCreate: composition 2
那么为什么会这样呢?我们看下Column的源码:
kotlin
@Composable
inline fun Column(
modifier: Modifier = Modifier,
verticalArrangement: Arrangement.Vertical = Arrangement.Top,
horizontalAlignment: Alignment.Horizontal = Alignment.Start,
content: @Composable ColumnScope.() -> Unit
) {
val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment)
Layout(
content = { ColumnScopeInstance.content() },
measurePolicy = measurePolicy,
modifier = modifier
)
}
我们发现Column
是内联函数,这就意味着编译器在执行的时候,其实Column
这个大括号是不存在的,相当于直接加到了setContent
代码中,因此在判断重组作用域的时候,需要注意一下父容器是否为内联函数,如果为内联函数,那么重组作用域将会依次往上提升,直到达到非内联函数为止。
1.1 重组的风险
前面我们介绍了如何判断重组作用域的范围,因为Compose自身的特性,可能会导致重组作用域扩大,引发一些不必要的刷新,从而影响系统的性能。
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
NoParamsCompose()
Column {
Log.d("TAG", "onCreate: composition 2")
// 重组作用域
var name by remember {
mutableStateOf("Alex")
}
Text(text = name, Modifier.clickable {
name = "Alex ++"
})
}
}
@Composable
fun NoParamsCompose() {
Log.d("TAG", "NoParamsCompose: ----composition")
Text(text = "我没有参数")
}
看下上面的例子,既然使用了Column
组件导致重组作用域变大,那么NoParamsCompose
在重组的过程中必然会被执行,但是运行之后发现,NoParamsCompose
在重组的时候并没有执行。
我们之前担心的问题并没有出现,是我们代码写的有问题吗?其实并不是,而是Compose自身的性能优化带来的。Compose在重建的过程中,会比较Composable函数的参数与上次是否发生了变化,如果没有发生变化,那么在重组的过程中就会跳过代码的执行。
NoParamsCompose
函数没有参数,因此并没有参数变化这一说,因此在重组的过程中便会跳过执行。
kotlin
@Composable
fun NoParamsCompose(content: String) {
Log.d("TAG", "NoParamsCompose: ----composition")
Text(text = "我有参数 $content")
}
现在我们对NoParamsCompose
改造一下,加了一个参数,那么如果在代码中入参写死,重组的过程中依然会跳过;如果将入参改为name,那么在重组的过程中就会刷新组件。
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
NoParamsCompose("Alex ooo")
Column {
Log.d("TAG", "onCreate: composition 2")
// 重组作用域
var name by remember {
mutableStateOf("Alex")
}
Text(text = name, Modifier.clickable {
name = "Alex ++"
})
}
}
那么问题来了!
1.2 如何判断Composable函数入参是否变化?
其实在Compose当中,是通过结构性判断Composable函数的入参是否发生变化,即通过equals
来判断内容是否发生变化,如果没有变化,那么在重组的过程中就会跳过。
前面我们验证了基本数据类型,那么如果是实体对象,还能经得起验证吗?
kotlin
data class Weather(
val city:String,
val temperature:Int
)
通过ShowWeather
可组合函数来展示一个地区的温度,当点击Text触发重组之后,即便是每次调用ShowWeather
时,都会新建一个Weather
对象,但是并没有触发重组,这就说明Compose对于入参的判断就是通过结构化检测,即equals检测。
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
Column {
Log.d("TAG", "onCreate: composition 2")
ShowWeather(weather = Weather("北京", 12))
// 重组作用域
var name by remember {
mutableStateOf("Alex")
}
Text(text = name, Modifier.clickable {
name = "Alex ++"
})
}
}
@Composable
fun ShowWeather(weather: Weather) {
Log.d("TAG", "ShowWeather: ----composition")
Column {
Text(text = "城市:${weather.city}")
Text(text = "温度:${weather.temperature}")
}
}
何为equals检测,就是检查Weather
的成员变量前后是否发生了变化,虽然前后两次对象的引用都发生了变化,但是内容是一致的,data class是默认重写了equals方法,不需要我们自己手动处理。
kotlin
class Weather(
val city: String,
val temperature: Int
) {
override fun equals(other: Any?): Boolean {
return city == (other as Weather).city
&& temperature == (other as Weather).temperature
}
override fun hashCode(): Int {
return super.hashCode()
}
}
但是当我们将参数的val
变为var
之后,发现在重组的时候,ShowWeather
可组合函数内部的代码被执行了,但是内容并没有发生变化。
kotlin
data class Weather(
var city:String,
var temperature:Int
)
其实这也是Compose做的一个安全机制 ,因为当参数变为var
的时候,它可以在任何地方都被修改,它不能保证一直不变,因此Compose会无脑刷新页面,从而保证一直持有最新的值。
kotlin
var w = Weather("北京", 12)
val w2 = Weather("北京", 12)
val w3 = Weather("北京", 12)
w = w2
setContent {
Log.d("TAG", "onCreate: composition 1")
Column {
Log.d("TAG", "onCreate: composition 2")
ShowWeather(weather = w)
// 重组作用域
var name by remember {
mutableStateOf("Alex")
}
Text(text = name, Modifier.clickable {
name = "Alex ++"
w = w3
})
}
}
w3.city = "上海"
1.3 @Stable注解使用
那我们会想,只要有被var修饰的成员,就认为这个类不可靠,在重组的时候Compose就会强制侵入本不需要刷新的Composable代码中,未免有些太暴力,那么有什么手段能够阻止吗?
kotlin
@Stable
data class Weather(
var city:String,
var temperature:Int
)
Compose中将不可靠的对象,转换为可靠的对象,就是使用@Stable
注解,它会告诉Compose编译器,Weather这个类从始至终都不会发生变化,当然这个保证需要我们自己做控制,保证不会在任何地方对其做修改。那么Compose就会在重组的时候,跳过执行。
但是作为程序员的我们,保证前后两个对象永远不发生变化,一直相等,其实还是很难的 ,而且在多方合作中也很容易打破这个规则,所以我们的前提是先保证安全性,然后再考虑性能,因此不再重写equals方法,只要两个对象不相同就刷新,我们只保证同一个对象就跳过刷新即可。
kotlin
@Stable
class Weather(
var city:String,
var temperature:Int
)
因此不再使用data class(默认重写了equals),而是使用普通的class类即可。
kotlin
//@Stable
class Weather(
city: String,
temperature: Int
) {
// 通过mutableStateOf修饰
var city by mutableStateOf(city)
var temperature by mutableStateOf(temperature)
}
如果我们不想使用@Stable
注解,那么对于类中的public成员变量使用mutableStateOf
存储,那么Compose也会认为这个类是可靠的,在重组的时候,会跳过执行。
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
Column {
Log.d("TAG", "onCreate: composition 2")
ShowWeather(weather = w)
// 重组作用域
var name by remember {
mutableStateOf("Alex")
}
Text(text = name, Modifier.clickable {
name = "Alex ++"
w.city = "上海"
})
}
}
而且,在类中的成员变量发生变化的时候,也会触发重组进行刷新,所以使用这种写法的好处就是:
- 在成员变量不变的情况下是稳定的,recomposition的过程中可以跳过;
- 在成员变量变了的情况下,会触发重组,进行页面的刷新;
所以@Stable
不建议使用得原因:因为我们自己无法保证做到完全的不可变。
2 derivedStateOf和remember的比较
在之前的文章中,我介绍了remember的使用场景,主要用于修饰mutableStateOf
,防止被多次初始化影响界面刷新,这一小节中将会介绍derivedStateOf
的用法,以及和remember
的区别。
2.1 derivedStateOf
derived
词义为衍生的,派生的。derivedStateOf从官方意思来看:state就是由一个或者多个state对象 派生或者衍生出来的,当任意state对象发生变化时,衍生state对象都会重新计算,并拿到一个最新状态的值。
kotlin
fun <T> derivedStateOf(
calculation: () -> T,
): State<T> = DerivedSnapshotState(calculation, null)
从源码看,当state对象发生变化时,会重新执行calculation
中的代码。
kotlin
@Composable
fun ShowCity(data: MutableList<String> = mutableListOf("北京", "上海", "杭州")) {
// 被移除的城市
val removeCity = remember { mutableStateListOf<String>() }
val showCity = remember {
derivedStateOf {
// 如果removeCity发生改变,这里会重新计算
for (city in removeCity) {
if (data.contains(city)) {
data.remove(city)
}
}
//返回筛选后的data
data
}
}
LazyColumn {
// 注意 LazyColumn不是内联函数,因此重组只会走这块区域
items(showCity.value) {
Text(text = it, Modifier.clickable {
removeCity.add(it)
})
}
}
}
所以根据derivedStateOf
的特性,需要一个状态依赖另一个状态,我这里写了一个列表用于展示城市信息,当点击任意一个城市的时候,会将城市信息添加到removeCity
中,从而触发了重组。
当重组的时候,执行到derivedStateOf
,因为showCity
的状态依赖于removeCity
,而且在触发重组的时候,removeCity
发生了变化,因此会执行derivedStateOf
代码块中的代码,并将删除的城市信息从data
中移除,此时showCity
中的数据被删除了一条,再刷新展示的时候这一条数据将不再显示在屏幕上。
那么问题来了,为什么要使用derivedStateOf
,好像使用mutableStateListOf
也能实现这个功能,那么到底什么时候才会使用derivedStateOf
。
2.2 derivedStateOf的使用场景
首先我们先看一个简单的例子:
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
val data = remember {
mutableStateListOf("北京", "上海", "杭州")
}
val showData = remember(data) {
Log.d("TAG", "onCreate: add val")
data.map {
it.plus(" ++")
}
}
Column {
showData.forEach {
Text(text = it,Modifier.clickable {
data.add("苏州")
})
}
}
}
关键看下showData
这个变量,与以往不同的是,remember
采用了下面的方法进行调用,将data
属性作为了key。
kotlin
/**
* Remember the value returned by [calculation] if [key1] is equal to the previous composition,
* otherwise produce and remember a new value by calling [calculation].
*/
@Composable
inline fun <T> remember(
key1: Any?,
crossinline calculation: @DisallowComposableCalls () -> T
): T {
return currentComposer.cache(currentComposer.changed(key1), calculation)
}
官方的解释:
如果key值与组建的时候一样,没有发生变化,那么就返回缓存中的值,不会执行calculation中的代码;否则将会执行calculation中的代码,重新生成一个新的值。
看官方的解释,哎,好像跟derivedStateOf
有点相似,derivedStateOf
也是当内部的state发生变化的时候,执行calculation
中的代码,那么我直接使用remember
这种方式就好了,但是是这样的吗?
回到本小节开头的例子,当点击Column
中的item时,会将data
中添加一个元素,而showData
则是将数据进行了一次转化,添加了++,当运行时我们发现并没有新增一条item。
data
数据发生了变化,但是showData
没有生成新的数据。那么这就是remember
对于key是否相等的判断出现了问题,一般对象变化主要分为两种:
- 直接赋值,例如给String赋值新值;
- 对象内部变化,例如List增加或者删除一个元素。
就这个例子来看,data
内部增加一个元素,但是data
得引用还是没变,还是认为是一个对象,那么自然是无法生成一个新值,因此需要通过derivedStateOf
装饰一下,用来处理因为内部数据发生变化时,remember认为没有发生变化,例如List的添加和删除操作。
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
val data = remember {
mutableStateListOf("北京", "上海", "杭州")
}
val showData = remember(data) {
Log.d("TAG", "onCreate: add val")
derivedStateOf {
data.map {
it.plus(" ++")
}
}
}
Column {
showData.value.forEach {
Text(text = it, Modifier.clickable {
data.add("苏州")
})
}
}
}
2.3 derivedStateOf注意事项
先看看起来,derivedStateOf
好像比remember(key)
更加灵活,但是实际的场景中,我们能无脑使用derivedStateOf
吗,看下面的例子:
kotlin
@Composable
fun ShowUp() {
val name = remember {
mutableStateListOf("Alex", "Tom")
}
val showName = remember {
derivedStateOf {
name.map {
it.uppercase()
}
}
}
Column {
showName.value.forEach {
Text(text = it, Modifier.clickable {
name.add("Jerry")
})
}
}
}
showName
用于将英文变为大写,然后点击列表中的Item新增一个元素Jerry,此时name
发生变化,Compose监听到从而导致showName
重新计算,发生重组后新增了一条元素。
此时ShowUp
是有状态的,那么我们将状态提升,将ShowUp
变成无状态。
kotlin
@Composable
fun ShowUp(name: String, onClick: (() -> Unit)? = null) {
Log.d("TAG", "ShowUp: name-- $name")
val showName by remember {
// 第一次初始化会进来
derivedStateOf { name.uppercase() }
}
Text(text = showName, Modifier.clickable {
onClick?.invoke()
})
}
首先我们先看一个最简单的例子,当点击某个Text之后,将Text的文案替换并且目的是将文案转成大写。
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
var name by remember {
mutableStateOf("Alex")
}
ShowUp(name) {
name = "Tom"
}
}
我们在使用的时候如上所示,当点击Text时,我们将name
赋值为Tom,此时会触发重组,重组作用域是调用name
的父作用域,也就是setContent
,此时ShowUp
会再次执行,我们发现文案并没有像我们预想的那样变化。
为什么没有变化呢?其实当重组时执行ShowUp
,此时作为参数传入的name只是一个代理值,就是一个String类型的value,那么在ShowUp中,derivedStateOf就无法对其进行监听,从而导致数据一直没有刷新。
解决这个问题的方案有两种:
- 将
ShowUp
参数改为State对象,从而穿透Composable函数,在内部derivedStateOf就可以对其完成订阅,但不建议这么做,因为传值只能传递State对象,不具备通用性; - 采用
remember(key)
的方式订阅。
kotlin
@Composable
fun ShowUp(name: String, onClick: (() -> Unit)? = null) {
Log.d("TAG", "ShowUp: name-- $name")
val showName = remember(name) {
name.uppercase()
}
Text(text = showName, Modifier.clickable {
onClick?.invoke()
})
}
采用remember(key)
的方式,当重组时进入ShowUp
,remember会检查前后两次name是否发生变化,显然是变化了,因此页面完成了刷新。
回到本小节开始的例子:
kotlin
@Composable
fun ShowUp(name: MutableList<String>, onClick: (() -> Unit)? = null) {
val showName = remember(name) {
name.map {
it.uppercase()
}
}
Column {
showName.forEach {
Text(text = it, Modifier.clickable {
onClick?.invoke()
})
}
}
}
当完成状态提升之后,在Composable函数内部还是采用remember(key)
这种形式,很显然按照我们之前的结论,因为List修改属于内部改变,通过remember(key)
校验前后两个key是一致的,从而不会刷新新值,需要使用derivedStateOf
。
kotlin
// 注意这里不再是代理,而是=,会穿透Composable函数,derivedStateOf能够订阅这个变量
val name = remember {
mutableStateListOf("Alex", "Tom")
}
ShowUp(name) {
name.add("Jerry")
}
那么有些伙伴可能会有疑问,ShowUp
函数参数为List类型,能够被derivedStateOf
订阅?其实我们需要看传参的地方,不再是通过by拿到代理的value,而是货真价实的State对象,mutableStateListOf
具备订阅的能力,而且继承自MutableList
。
kotlin
@Composable
fun ShowUp(name: MutableList<String>, onClick: (() -> Unit)? = null) {
val showName by remember {
derivedStateOf {
name.map {
it.uppercase()
}
}
}
Column {
showName.forEach {
Text(text = it, Modifier.clickable {
onClick?.invoke()
})
}
}
}
那么这样真能做到万无一失吗👆🏻?
kotlin
setContent {
Log.d("TAG", "onCreate: composition 1")
var count by remember {
mutableStateOf(0)
}
val name = remember(count) {
if (count > 5){
mutableStateListOf("Alex", "Tom")
}else{
mutableStateListOf("北京","上海","广州")
}
}
ShowUp(name) {
count++
}
}
假设对于ShowUp
函数的入参有变化,那么derivedStateOf
只会对第一个name订阅,后续name发生了变化都不会刷新,只会刷新第一个列表数据,因此官方的案例中建议我们这么写:
kotlin
@Composable
fun ShowUp(name: MutableList<String>, onClick: (() -> Unit)? = null) {
val showName by remember(name) {
derivedStateOf {
name.map {
it.uppercase()
}
}
}
Column {
showName.forEach {
Text(text = it, Modifier.clickable {
onClick?.invoke()
})
}
}
}
derivedStateOf
和remember(key)
一起使用,能够保证在name发生变化时,重新计算帮助derivedStateOf
重新订阅,从而保证数据的准确刷新:不管是换了新对象,还是内部的状态发生了变化,Compose都可以监听到。
3 CompositionLocal
这又是一个新的概念,从字面意思上来看,就是Composition的局部变量。
kotlin
setContent {
val name = "Alex"
ShowText(name = name)
}
kotlin
@Composable
fun ShowText(name: String) {
Text(text = name)
}
在setContent
重组作用域内,name属于这个作用域内的局部变量,在这个作用域外的成员无法直接调用,包括ShowText
可组合函数。
那么假设,我想把ShowText
中的参数去掉,并且可以在ShowText
中使用setContent
重组作用域中的局部变量,看似天方夜谭,实则Compose已经帮我们做好了。
3.1 CompositionLocal的使用
CompositionLocalProvider
可以看做是Composition局部变量的提供者,在其提供的content
作用域内执行的Composable函数,局部变量具有穿透性,即在Composable函数中可以直接使用局部变量。
kotlin
@Composable
@OptIn(InternalComposeApi::class)
fun CompositionLocalProvider(vararg values: ProvidedValue<*>, content: @Composable () -> Unit) {
currentComposer.startProviders(values)
content()
currentComposer.endProviders()
}
- 首先需要定义一个Composition局部变量,可以通过
compositionLocalOf
来创建。
kotlin
val LocalName = compositionLocalOf<String> { error("LocalName is not init") }
- 通过
CompositionLocalProvider
来给局部变量LocalName
赋值,可以使用provides
函数来为其赋值。这里需要注意,如果想要使用LocalName
,那么就需要将@Composable函数放在CompositionLocalProvider作用域内。
kotlin
CompositionLocalProvider(LocalName provides "Alex") {
ShowText()
}
- 获取值,可以通过CompositionLocal的
current
成员对象来获取。
kotlin
@Composable
fun ShowText() {
Text(text = LocalName.current)
}
以上就是CompositionLocal的简单使用,通过这种方式可以移除@Composable函数中的参数,直接调用局部变量的成员对象。
3.2 CompositionLocal有什么用?
从CompositionLocal
的使用中我们大概能够知道,CompositionLocal
是一个具有穿透功能的局部变量,从而取代@Composable函数中的参数,在日常的开发中,我们已经熟悉了通过传值的方式进行透传,而如果使用CompositionLocal
取代全部的入参,那么可能会影响到更大的范围,因为我们如果采用传参的方式,那么只会影响一个组合函数的内部执行,而CompositionLocal属于作用域内部的全局变量,在任意组合中改动,都可能会影响到其他的组合。
因此使用CompositionLocal
一般是用来表示上下文、环境、主题等可能会在组合函数中使用到,也有可能使用不到,而一定会在组合函数中使用到的参数,请在组合函数中显示地声明。
kotlin
val LocalActivity = compositionLocalOf<Activity> { error("LocalActivity error") }
CompositionLocalProvider(LocalActivity provides this) {
ShowText2()
}
像如果使用LocalActivity
来表示全局的上下文,那么在CompositionLocalProvider
作用域内,所有的组合函数都可以拿到上下文使用,这也避免了我们在ShowText2
中传入context
参数,其实我们在开发中也是尽可能地减少这种参数传递,经常会在一个静态类中注入Context,然后直接调用,不知道是否有这么干的伙伴。
除了上下文,我们还可以定义主题,例如:
kotlin
// 当前主题的局部变量
val LocalBackground = compositionLocalOf<Color> { error("LocalBackground error") }
kotlin
CompositionLocalProvider(LocalBackground provides Color.Blue) {
ShowBackground()
}
@Composable
fun ShowBackground() {
Text(text = "检测文案展示", modifier = Modifier.background(LocalBackground.current))
}
假设这个页面的主题颜色为蓝色,那么就可以使用LocalBackground
定义主题颜色,并且在ShowBackground
中可以直接使用这个局部变量,好处是:当UI发生变更,主题色变为红色,那么只需要修改LocalBackground
即可。
假设我们定义一个App的主题:
kotlin
//定义局部变量
val LocalBackground = compositionLocalOf<Color> { error("") }
val LocalTextSize = compositionLocalOf<TextUnit> { error("") }
val LocalTextColor = compositionLocalOf<Color> { error("") }
@Composable
fun MyAppTheme(
content: @Composable () -> Unit
) {
CompositionLocalProvider(
LocalBackground provides Color.Blue,
LocalTextSize provides 20.sp,
LocalTextColor provides Color.Black,
) {
content.invoke()
}
}
那么我们在使用的时候,就可以将其放在setContent
之下,那么所有的组件都可以使用这些主题资源信息。
kotlin
setContent {
MyAppTheme {
Column(Modifier.background(LocalBackground.current)) {
Text(
text = "测试主题1",
color = LocalTextColor.current,
fontSize = LocalTextSize.current
)
Text(
text = "测试主题2",
color = LocalTextColor.current,
fontSize = LocalTextSize.current
)
}
}
}