Jetpack Compose State 你用对了吗?

325 阅读6分钟

前言

在 Jetpack Compose 中,如果要实现页面的刷新,或者说重组。需要借助 State, 通过 State 包装的对象记录当前页面的状态,当 State 记录的内容发生变化时,Compose 会进行重组,实现 UI 刷新。从而实现可交互、基于数据变化的 UI。而在传统的基于 View 体系的 UI 组件中,我们会借助 ViewModel + LiveData/Flow + ViewBingding/DataBinding 的组合实现页面刷新,都是数据驱动 UI 发生变化的实现。

但是这两者还是有差异的,在 View 体系中,触发 UI 刷新的时机是开发者可控的,UI 内容最终发生变化还是由于在代码里主动调用了 View 的 setXXX 方法改变对象属性的值实现内容变化。而在 Compose 中,开发者只负责 State 状态的更新,Compose 框架的底层会基于变化的部分用最合理的方式进行 UI 的刷新,毕竟是声明式 UI,开发者用代码写的布局文件更像是一个配置文件,并不能真正掌控 UI 的刷新。 正常情况下,State 包装的内容变化会驱动 UI 重组,但是如果 State 变化了重组却没有发生,我们又该如何排查呢?可能是什么原因导致的呢?下面我们来总结一下。

State 和重组

关于 State 用法最经典的例子就是计数器,这里我们也以计数器为例。

@Composable
fun DemoCard() {
    var count by remember { mutableIntStateOf(0) }
    val list by remember { mutableStateOf(ArrayList<String>()) }


    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
    ) {
        Text(modifier = Modifier.padding(start = 10.dp), color = Color.Red, text = "$list")
        Text(modifier = Modifier.padding(start = 10.dp), color = Color.Blue, text = "$count")
        ContentA(list)
        ContentB(count)

        val context = LocalContext.current
        Button(modifier = Modifier.padding(5.dp), onClick = {
            Toast.makeText(context, "you clicked me", Toast.LENGTH_SHORT).show()
            count++
            list.add(count.toString())
        }) {
            Text(text = "click me")
        }
    }
}

这里我们声明了两个 State ,count 和 list。 Button 点击的时候,会同时更新 这两个变量的值。用两个 Text 组件展示两个变化的值,同时依次将两个 Text 组件原封不动抽取成了单独的组件 ContentA 和 ContentB

@Composable
fun ContentA(list: List<String>) {
    Log.d(TAG, "ContentA() called with: list = $list")
    Text(modifier = Modifier.padding(start = 10.dp), color = Color.Red, text = "$list")
}

@Composable
fun ContentB(num: Int) {
    Log.d(TAG, "ContentB() called with: num = $num")
    Text(modifier = Modifier.padding(start = 10.dp), color = Color.Blue, text = num.toString())
}

代码运行后,UI 展示如下。

state1.png

日志输出如下:

10:50:58.336 27008-27008 ComposeView              D  ContentA() called with: list = []
10:50:58.337 27008-27008 ComposeView              D  ContentB() called with: num = 0

然后我们开始点击按钮,按照我们熟悉的思路,list 和 count 会同步触发更新,但是实际情况却发生了变化。

state2.png

list 的值发生了变化,但是 ComposeA 这个组件没有对应的 UI 没有重新渲染。

state2_log.png

从日志也可以看出来,只有 ComposeB 这个函数发生了重组,这是为什么?list 变量变成方法的参数之后,怎么就不灵了,但是相同的 count 变量却可以 ?

到了这里,我们再添加一个组件

@Composable
fun ContentC(list: ArrayList<String>, num: Int) {
    Log.d(TAG, "ContentC() called with: list = $list, num = $num")
    Column {
        Text(modifier = Modifier.padding(start = 10.dp), color = Color.Red, text = "$list")
        Text(modifier = Modifier.padding(start = 10.dp), color = Color.Blue, text = num.toString())
    }
}

再次运行代码之后,通过点击按钮更新两个 state 变量的值。

state3.png

state3_log.png

可以看到 ComposeC 这个函数发生了重组,list 和 count 两个变量的值都触发了 UI 的重绘。但是我们 ComposeA 函数依然没有重组。到这里感觉像是 ComposeC 函数能够重组是蹭了 count 变量的好处,list 这个 state 变量似乎完全没有能力触发重组。事实证明,的确如此,当我们在 DemoCard 的这个函数中只保留由 list 变量控制的组件时,是否再次进行封装他都不会再变了,和是不是作为方法参数并没有关系。

可变的 State

当然,要解决上面的这个问题,我们有很多种方法

