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

相关推荐
风槐啊29 分钟前
六、Java 基础语法(下)
android·java·开发语言
皮皮虾在睡觉1 小时前
数据库基础04
android·数据库
彭于晏6894 小时前
Android高级控件
android·java·android-studio
666xiaoniuzi9 小时前
深入理解 C 语言中的内存操作函数:memcpy、memmove、memset 和 memcmp
android·c语言·数据库
沐言人生13 小时前
Android10 Framework—Init进程-8.服务端属性文件创建和mmap映射
android
沐言人生13 小时前
Android10 Framework—Init进程-9.服务端属性值初始化
android·android studio·android jetpack
沐言人生14 小时前
Android10 Framework—Init进程-7.服务端属性安全上下文序列化
android·android studio·android jetpack
追光天使14 小时前
【Mac】和【安卓手机】 通过有线方式实现投屏
android·macos·智能手机·投屏·有线
小雨cc5566ru14 小时前
uniapp+Android智慧居家养老服务平台 0fjae微信小程序
android·微信小程序·uni-app