Jetpack Compose(十一)-重组与自动刷新

一、智能的重组

传统视图中通过修改View的私有属性来改变UI, Compose则通过重组刷新UICompose的重组非常"智能",当重组发生时,只有状态发生更新的Composable才会参与重组,没有变化的Composable会跳过本次重组。

二、避免重组的"陷阱"

由于Composable在编译期代码会发生变化,代码的实际运行情况可能并不如你预期的那样。所以需要了解Composable在重组执行时的一些特性,避免落入重组的"陷阱"。

1、Composable会以任意顺序执行

首先需要注意的是当代码中出现多个Composable时,它们并不一定按照代码中出现的顺序执行。比如,在一个Navigation中处于Stack最上方的UI会优先被绘制,在一个Box布局中处于前景的UI具有较高的优先级,因此Composable会根据优先级来执行,这与代码中出现的位置可能并不一致。

Composable都应该"自给自足",不要试图通过外部变量与其他Composable产生关联。在Composable中改变外部环境变量属于一种"副作用 "行为,Composable应该尽量避免副作用

2、Composable会并发执行

重组中的Composable并不一定执行在UI线程,它们可能在后台线程池中并行执行,这有利于发挥多核处理器的性能优势。但是由于多个Composable在同一时间可能执行在不同线程,此时必须考虑线程安全问题。看看下面EventsFeed的例子:

kotlin 复制代码
@Composable
fun EventsFeed(localEvents: List<Event>, nationalEvents: List<Event>) {
    var totalEvents = 0
    Row {
        Column {   //column-content-1 
            localEvents.forEach { event ->
                Text("Item: $ {event.name}")
                totalEvents++
            }
        }

        Spacer(Modifier.height(10.dp))

        Column {   //column-content-2
            nationalEvents.forEach { event ->
                Text("Item: $ {event.name}")
                totalEvents++
            }
        }

        Text(
            if (totalEvents == 0) "No events." else "Total events StotalEvents"
        )
    }
}

本例想使用totalEvents记录events的合计数量并在Text显示,column-content-1column-content-2有可能在不同线程并行执行,所以totalEvents的累加是非线程安全的,结果可能不准确。即使totalEvents的结果准确,由于Text可能运行在单独线程,所以也不一定能正确显示结果,这同样还是Composable副作用带来的问题,大家需要极力避免。

3、Composable会反复执行

除了重组会造成Composable的再次执行外,在动画等场景中每一帧的变化都可能引起Composable的执行,因此Composable有可能会短时间内反复执行,我们无法准确判断它的执行次数。大家在写代码时必须考虑到这一点:即使多次执行也不应该出现性能问题,更不应该对外部产生额外影响。来看下面的例子:

kotlin 复制代码
@Composable
fun EventsFeed(networkService: EventsNetworkService) {
    //异步请求数据
    val events = networkService.loadAllEvents()
    LazyColumn {
        items(events) { event ->
            Text(text = event.name)
        }
    }
}

EventsFeed中,loadAllEvents是一个IO操作,执行成本高,如果在Composable中同步调用,会在重组时造成卡顿。也许有人会提出将数据请求逻辑放到异步线程执行,以提高性能。这里尝试将数据请求的逻辑移动到ViewModel中异步执行,避免阻塞中线程:

kotlin 复制代码
@Composable
fun EventsFeed(viewModel: EventsViewModel) {
    //异步执行结果回调到主线程并将Flow转换为State
    val events = viewModel.loadAllEvents().collectAsState(emptyList())
    LazyColumn {
        items(events) { event ->
            Text(text = event.name)
        }
    }
}

虽然没有了同步IO的烦恼,但是events的更新会触发EventsFeed重组,从而造成loadAllEvents的再次执行。loadAllEvents作为一个副作用 不应该跟随重组反复调用,Compose中提供了专门处理副作用的方法,这个会在后面介绍。

4、Composable的执行是"乐观"的

所谓"乐观"是指Composable最终总会依据最新的状态正确地完成重组。在某些场景下,状态可能会连续变化,这可能会导致中间态的重组在执行中被打断,新的重组会插入进来。对于被打断的重组,Compose不会将执行一半的重组结果反应到视图树上,因为它知道最后一次状态总归是正确的,因此中间状态会被丢弃。

kotlin 复制代码
@Composable
fun MyList{
    val listState = rememberLazyListState()

    LazyColumn(state = listState) {
        // 展示列表项 ...
    }

    //上报列表展现元素用作数据分析
    MyAnalyticsService.sendVisibleItem(listState.layoutInfo.visibleItemsInfo)
}

