ITmob-Ly
发布于 2023-08-25 / 231 阅读
0

Jetpack Compose 中 mutableStateOf 的状态改变是怎样管理的?( SnapshotMutationPolicy 快照冲突时的策略详解)

SnapshotMutationPolicy for mutableStateOf in Jetpack Compose

前言

Jetpack Compose 何时重构 UI 的核心是 State 。在 Compose 的状态管理中,使用 MutableState 保存一个可以更改的状态值,一旦值改变就会触发重组。

在使用 Jetpack Compose 时,我们经常使用 mutableStateOf 函数来初始化的新 MutableState 实例,如下所示:

var domainName by remember { mutableStateOf("https://itmob.cn") }

如下代码我们定义了一个 Composable 可组合函数,并定义了一个 MutableState 对象用于保存状态

@Composable
private fun SnapshotMutationPolicySample() {
    var domainName by remember { mutableStateOf("https://itmob.cn") }

    Button(
        onClick = {
            domainName = "https://itmob.cn"
        },
    ) {
        Text(text = domainName)
        Log.i("Composable State", "recomposition / 重组")
    }
}
// 点击按钮时没有任何日志输出

上面的可组合函数,点击按钮时在 onClick 函数中为 domainName 重新赋值,但并没有输出任何日志,也就是说并不会触发 Button 的重组。

这是因为在 MutableState 出现新的值时,默认会检查新值与旧值是否在结构上相等(==),如果新旧两个值相等,则不视为更改。这个过程就是 mutableStateOf 默认的快照策略

也就是 domainName 重新赋的值跟前值相同,所以状态并没有变化,不会触发 Button 的重组。

什么是 SnapshotMutationPolicy

上面的例子中 mutableStateOf 是怎样判断新赋的值跟旧值相同而不会触发状态改变的呢?

我们来看看 mutableStateOf 函数的源码:

fun <T> mutableStateOf(
    value: T,
    policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)

可以看到 mutableStateOf 还有第二个参数 policy,它是 SnapshotMutationPolicy 类型。

文档中 SnapshotMutationPolicy 的介绍:

A policy to control how the result of mutableStateOf report and merge changes to the state object.

意思是 SnapshotMutationPolicy 是一种快照冲突时的解决策略,通过它可以控制如何将一个状态视为已更改。

这是 SnapshotMutationPolicy 接口的定义:

@JvmDefaultWithCompatibility
interface SnapshotMutationPolicy<T> {
    /**
     * 确定设置的新旧状态值是否应视为相等。如果返回 true,则新值不被视为更改
     */
    fun equivalent(a: T, b: T): Boolean

    /**
     * 合并快照中冲突的更改
     */
    fun merge(previous: T, current: T, applied: T): T? = null
}

我们可以看到该接口中定义了两个函数,这里我们先只看 equivalentmerge 函数将在之后的文章中介绍。

equivalent 是如何判断新值是否被视为状态更改的函数。所以实现 SnapshotMutationPolicy 接口就可以控制 State/状态的值改变时是否视为状态改变,从而影响可组合项是否重组。

这种策略可以作为参数传递给 mutableStateOfcompositionLocalOf,提供判断状态是否更新的策略

标准库中预定义的策略

Jetpack Compose 的标准库带有三个预定义的快照策略:

  1. StructuralEqualityPolicy

    如下是 StructuralEqualityPolicy 的源码:

    @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"
    }
    

    StructuralEqualityPolicy 的定义可以看出,这个策略是通过 == 操作符对比新旧值的结构来判断状态是否改变的。而且 StructuralEqualityPolicymutableStateOf 函数中 policy 参数的默认值。

    这也就是为什么前言中给出的例子里点击按钮对状态重新赋值并不会触发重组的原因,因为 == 操作符等价于 equals 用于比较两个对象的结构是否相等。

  2. ReferentialEqualityPolicy

    如下是 ReferentialEqualityPolicy 的源码:

    @Suppress("UNCHECKED_CAST")
    fun <T> referentialEqualityPolicy(): SnapshotMutationPolicy<T> =
        ReferentialEqualityPolicy as SnapshotMutationPolicy<T>
    
    private object ReferentialEqualityPolicy : SnapshotMutationPolicy<Any?> {
        override fun equivalent(a: Any?, b: Any?) = a === b
    
        override fun toString() = "ReferentialEqualityPolicy"
    }
    

    可以看出该策略是通过 === 操作符来对比新旧状态值的,也就是比较两个值的引用(内存地址)是否相同。

    使用 ReferentialEqualityPolicy 策略时,新旧值的引用/内存地址不同即作为状态改变。因此我们需要根据引用是否改变来判断是否重组时,可以使用此策略

  3. NeverEqualPolicy

    如下是 NeverEqualPolicy 的源码:

    @Suppress("UNCHECKED_CAST")
    fun <T> neverEqualPolicy(): SnapshotMutationPolicy<T> =
        NeverEqualPolicy as SnapshotMutationPolicy<T>
    
    private object NeverEqualPolicy : SnapshotMutationPolicy<Any?> {
        override fun equivalent(a: Any?, b: Any?) = false
    
        override fun toString() = "NeverEqualPolicy"
    }
    

    源码中无论新旧值是什么 equivalent 的返回值总是 false,

    也就是无论新旧值是怎样的都作为不同的值,也就是使用此策略时每次更新状态的值都视为更改