@Composable
fun DemoCard() {
    var count by remember { mutableIntStateOf(0) }
    var list by remember { mutableStateOf(ArrayList<String>()) }
    val list2 = remember { mutableStateListOf<String>() }
    val list3 = remember { mutableStateListOf("") }
    var list4 = remember { mutableStateOf(ArrayList<String>())}


    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
    ) {
        ContentA(list)
        ContentA(list2.toList())
        ContentA(list3.toList())
        ContentA(list4.value)


        val context = LocalContext.current
        Button(modifier = Modifier.padding(5.dp), onClick = {
            Toast.makeText(context, "you clicked me", Toast.LENGTH_SHORT).show()
            count++
            list.add(count.toString())
            list2.add(count.toString())
            list3.add(count.toString())
            list4.value = ArrayList(list)
        }) {
            Text(text = "click me")
        }
    }
}
  • 按照 IDE 的提示,将 mutableStateOf 修改为 mutableStateListOf 类型
  • 手动创建 mutableStateOf 类型的变量,不依赖 by .这里可以关注一下 listlist4 这两个变量声明时的差异,虽然看起来非常相似,变量类型却差了十万八千里,用法也就相应的发生了变化。

再次点击按钮,更新各个 State 变量的值

stete4.png

可以看到除了 list 之外,其他变量都触发了 Compose 的重组。

可变与不可变

那么个问题的原因是什么呢?其实我们打印一下这几个变量的类型就知道答案了。

12:39:31.356  4522-4522  ComposeView              I  count -> class kotlin.Int
12:39:31.366  4522-4522  ComposeView              I  list  -> class java.util.ArrayList
12:39:31.366  4522-4522  ComposeView              I  list2 -> class androidx.compose.runtime.snapshots.SnapshotStateList
12:39:31.367  4522-4522  ComposeView              I  list3 -> class androidx.compose.runtime.snapshots.SnapshotStateList
12:39:31.367  4522-4522  ComposeView              I  list4 -> class androidx.compose.runtime.ParcelableSnapshotMutableState

可以看到 list 是普通的 ArrayList 。而 list2/list3/list4 都带有 State 这个字眼,很明显是和 State 有关联的,是对普通的 ArrayList 进行了包装,因此在 Compose 触发重组检测的时候,才能根据这些包装类型变量的值是否发生变化而决定是否要进行函数的重组。

具体来说,SnapshotStateList 实现了 StateObject 这个接口,使得其包含的数据状态的变化可以被感知到,类似 Lifecycle 的效果。

而 count 和 list 这两个变量,虽然都是普通的类型,但是自身又是有差异的。我们通过 Button 的 onClick 方法更新他们时,count 由于是 Int 类型,因此每次都是一个变量,而 list 是 ArrayList ,我们通过通过调用其 add 方法时并不会改变他自身。


internal actual fun <T> createSnapshotMutableState(
    value: T,
    policy: SnapshotMutationPolicy<T>
): SnapshotMutableState<T> = ParcelableSnapshotMutableState(value, policy)

internal actual fun createSnapshotMutableIntState(
    value: Int
): MutableIntState = ParcelableSnapshotMutableIntState(value)

这里有一个 SnapshotMutationPolicy 的接口,其中定义了判定两个对象是否相等的方法。

/**
 * A policy to treat values of a [MutableState] as equivalent if they are structurally (==) equal.
 *
 * Setting [MutableState.value] to its current structurally (==) equal value is not considered
 * a change. When applying a [MutableSnapshot], if the snapshot changes the value to the
 * equivalent value the parent snapshot has is not considered a conflict.
 */
@Suppress("UNCHECKED_CAST")
fun <T> structuralEqualityPolicy(): SnapshotMutationPolicy<T> =
    StructuralEqualityPolicy as SnapshotMutationPolicy<T>

private object StructuralEqualityPolicy : SnapshotMutationPolicy<Any?> {
    override fun equivalent(a: Any?, b: Any?) = a == b

    override fun toString() = "StructuralEqualityPolicy"
}

可以看到默认情况是直接比较两个对象是否相等,一旦判定对象相等,就会认为其 State 并没有发生变化,在 Compose 构成的树中,对应节点的函数就不会发生重组。

而 ParcelableSnapshotMutableState 和 ParcelableSnapshotMutableIntState (实际上其他基础类型的装箱变量也是)默认的 SnapshotMutationPolicy 都是 StructuralEqualityPolicy 。

因此,才会发生我们一开始遇到的问题。用 ArrayList 这个类型创建的 State 变量无法触发重组,究其原因还是因为我们创建 State 的姿势不正确。

总结

Compose 的 State 和 LiveData 是非常相似的,都是数据的包装类,从设计模式的角度出发,都是被观察者,想要让观察者感知到其内部数据的变化,我们需要通过合适的方式更新数据。对于不同类型的 API,需要选择合适的方式进行调用。

在 Compose 中 State 的变化,会导致相应的 Compose 函数发生重组。但是有时候,这种重组可能并不是由于自身的变化,而是相邻节点的其他组件感知到变化后,影响 了其他组件。这是我们需要关注的,从性能优化的角度出发,Compose 函数接收的 State 类型变量越少越好。

参考文档