在上面的代码中,MyList用来显示一个列表数据。这里试图在重组过程中将列表中展示的项目信息上报服务器,用作产品分析,但这样写是很危险的,因为任何时候大家都无法确定重组能够被正常执行而不被打断。如果此次重组被打断了,那么会出现数据上报内容与实际视图不一致的问题,影响产品分析数据。

像数据上报这类会对外界产生影响的逻辑称为副作用。Composable不应该直接出现任何对状态有依赖的副作用代码 ,当有类似需求时,应该使用Compose提供的专门处理副作用的API进行包裹,例如SideEffect{...}等,副作用可以在里面安全地执行并获取正确的状态。

针对本小节的介绍得出一个结论:Compose框架要求Composable作为一个无副作用的纯函数运行,只要在开发中遵循这一原则,上述这一系列特性就不会成为程序执行的"陷阱",反而有助于提高程序的执行性能。

三、如何确定重组范围

我们知道重组是智能的,会尽可能跳过不必要的重组,仅仅针对需要变化的UI进行重组。那么Compose如何认定UI需要变化呢?或者说Compose如何确定重组的最小范围呢?看下面的代码:

kotlin 复制代码
@Composable
fun Greeting() {
    Log.d(TAG, "Scope-1 run")      //日志一
    var counter by remember { mutableStateOf(0) }
    Column {  //Scope-2 
        Log.d(TAG, "Scope-2 run")       //日志二
        Button(
            onClick = run {
                Log.d(TAG, "Button-onClick")    //日志三
                return@run { counter++ }
            }
        ) {  //Scope-3
            Log.d(TAG, "Scope-3 run")     //日志四
            Text("+")
        }
        Text("$counter")
    }
}

运行后会得到下面的日志:

kotlin 复制代码
//运行后,点击按钮前
Scope-1 run
Scope-2 run
Button-onClick
Scope-3 run

//点击按钮后
Scope-1 run
Scope-2 run
Button-onClick
Scope-3 run

第一条日志居然不是Button-onClick,这是为什么呢?

经过Compose编译器处理后的Composable代码 在对State进行读取的同时能够自动建立关联,在运行过程中当State变化时,Compose会找到关联的代码块标记为Invalid。在下一渲染帧到来之前,Compose会触发重组并执行invalid代码块,Invalid代码块即下一次重组的范围。能够被标记为Invalid的代码必须是非inline且无返回值的Composable函数或lambda,如果是inline的,则在编译时会被展开,无法确定重组范围。

只有受到State变化影响的代码块,才会参与到重组,不依赖State的代码则不参与重组,这就是重组范围的最小化原则。

那么参与重组的代码块为什么必须是inline的无返回值函数呢?因为inline函数在编译期会在调用处展开,因此无法在下次重组时找到合适的调用入口,只能共享调用方的重组范围 。而对于有返回值的函数,由于返回值的变化会影响调用方,所以必须连同调用方一同参与重组,因此它不能单独作为Invalid代码块

分析一下上面的日志:

按照重组最小化原则,访问counter的最小范围应该是Scope2,为什么Scope1-run也输出了呢?还记得最小化范围的定义必须是非inlineComposable函数或lambda吗?Column实际上是个inline声明的高阶函数,内部content也会被展开在调用处,Scope-2Scope-1共享重组范围,Scope-1 run日志被输出 。看下Column源码:

kotlin 复制代码
@Composable
inline fun Column(...){...}       //inline

如果我们将Column修改为非inlineCard结果就会不一样,先看一下Card的源码:

kotlin 复制代码
@Composable
@NonRestartableComposable
fun Card(...){...}              //非inline

修改为Card后的代码和日志:

kotlin 复制代码
@Composable
fun Greeting() {
    Log.d(TAG, "Scope-1 run")
    var counter by remember { mutableStateOf(0) }
    Card {  //Scope-2        <---------将Column修改为非inline的Card
        Log.d(TAG, "Scope-2 run")
        Button(
            onClick = run {
                Log.d(TAG, "Button-onClick")
                return@run { counter++ }
            }
        ) {  //Scope-3
            Log.d(TAG, "Scope-3 run")
            Text("+")
        }
        Text("$counter")
    }
}
kotlin 复制代码
//运行后,点击按钮前
Scope-1 run
Scope-2 run
Button-onClick
Scope-3 run

//点击按钮后
Scope-2 run
Button-onClick
Scope-3 run

重组范围已经被限定在非inline且无返回值的函数Card内部,不再输出Scope-1 run

四、优化重组的性能