从三种预定义的策略的源码可用看到,它们都被定义成了私有object 类型,而且定义了相应的公开方法用于获取它们。

示例

下面我们通过示例看看三种策略的用法和区别:

1. StructuralEqualityPolicy

data class Domain(val id: Int, var name: String, val url: String)

@Composable
private fun StructuralEqualityPolicySample() {
    var domain by remember {
        mutableStateOf(
            value = Domain(
                id = 0,
                name = "itmob",
                url = "https://itmob.cn",
            ),
        )
    }

    Button(onClick = {}) {
        Text(text = "StructuralEqualityPolicy")
        println("Domain instance with StructuralEqualityPolicy: $domain")
    }

    LaunchedEffect(Unit) {
        delay(1000)
        domain = domain.copy(name = "New name")
        delay(1000)
        domain = domain.copy(name = "New name")
    }
}
/**
 * 输出:
 * 第一次渲染,输出 domain 的值
 * Domain instance with StructuralEqualityPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 * LaunchedEffect 中修改了字段 name 并赋新值,domain 对象发生了变化
 * Domain instance with StructuralEqualityPolicy: Domain(id=0, name=New name, url=https://itmob.cn)
 * LaunchedEffect 中再次修改字段 name 并赋新值,但 domain 对象没有变化,不重组不输出任何内容
 */

默认情况下 mutableStateOf 函数的 policy 参数的默认值是 StructuralEqualityPolicy

2. ReferentialEqualityPolicy

@Composable
private fun ReferentialEqualityPolicySample() {
    var domain by remember {
        mutableStateOf(
            value = Domain(
                id = 0,
                name = "itmob",
                url = "https://itmob.cn",
            ),
            policy = referentialEqualityPolicy(),
        )
    }

    Button(onClick = {}) {
        Text(text = "ReferentialEqualityPolicy")
        println("Domain instance with ReferentialEqualityPolicy: $domain")
    }

    LaunchedEffect(Unit) {
        delay(1000)
        domain = domain.copy(name = "itmob")
        delay(1000)
        domain = domain.copy(name = "itmob")
    }
}
/**
 * 输出:
 * Domain instance with ReferentialEqualityPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 * Domain instance with ReferentialEqualityPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 * Domain instance with ReferentialEqualityPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 */

使用 ReferentialEqualityPolicy 时,无论 domain 对象的字段是否变化,只要给它赋的新值的引用(内存地址)发生变化,就会作为新触发可组合项重组。

3. NeverEqualPolicy

@Composable
private fun NeverEqualPolicySample() {
    var domain by remember {
        mutableStateOf(
            value = Domain(
                id = 0,
                name = "itmob",
                url = "https://itmob.cn",
            ),
            policy = neverEqualPolicy(),
        )
    }

    Button(onClick = {}) {
        Text(text = "NeverEqualPolicy")
        println("Domain instance with NeverEqualPolicy: $domain")
    }

    LaunchedEffect(Unit) {
        delay(1000)
        domain = domain
        delay(1000)
        domain = domain
    }
}
/**
 * 输出:
 * Domain instance with NeverEqualPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 * Domain instance with NeverEqualPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 * Domain instance with NeverEqualPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 */

使用 NeverEqualPolicy 时,无论给 domain 赋的新值的结构和引用是否发生改变,只要赋值就视为状态改变而触发可组合项重组。

所以本例中即使给 domain 赋值其本身,domain 对象的字段和引用都没有变化,依然会触发 Button 可组合项的重组。

4. 自定义策略

@Composable
private fun CustomEqualPolicySample() {
    var domain by remember {
        mutableStateOf(
            value = Domain(
                id = 0,
                name = "itmob",
                url = "https://itmob.cn",
            ),
            // 自定义 policy
            policy = object : SnapshotMutationPolicy<Domain> {
                override fun equivalent(a: Domain, b: Domain) = a.id == b.id
            },
        )
    }

    Button(onClick = {}) {
        Text(text = "CustomEqualPolicy")
        println("Domain instance with CustomEqualPolicy: $domain")
    }

    LaunchedEffect(Unit) {
        delay(1000)
        domain = domain.copy(id = 1)
        delay(1000)
        domain = domain.copy(name = "New name")
    }
}
/**
 * 输出:
 * Domain instance with CustomEqualPolicy: Domain(id=0, name=itmob, url=https://itmob.cn)
 * Domain instance with CustomEqualPolicy: Domain(id=1, name=itmob, url=https://itmob.cn)
 */

上面的代码中自定义了我们自己的 policy 并实现了 equivalent 方法,只要新旧两个值的 id 相同则视为它们没有变化。

所以 LaunchedEffect 中第一次给状态重新赋值时修改了 id 则触发了 Button 的重组,而第二次重新赋值,只修改了 domainname 字段,则不会触发 Button 的重组。

总结

本文介绍了 MutableState 是怎样判断状态是否需要更新的,因此,如果正确使用预定义的 SnapshotMutationPolicy 或自定义 SnapshotMutationPolicy 可以避免由于无意义的更新而导致不必要的重组。