文章

Jetpack Compose 笔记(4) - drivedStateOf

数据的转换

对于 Compose 中的数据,用 mutableStateOf() 包裹来实现监听刷新我们已经轻车熟路。那如果一个数据依赖于另一个或多个数据怎么办?这就用到 derivedStateOf(),它专门用于数据的转换,当上游数据发生变化时会自动刷新下游数据,进一步刷新 UI。有点类似 LiveData 中 Transformations.switchMap 的作用。

Show me the code:

setContent {
    var name by remember { mutableStateOf("chenhe") }
    val upperName by remember { derivedStateOf { name.uppercase() } }
    Column {
        Text(upperName)
        Button(onClick = { name = "bob" }) {
            Text(text = "Change Name")
        }
    }
}

upperNamename 转换得来,一开始会显示 "CHENHE",点击按钮后变成 "BOB"。好了,本节到此结束 😋


当然没那么简单 - -

为什么一定要经过包装才行呢?换句话说,套一层 derivedStateOf() 是为了解决什么问题?答案是:

  1. 上游数据改变时要重新处理。
  2. 上游数据没有改变时,不要重新处理。

诶?变则刷新,不变就不刷新... 听起来很像 remember 的用法?!那就试试看:

var name by remember { mutableStateOf("chenhe") }
val upperName = remember(name) { name.uppercase() } // <--------关键

name 作为 remember 的参数起到缓存 key 的作用,忘记的同学看看[之前的笔记]({{< ref "JetpackCompose笔记(2).md#带参-remember" >}})。这种写法同样可以实现需求。那... 还要 derivedStateOf() 干啥?

两者的本质区别

诚然 derivedStateOfremember 都可以实现刷新,但他们的原理不同,更准确来讲,是驱动不同。

对于前者的情况,当 name 改变,upperName 使用到了 name 因此发生刷新。但由于无参 remember 的存在,upperName 的引用没有改变,依然是最开始的那个 MutableState,只不过内部的值变了,因为 derivedStateOf 实现了对上游数据的监听,它会重新调用转换函数,生成新的值。

对于后者则有所不同。同样发生了刷新,对于第二个 remember 来说 key 发生了变化,因此要重新执行初始化代码。初始化代码只是简单的一次性转换,没有监听功能。

总结,驱动 derivedStateOf 刷新的是其内部的监听机制;而驱动 remember 刷新的是它外部的 Recompose Scope。前者是主动,后者是被动。

So what?

derivedStateOf 的优势

在最简单的例子中,两个写法没有什么区别。那么我们把单一的值换成 List 试试看:

setContent {
    val names = remember { mutableStateListOf("chenhe", "olivia") }
    val upperNames = remember(names) { names.map { it.uppercase() } }
    Column {
        Text(upperNames.joinToString())
        Button(onClick = { names.add("bob") }) {
            Text(text = "Change Name")
        }
    }
}

想象中,点击按钮后应该显示三个名字。实际上,显示没有发生变化 😵

此时聪明的网友就要抢答了。这个我知道!names 的引用没有发生变化,只是内容变了,系统判定 key 没有发生变化。如果你想表达的是 Reference Equality 与 Structural Equality 的问题,那么又错了 ❌,请课后及时复习,key 的比较恰好就是结构性比较。

真实原因比这位网友的猜测还要简单:它在自己和自己对比! 好了,先停下来好好琢磨一下这句话再继续。

names 指向的对象没有变化,尽管做的是结构性比较,那也是当前结构与当前结构的比较,肯定相等呀!得把当前结构和过去结构相比才能发现变化,不过我们只有这一个 List 对象,上哪保存曾经的结构去?还不懂的看这个例子:

val l1 = mutableListOf(1, 2)
val l2 = l1
l1.add(3)
l1 == l2  // Structural Compare: true

还不懂的大概是脑子已经乱了,建议睡一觉 💤 再接着学吧。

显然,remember + 参数的方式失效了。别告诉我直接删掉 remember 就行了,你不要性能了么。一个优雅的解决方案就是 derivedStateOf

val names = remember { mutableStateListOf("chenhe", "olivia") }
val upperNames by remember { derivedStateOf { names.map { it.uppercase() } } }

现在 upperNames 的刷新由 derivedStateOf 内部驱动,不受 remember 控制了,但它不多余:可以保证因其他原因导致重组时 upperNames 不会产生多余的计算。

remember 的独到之处

学到这里,又有好事者提出:既然如此,抛开性能不谈,是不是 remember 没啥用了,可以全部用 derivedStateOf 实现?那再看看这个例子:

setContent {
    var name by remember { mutableStateOf("chenhe") }
    UpperName(name) { name = "bob" }
}

@Composable
private fun UpperName(name: String, click: () -> Unit) {
    val upperName by remember { derivedStateOf { name.uppercase() } }
    Column {
        Text(upperName)
        Button(onClick = click) {
            Text(text = "Change Name")
        }
    }
}

我们把一部分逻辑提取到了新的函数里。不卖关子了,点击后 UI 不会变化的。首先,upperNameremember 包裹所以不会二次初始化,这一点应该没什么问题。按照上文的分析,derivedStateOf 内部会监听到变化而更新。问题就出在这里。曾经 derivedStateOf 代码块所使用到的是 MutableState,现在使用到的是一个普普通通的 String。String 没有「状态」,数值改变当然也就无法触发任何监听。

形象地总结:当一个被代理的变量作为参数传递的时候,就变成了普通的值,不再是 MutableState,导致监听链条断开,进而导致函数内部无法监听变化。

分析了原因,解决方案也就水到渠成。有两个可行的做法:

  1. 强行传递 State,而不是普通的值。但这不是一个优雅的方案——它强制 caller 传递状态,而有时我们只是想简单地显示一个字符串而已。

    setContent {
        val name = remember { mutableStateOf("chenhe") }
        UpperName(name) { name.value = "bob" }
    }
    
    @Composable
    private fun UpperName(name: State<String>, click: () -> Unit) {
        // name 是正宗的 State,可以被监听
        val upperName by remember { derivedStateOf { name.value.uppercase() } }
        Column {
            Text(upperName)
            Button(onClick = click) {
                Text(text = "Change Name")
            }
        }
    }
    
  2. 使用带参 remember

    @Composable
    private fun UpperName(name: String, click: () -> Unit) {
        val upperName = remember(name) { name.uppercase() }
        // ...
    }
    

总结

最佳实践

  1. 对于状态数据的转换,在同一个函数中,优先使用 derivedStateOf() + remember()
  2. 对于参数传递过来的无状态数据(String, Int 等),只能使用带参数的 remember()
  3. 对于参数传递过来的有内部状态的数据(SnapshotStateList 等),要使用 derivedStateOf() + 带参 remember()。带参数是防止传过来的对象本身引用发生变化,这种情况 derivedStateOf() 是监听不到的。

作用的区别

带参 remember():可以判定对象的重新赋值(引用改变),适合对函数参数使用。
derivedStateOf():适用于监听内部有状态的对象(可变对象)。