Composable经过执行之后会生成一颗视图树,每个Composable对应了树上的一个节点。因此Composable智能重组的本质其实是从树上寻找对应位置的节点并与之进行比较,如果节点未发生变化,则不用更新。

补充提示:

视图树构建的实际过程比较复杂,Composable执行过程中,先将生成的Composition状态存入SlotTable,而后框架基于SlotTable生成LayoutNode树,并完成最终界面渲染。谨慎来说,Composable的比较逻辑发生在SlotTable中,并非是Composable在执行中直接与视图树节点作比较。

1、Composable的位置索引

在重组过程中,Composition上的节点可以完成增、删、移动、更新等多种变化。Compose编译器会根据代码调用位置,为Composable生成索引key,并存入ComposiitoinCompoable在执行中通过与key的对比,可以知道当前应该执行何种操作。

(1)如何理解这个key

Compose 编译器会根据 Composable 函数在代码中被调用的位置来生成一个唯一的索引 key。如果同一个函数被调用二次,会生成二个不同的索引key

(2)索引key是如何生成的?

Compose 编译器会使用以下算法来生成索引 key

  1. 首先,Compose 编译器会为每个 Composable 函数生成一个唯一的 ID。这个 IDComposable 函数的名称和参数列表组成。
  2. 然后,Compose 编译器会将这个 ID 转换为一个十六进制字符串。
  3. 最后,Compose 编译器会将这个十六进制字符串加上一个随机数作为索引 key

(3)索引 key 有什么作用?

索引 key 用于在 Compose 的布局树中唯一标识一个 Composable 函数。Compose 会使用索引 key 来确定一个 Composable 函数是否已经被渲染过,以及如何更新已经渲染过的 Composable 函数。

(4)哪些情况下会添加索引key

Jetpack Compose 中,Composable 函数的渲染结果会被添加到一个布局树中。如果 Composable 函数的渲染结果是可变的,Compose 会使用索引 key 来识别该节点是否已经被渲染过,以及如何更新已经渲染过的节点。

例如在 Composable 函数中使用 if/else 等条件语句时,Compose 编译器会在条件语句前插入 startXXXGroup() 代码,并为每个条件分支添加一个唯一的索引 key。这样,Compose 就可以在布局树中识别条件语句导致的节点增减。示例代码:

kotlin 复制代码
@Composable
fun MyComposable() {
    val isEnabled = true

    if (isEnabled) {
        // 渲染内容 1
        // 添加索引 key 1
        startScope {
            // ...
        }
    } else {
        // 渲染内容 2
        // 添加索引 key 2
        startScope {
            // ...
        }
    }
}

除了函数节点,条件语句节点外,Compose 编译器还会在以下情况下添加索引 key

  • 使用 remember() 函数来缓存数据时。
  • 使用 mutableStateOf() 函数来创建可变状态时。
  • 使用 MutableList()MutableMap() 等可变集合或映射时。

总体来说,Compose 会在任何可能导致布局树中节点增减的地方添加索引 key

(5)手动添加索引key提高性能

先看下面的代码:

kotlin 复制代码
@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            //MovieOverview无法在编译期进行索引
            //只能根据运行时的index进行索引
            MovieOverview(movie)
        }
    }
}

在上面的代码中,基于Movie列表数据展示MovieOverview。此时无法基于代码中的位置进行索引,只能在运行时基于index进行索引 。这样的索引会根据item的数量变化而发生变化,无法准确匹配对象进行比较。如下图所示,当前MoviesScreen已经有两条数据,当在头部再插入一条数据时,之前的索引发生错误,无法在比较时起到锚定原对象的作用。

当重组发生时,新插入的数据会与以前的0号数据比较,以前的0号数据会与以前的1号数据比较,以前的1号数据作为新数据插入,结果所有item都会发生重组,但我们期望的行为是,仅新插入的数据需要组合,其他数据因为没有变化不应该发生重组。------图中灰色的部分表示参与重组的item

此时可以使用key方法为Composable在运行时手动建立唯一索引,代码如下:

kotlin 复制代码
@Composable
fun MoviesScreen(movies: List<Movie>) {
    Column {
        for (movie in movies) {
            key(movie.id){
                //使用Movie的唯一id作为Composable的索引
                MovieOverview(movie)
            }
        }
    }
}

使用Movie.id传入Composable作为唯一索引,当插入新数据后,之前对象的索引没有被打乱,仍然可以发挥比较时的锚定作用,如下图所示,之前的数据没有发生变化,对应的Item无须参与重组。

二、活用@Stable或@Immutable

