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

一、智能的重组

传统视图中通过修改View的私有属性来改变UI, Compose则通过重组刷新UI。Compose的重组非常"智能",当重组发生时,只有状态发生更新的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-1和column-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也输出了呢?还记得最小化范围的定义必须是非inline的Composable函数或lambda吗?Column实际上是个inline声明的高阶函数,内部content也会被展开在调用处,Scope-2与Scope-1共享重组范围,Scope-1 run日志被输出 。看下Column源码:

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

如果我们将Column修改为非inline的Card结果就会不一样,先看一下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,并存入Composiitoin。Compoable在执行中通过与key的对比,可以知道当前应该执行何种操作。

(1)如何理解这个key?

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

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

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

  1. 首先,Compose 编译器会为每个 Composable 函数生成一个唯一的 ID。这个 ID 由 Composable 函数的名称和参数列表组成。
  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中常见的基本类型(Boolean、Int、Long、Float、Char)、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

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

相关推荐
小程故事多_807 小时前
从 Tokenizer 到 Next Token,穿透数据流,完整理解 Transformer 底层逻辑
android·深度学习·transformer
三少爷的鞋10 小时前
Android 协程实践:为什么不应将 `supervisorScope` 作为常规方案
android
承渊政道11 小时前
【从零开始大模型开发与微调:基于PyTorch与ChatGLM】(从ChatGLM本地化到LoRA、PEFT的高级微调)
android·人工智能·pytorch·chatglm·本地化部署·大模型开发与微调
2601_9628857213 小时前
如何用 Python 判断一只股票“追涨”还是“抄底”更有效?
android·开发语言·python
素师良码13 小时前
第10篇:屏ESD Check内核逻辑驱动代码完整拆解
android
高晶13 小时前
Android 短信收发模块从零到实战:权限配置、界面设计、发送接收与兼容性适配全攻略
android·程序员
随遇丿而安13 小时前
第18周:ViewModel 基础功能 + 架构优化
android
问心无愧051316 小时前
[GXYCTF2019]Ping Ping Ping
android·笔记
事圆则缓16 小时前
Kotlin 异步与并发:从一次搜索请求走到状态流
android·开发语言·kotlin
帅得不敢出门17 小时前
Android Framework定制卸载确认窗口
android