Jetpack Compose 笔记(4) - drivedStateOf

数据的转换

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

Show me the code:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 的用法?!那就试试看:

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

name 作为 remember 的参数起到缓存 key 的作用,忘记的同学看看之前的笔记。这种写法同样可以实现需求。那… 还要 derivedStateOf() 干啥?

两者的本质区别

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

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

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

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

So What?

derivedStateOf 的优势

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
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 对象,上哪保存曾经的结构去?还不懂的看这个例子:

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

还不懂的,建议睡一觉 💤 再接着学吧。

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

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

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

remember 的独到之处

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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 传递状态,而有时我们只是想简单地显示一个字符串而已。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    16
    
    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

    1
    2
    3
    4
    5
    
    @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():适用于监听内部有状态的对象(可变对象)。

禁止转载到私域(公众号,非自己托管的博客等),其他情况请注明原作者与可点击跳转的来源链接。