Composable基于参数的比较结果来决定是否重组。更准确地说,只有当参与比较的参数对象是稳定的且equals返回true,才认为是相等的。

那么什么样的类型是稳定的呢?比如Kotlin中常见的基本类型(BooleanIntLongFloatChar)、String类型,以及函数类型(Lambda)都可以称得上是稳定的,因为它们都是不可变类型,它们参与比较的结果是永远可信的。反之,如果参数是可变类型 ,它们的equlas结果将不再可信。

下面的例子清晰地展示了这一点:

kotlin 复制代码
//MutableData类
class MutableData(var data: String)

//MutableDemo函数
@Composable
fun MutableDemo() {
    val mutable = remember { MutableData("Hello") }
    var state by remember { mutableStateOf(false) }
    if (state) {
        mutable.data = "World"
    }

    //WrapperText显示会随着state的变化而变化
    Button(onClick = { state = !state }) {
        WrapperText(mutable)
    }
}

//WrapperText函数
@Composable
fun WrapperText(mutableData: MutableData) {
    Text(text = mutableData.data)
}

上述代码中,MutableData是一个"不稳定"的对象,因为它有一个var类型的成员data,当单击Button改变状态时,mutable修改了data。对于WrapperText来说,参数mutable在状态改变前后都指向同一个对象,因此仅仅靠equals判断会认为是参数没有变化。但实际测试后会发现WrapperText的重组仍然发生了,因为对于Compiler来说,MutableData参数类型是不稳定的,equals结果并不可信。

对于一个非基本类型T,无论它是数据类还是普通类,若它的所有public属性都是final的不可变类型,则T也会被Compiler识别为稳定类型。此外,像MutableState这样的可变类型也被视为稳定类型,因为它的value的变化可以被追踪并触发重组,相当于在新的重组发生之前保持不变。

对于一些默认不被认为是稳定的类型,比如interface或者List等集合类,如果能够确保其在运行时的稳定,可以为其添加@Stable注解,编译器会将这些类型视为稳定类型,从而发挥智能重组的作用,提升重组性能。需要注意的是,被添加@Stable的普通父类、密封类、接口等,其派生子类也会被视为是稳定的。

如下面的代码所示,当使用interface定义UiState时,可以为其添加@Stable,当在Composable中传入UiState时,Composable的重组会更加智能。

kotlin 复制代码
//添加注解,告诉编译器其类型是稳定的,可以跳过不必要的重组
@Stable
interface UiState<T> {
    val value: T?
    val exception: Throwable?
    val hasError: Boolean
        get() = exception != null
}

除了@Stable外,Compose还提供了另一个类似的注解@Immutable。两者都继承自@StableMarker,在功能上类似,都是用来告诉编译器所注解的类型可以跳过不必要的重组。 不同点在于,@Immutable修饰的类型应该是完全的不可变类型,@Stable修饰的类型中可以存在可变类型的属性,但只要属性的变化是可以观察的(能够触发重组,例如MutableStable<T>等),仍然被视作稳定的。另外在使用注解范围上,@Stable可以用在函数、属性等更多场景,但是总体上@Stable的能力完全覆盖了@Immutable。由于功能的重叠,未来@Immutable有可能会被移除,建议大家优先选择使用@Stable

参考了以下内容:

本文大部分内容参考了实体书 Jetpack Compose从入门到实战

其他参考内容:

Jetpack Compose docs

官网Sate

初学者如有错误欢迎批评指正!

相关推荐
深海呐3 小时前
Android 最新的AndroidStudio引入依赖失败如何解决?如:Failed to resolve:xxxx
android·failed to res·failed to·failed to resol·failed to reso
解压专家6663 小时前
安卓解压软件推荐:高效处理压缩文件的实用工具
android·智能手机·winrar·7-zip
Rverdoser3 小时前
Android 老项目适配 Compose 混合开发
android
️ 邪神6 小时前
【Android、IOS、Flutter、鸿蒙、ReactNative 】标题栏
android·flutter·ios·鸿蒙·reatnative
努力遇见美好的生活6 小时前
Mysql每日一题(行程与用户,困难※)
android·数据库·mysql
图王大胜8 小时前
Android Framework AMS(17)APP 异常Crash处理流程解读
android·app·异常处理·ams·crash·binderdied·讣告
ch_s_t8 小时前
电子商务网站之首页设计
android
豆 腐11 小时前
MySQL【四】
android·数据库·笔记·mysql
想取一个与众不同的名字好难13 小时前
android studio导入OpenCv并改造成.kts版本
android·ide·android studio
Jewel10513 小时前
Flutter代码混淆
android·flutter·ios