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")
}
}
}
upperName
由 name
转换得来,一开始会显示 "CHENHE",点击按钮后变成 "BOB"。好了,本节到此结束 😋
当然没那么简单 - -
为什么一定要经过包装才行呢?换句话说,套一层 derivedStateOf()
是为了解决什么问题?答案是:
- 上游数据改变时要重新处理。
- 上游数据没有改变时,不要重新处理。
诶?变则刷新,不变就不刷新... 听起来很像 remember
的用法?!那就试试看:
var name by remember { mutableStateOf("chenhe") }
val upperName = remember(name) { name.uppercase() } // <--------关键
name
作为 remember
的参数起到缓存 key 的作用,忘记的同学看看[之前的笔记]({{< ref "JetpackCompose笔记(2).md#带参-remember" >}})。这种写法同样可以实现需求。那... 还要 derivedStateOf()
干啥?
两者的本质区别
诚然 derivedStateOf
与 remember
都可以实现刷新,但他们的原理不同,更准确来讲,是驱动不同。
对于前者的情况,当 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 不会变化的。首先,upperName
被 remember
包裹所以不会二次初始化,这一点应该没什么问题。按照上文的分析,derivedStateOf
内部会监听到变化而更新。问题就出在这里。曾经 derivedStateOf
代码块所使用到的是 MutableState,现在使用到的是一个普普通通的 String。String 没有「状态」,数值改变当然也就无法触发任何监听。
形象地总结:当一个被代理的变量作为参数传递的时候,就变成了普通的值,不再是 MutableState,导致监听链条断开,进而导致函数内部无法监听变化。
分析了原因,解决方案也就水到渠成。有两个可行的做法:
-
强行传递 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") } } }
-
使用带参
remember
✅@Composable private fun UpperName(name: String, click: () -> Unit) { val upperName = remember(name) { name.uppercase() } // ... }
总结
最佳实践
- 对于状态数据的转换,在同一个函数中,优先使用
derivedStateOf()
+remember()
。 - 对于参数传递过来的无状态数据(String, Int 等),只能使用带参数的
remember()
- 对于参数传递过来的有内部状态的数据(SnapshotStateList 等),要使用
derivedStateOf()
+ 带参remember()
。带参数是防止传过来的对象本身引用发生变化,这种情况derivedStateOf()
是监听不到的。
作用的区别
带参 remember()
:可以判定对象的重新赋值(引用改变),适合对函数参数使用。
derivedStateOf()
:适用于监听内部有状态的对象(可变对象)。