Compose 实践与探索三 —— 深入理解重组

前面我们已经提到过,Composable 函数依赖的状态发生变化时会触发重组,比如 mutableStateOf() 的变量值变化、mutableStateListOf() 的 List 内元素变化、mutableStateMapOf() 的 Map 内元素变化时都会触发重组。本篇我们来深入了解重组的过程,了解重组有哪些性能风险以及如何应对。

1、重组的风险与优化

1.1 重组的风险

重组会对相应的重组作用域(Recompose Scope)内的所有组件进行刷新,因此可能会发生一个重组作用域内有 A、B 两个组件,A 依赖的状态发生变化触发整个作用域的重组,但 B 组件依赖的状态没有变化,明明不需要被重组,却受到 A 的影响也被重组了,这就是重组带来的性能风险。

举个简单的示例:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        var name by mutableStateOf("Compose")

        setContent {
            println("Recompose 范围:1")
            Column {
                println("Recompose 范围:2")
                Text(text = name, modifier = Modifier.clickable { name = "Recompose" })
            }
        }
    }

点击 Text 后文字内容会由初始的 “Compose” 变为 “Recompose”,打印 log 如下:

Recompose 范围:1
Recompose 范围:2
Recompose 范围:1
Recompose 范围:2

第一组 1、2 是初次构建页面,进行组合时的输出。第二组 1、2 是点击 Text 后进行重组时的输出。可以看到,虽然只改变了 Text 所需要的 name 状态,但是在 Column 之外的范围也发生了重组,这就是一个性能风险。

1.2 重组的跳过

在上述示例代码的基础上增加一个空参的可组合函数 Heavy:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        var name by mutableStateOf("Compose")

        setContent {
            println("Recompose 范围:1")
            Column {
                println("Recompose 范围:2")
                Heavy()
                Text(text = name, modifier = Modifier.clickable { name = "Recompose" })
            }
        }
    }

@Composable
fun Heavy() {
    println("Recompose Heavy")
    Text(text = "Heavy")
}

还是点击 Text 变更文字内容,输出如下:

Recompose 范围:1
Recompose 范围:2
Recompose 范围:1
Recompose 范围:2

可以看到并没有像预期的那样,输出 Heavy 内的日志内容,这实际上是因为 Compose 对 Composable 函数做了优化,如果在重组时检测到 Composable 函数内使用的状态没有发生变化,那么就不执行函数内容。因此 Heavy 的日志内容没有输出,不是因为该函数没有被调用,而是在调用时判断出函数使用的状态没有变化,因此跳过了对该函数的执行。

Compose 的编译过程是有它的编译器插件做干预的,干预过程会修改 Composable 函数,增加 Composer 参数,并且为代码内容添加一些判断条件,判断函数参数与上一次被调用时是否发生了变化,如果没有,意味着即便再次调用也与上一次运行结果相同,显示内容不会变化,所以干脆跳过这一次对函数内部代码的执行。

Compose 这种自动更新 UI 的机制容易出现更新范围过大、超过需求的问题,因此需要跳过没必要的更新。而传统的 View 系统是手动指定更新哪一个 UI 组件的,因此不会有超出范围的问题。

1.3 判断依据

在判断 Composable 函数参数是否发生变化时,采用的是 Kotlin 的 == 进行判断。为了验证这一说法,我们对上面的示例代码进行改造:

@Composable
fun Heavy(user: User) {
    println("Recompose Heavy")
    Text(text = "Heavy: ${user.name}")
}

// 注意使用 val 修饰 name
data class User(val name: String)

然后在点击 Text 时为 Heavy 参数上的 User 对象重新赋值,赋的值与初始值一样:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        var name by mutableStateOf("Compose")
        var user = User("Compose")

        setContent {
            println("Recompose 范围:1")
            Column {
                println("Recompose 范围:2")
                Heavy(user)
                Text(
                    text = name,
                    modifier = Modifier.clickable {
                        name = "Recompose"
                        user = User("Compose")
                    }
                )
            }
        }
    }

这样点击 Text 后,输出如下:

Recompose 范围:1
Recompose 范围:2
Recompose Heavy
Recompose 范围:1
Recompose 范围:2

说明点击 Text 后没有执行 Heavy 内的代码。此时如果将 Text 点击事件内为 User 赋值改为 “Compose” 以外的任意值,那么新的 User 与初始的 User 通过结构性相等(Structual Equality),即 Kotlin 的 ==(区别于 Referential Equalilty 的 ===)判断的结果就为 false,输出结果如下:

Recompose 范围:1
Recompose 范围:2
Recompose Heavy
Recompose 范围:1
Recompose 范围:2
Recompose Heavy

说明当 Composable 函数的参数不满足 == 判断为 true 的条件时,会执行函数内的代码。

除此之外,还有一个需要留意的地方就是当前 User 在定义 name 属性时使用的是 val,假如换成 var,那么 Compose 会认为 User 是一个不可靠的类。对于不可靠的类,Compose 不会对其进行判断,而是在重组时一定会执行其函数内的代码。不论重新为 User 赋什么样的值,甚至去掉 clickable() 内为 User 重新赋值的语句 user = User("Compose"),不修改 Heavy 上的参数,都会在重组时执行 Heavy 内的代码。

通过反证法解释一下为什么类的属性是 var 时会在重组时无脑执行其代码。假如还像 val 一样,在参数对象满足结构性相等时不执行函数代码,那么对于以下代码:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        var name by mutableStateOf("Compose")
        // user1 和 user2 是普通变量,不是 mutableState
        val user1 = User("Compose")
        val user2 = User("Compose")
        var user = user1

        setContent {
            println("Recompose 范围:1")
            Column {
                println("Recompose 范围:2")
                Heavy(user)
                Text(
                    text = name,
                    modifier = Modifier.clickable {
                        name = "Recompose"
                        user = user2
                    }
                )
            }
        }
    }

当点击 Text 时由于 name 状态被修改从而触发重组,按照我们的假设,Heavy 的参数由于 user1 和 user2 的属性值相同,因此不会执行 Heavy 的代码,它的参数仍然是 user1,而不是 user2,这样一直监听 user1 的值为后续步骤埋下隐患。倘若在某个地方修改了 user2 的 name 属性值,Heavy 内的文字 Text 不会随着 user2 变化,造成了 bug。

因此在不是所有属性都是 val 的类对象作为 Composable 函数参数时,会无脑执行该函数内容以避免上述问题。

如果 Composable 函数的参数都是不可变类型(所有属性都是 val 的),意味着该 Composable 组件的状态不会发生变化,因此在重组时可以放心地一直跳过它。但假如有一个参数是可变类型(类内有至少一个 var 属性),由于 Compose 无法监测到这个类型的属性是否发生了变化,因此安全起见,在重组时对这种 Composable 不进行跳过,统统执行函数内的代码。

1.4 @Stable 注解

稳定性声明

如果按照上一节的判断依据,只要参数类型是可变类型就在重组时无脑执行其内容,那么似乎很多情况下都无法跳过,性能优化也就无从谈起。为了解决这一问题,Compose 提供了 @Stable 注解,让程序员手动标注该类型是一个稳定类型,这样在进行智能跳过时,对于稳定类型,即便有 var 属性,也会跳过。

这个问题实际上与 HashMap 的 Key 冲突问题类似,当我们向 HashMap 中添加数据时,对于 String、Int 等不可变类型不会有问题。但对于自定义类,你会自定义实现 hashCode() 和 equals(),当类内的属性发生变化时,hashCode() 的返回值就会改变,从而导致 HashMap 的不稳定。

我们给 User 类加上 @Stable 注解:

@Stable
data class User(var name: String)

然后点击 Text 发现 Heavy() 又能跳过重组了:

Recompose 范围:1
Recompose 范围:2
Recompose Heavy
Recompose 范围:1
Recompose 范围:2

加上 @Stable 还没有完,你还需切实地保证参数上的对象确实是稳定的。如何保证两个可变对象的所有可变属性永远相等呢?这似乎做不到,因此只能通过另一种形式,让两个不同的对象在做比较时永远不相等,即做 === 判断。而刚好在 Java 的 Object 与 Kotlin 的 Any 中,equals() 的默认实现就是 ===。所以,在为 User 添加 @Stable 注解的同时,还要去掉其 data 类的声明,变为一个普通类:

@Stable
class User(var name: String)

性质

@Stable 的“稳定”指三点:

  1. 现在相等就永远相等
  2. 当被标记的类的公开属性改变时,要通知用到该属性的 Composition
  3. 公开属性需要全部是稳定的属性

Compose 只判断第二条,即只需满足第二条,Compose 就会认为这个类是稳定的(因为 1、3 Compose 判断不了)。

为了构造第二点,需要对 User 做进一步的修改:

class User(name: String) {
    var name by mutableStateOf(name)
}

将 User 的公开属性 name 声明为一个状态,这样当它改变时就能通知到该属性的 Composition 了。并且在满足这一点后,不用声明为 @Stable 也是可以的了。

源码中有一些类或接口在处理内部可变属性时没有使用 mutableStateOf() 这种形式,因此 Compose 无法判断出它到底是不是一个稳定类,此时可以用 @Stable 注解帮助 Compose 确定该类确实是一个稳定类,比如 MutableState:

@Stable
interface MutableState<T> : State<T> {
    override var value: T
    operator fun component1(): T
    operator fun component2(): (T) -> Unit
}

当然在实现时,确实保证了 var 属性变化时通知到所有的 Composition。比如 SnapshotMutableStateImpl 中在重写 value 时,set() 确实通过特殊方式通知到了所有 Composition:

internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
    @Suppress("UNCHECKED_CAST")
    override var value: T
        get() = next.readable(this).value
        set(value) = next.withCurrent {
            if (!policy.equivalent(it.value, value)) {
                next.overwritable(this, it) { this.value = value }
            }
        }
}

第三点,假如为 User 增加一个不稳定属性 Company:

class Company(var address: String)

class User(name: String, company: Company) {
    var name by mutableStateOf(name)
    var company by mutableStateOf(company)
}

由于 Company 不稳定导致使用它作为成员属性的 User 也变为不稳定的了,但是由于 company 属性做成了 mutableStateOf() 这种可变状态,在变化时会通知所有的 Composition,所以在实际处理时,Compose 是会为 User 对象进行智能跳过的。

@Immutable 是比 @Stable 更“稳定”的类型,它表明该类是一个不变类。

总结:

  • 不要轻易重写 equals()
  • 所有 var 属性都用 by mutableStateOf() 做初始化
  • 如果用特殊方式实现变量变化时对 Composition 的通知,使得 Compose 无法检测出来,为这个类加一个 @Stable 注解帮助 Compose 进行判断

2、derivedStateOf()

derivedStateOf() 是一个附带效应 API,本应该在附带效应专题篇章中介绍。但是由于其作用和使用场景与重组有很大的关联,并且在用法上与 remember() 有相似之处,容易混淆,所以就单独拿到重组的部分介绍。

derivedStateOf() 是 Compose 中很小的一个知识点,官方文档只用了很小的篇幅来介绍它,但是它并不太容易学透彻。

2.1 作用与基本用法

derivedStateOf() 用于将一个或多个状态对象转换(或者说派生)为其他状态,它的核心作用是:

  1. 自动追踪依赖:在计算过程中访问的所有 State 对象会被自动记录为依赖项
  2. 缓存计算结果:只有当依赖项发生变化时,才会重新执行计算逻辑,否则直接返回缓存值
  3. 高效更新:通过订阅依赖项的变化,仅在必要时触发界面重组

derivedStateOf() 只有一个参数,是一个生成被派生出来的状态对象的函数:

/**
 * 创建一个 [State] 对象,其 [State.value] 值为 [calculation] 的计算结果。计算结果将以一种缓存方式
 * 进行存储,多次调用 [State.value] 不会导致 [calculation] 多次执行,但是读取 [State.value] 会导致
 * 在当前 [Snapshot] 中读取所有在 [calculation] 中被读取的 [State] 对象。
 * 没有变异策略的派生状态在每次依赖关系更改时触发更新。为避免更新时的失效,通过 [derivedStateOf] 重载
 * 提供适当的 [SnapshotMutationPolicy]。
 *
 * @param calculation 用于生成该状态的值的计算函数
 */
fun <T> derivedStateOf(
    calculation: () -> T,
): State<T> = DerivedSnapshotState(calculation, null)

calculation 的返回值 T 就是 derivedStateOf() 返回的状态对象 State<T> 中保存的 value。

下面我们通过一段官方文档中的示例来解释 derivedStateOf() 的作用与用法。假设要从待办任务列表中筛选出含有高优先级关键字列表中的任务作为高优先级任务,并先后展示高优先级任务列表与全部待办任务。在学习 derivedStateOf() 之前,可能会认为使用 remember() 可以实现该需求:

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {
    // 假设 mutableStateListOf() 内提供了很多字符串作为待办任务
    val todoTasks = remember { mutableStateListOf<String>(...) }
    val highPriorityTasks by remember(key1 = highPriorityKeywords, key2 = todoTasks) {
        // containsWord() 会在 it 包含 highPriorityKeywords 内任意一个任务时返回 true
        todoTasks.filter { it.containsWord(highPriorityKeywords) }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) {}
            items(todoTasks) {}
        }
    }
}

这样确实可以从所有的任务列表 todoTasks 中帅选出包含高优先级词汇 highPriorityKeywords 的任务作为高优先级任务列表 highPriorityTasks。并且当 highPriorityTasks 的两个依赖项 todoTasks 与 highPriorityKeywords 发生变化时,remember() 会重新计算 highPriorityTasks。但这里有两个性能问题:

  1. 频繁计算:remember() 依赖的两个 key,有任意一个发生变化时,就会导致重新计算 remember() 的尾随 lambda,但倘若只是一个微小的、不会改变 highPriorityTasks 的结果的变化,比如 todoTasks 内新增了一个非高优先级任务,也会触发过滤逻辑的重新执行。如果有大量的不改变 highPriorityTasks 的状态变化,势必会有大量的无用计算,并且每次计算得出的都是一个新的列表对象,即便内容相同,Compose 在比较列表内容时也需要遍历列表产生 O(n) 的时间开销,影响性能
  2. 触发不必要的重组:假如 todoTasks 的引用发生变化,但过滤结果 highPriorityTasks 没发生变化,LazyColumn 仍然会触发重组

这里解释一下,为什么 highPriorityTasks 依赖的是 todoTasks 与 highPriorityKeywords 两项,特别是后者。

首先,todoTasks 的类型也就是 mutableStateListOf() 的返回值 SnapshotStateList 实现了 StateObject,因此 todoTasks 是一个状态对象,可以被依赖。

然后,再看参数 highPriorityKeywords,其类型是 List<String>,表面上看起来它似乎并不是一个状态对象,但是在实际调用 TodoList() 时,完全可以给它传一个带有状态的 List:

val list = remember {
    mutableStateListOf("String1", "String2", "String3")
}
TodoList(list)

而且在实践中,为了 TodoList() 这个 Composable 函数可以在重组时自动进行更新,对 highPriorityKeywords 这个参数几乎就会传递一个状态对象。

因此,highPriorityTasks 的值实际上是依赖于 todoTasks 和 highPriorityKeywords 这两个状态对象的。

因此,为了减少不必要的重组,应该用 derivedStateOf() 结合 remember() 来实现这个需求:

@Composable
fun TodoList(highPriorityKeywords: List<String> = listOf("Review", "Unblock", "Compose")) {
    val todoTasks = remember { mutableStateListOf<String>(...) }
    val highPriorityTasks by remember(highPriorityKeywords) {
        derivedStateOf { todoTasks.filter { it.containsWord(highPriorityKeywords) } }
    }

    Box(Modifier.fillMaxSize()) {
        LazyColumn {
            items(highPriorityTasks) {}
            items(todoTasks) {}
        }
    }
}

优化后的代码解决了仅使用 remember() 所产生的性能问题:

  1. 计算频率降低:由于 derivedStateOf() 会在内部跟踪其 lambda 表达式中访问的所有状态(也就是 todoTasks 与 highPriorityKeywords),只有当这些状态的变化导致过滤结果改变时,才会重新计算 highPriorityTasks,所以有效降低了计算频率
  2. 避免无效重组:derivedStateOf() 返回的 State 的对象,其 value 的变更会被 Compose 智能比较。即便 todoTasks 引用发生变化,但如果过滤结果的 highPriorityTasks 的内容不变,依赖 highPriorityTasks 的组件 LazyColumn 也不会触发重组

这里还需要解释一下,为什么不干脆去掉 remember(highPriorityKeywords) 只使用 derivedStateOf()。因为虽然 derivedStateOf() 确实能自动跟踪它内部依赖的状态的变化 todoTasks,但另一个依赖 highPriorityKeywords 是一个函数参数,derivedStateOf() 无法跨过 TodoList 函数去追踪调用端的实参是否发生了变化,所以才需要借助 remember() 来确定 highPriorityKeywords 的内容甚至是引用是否变化了。

再多说一句,即便 highPriorityKeywords 不是函数形参,而是就像 todoTasks 那样是这个函数内部声明的状态,你也需要在使用 derivedStateOf() 时在它外面包上一层不传 key 的 remember() 以缓存 derivedStateOf() 的计算结果,这样可以在遇到预期之外的重组时可以直接用缓存作为结果而不用重新计算 derivedStateOf()。

2.2 与 remember() 比较

上一小节,我们说明了 derivedStateOf() 的最典型的使用场景,就是要创建的状态依赖于其他状态时。但这只是使用场景之一,还有其他的使用场景。接下来,我们还是通过一个例子,通过 derivedStateOf() 与 remember() 的比较引出前者的另一个使用场景。

通用的场景

假如我想在点击 Text 之后将其显示的内容变为指定字符串的大写形式,使用 derivedStateOf() 实现如下:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            var name by remember { mutableStateOf("init name") }
            val processedName by remember { derivedStateOf { name.uppercase() } }
            Text(processedName, Modifier.clickable { name = "frank" })
        }
    }

这个功能通过 remember() 也能实现:

	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        setContent {
            var name by remember { mutableStateOf("init name") }
            val processedName = remember(name) { name.uppercase() }
            Text(processedName, Modifier.clickable { name = "frank" })
        }
    }

二者实现相同效果的原理不同:

  • 第一种使用 derivedStateOf() 的方式,由于 name 变化,导致 derivedStateOf() 会重新计算其尾随 lambda,生成一个新的状态对象。是一种从内部触发的重新计算,因为 remember 没有传 key 参数,所以不是从外部的 remember() 去触发的重新计算
  • 第二种使用 remember() 传入参数 name 作为 key 的方式就是由 remember() 触发重新计算,因为 key 变了,这是一种从外部触发的重新计算

remember() 的不足之处

虽然两种方式的计算过程不同,但效果相同,是否意味着两种方式等价呢?当然不是,我们把数据类型由 String 变为 List,情况就大不相同了:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    
        setContent {
            val names = remember { mutableStateListOf("android", "java") }
            val processedNames = remember(names) { names.map { it.uppercase() } }
            Column {
                processedNames.forEach {
                    Text(it, Modifier.clickable { names.add("kotlin") })
                }
            }
        }
    }

示例代码的意图是在点击 Column 内的 Text 时添加一项大写的 KOTLIN 到 Column 中,但测试时点击 Text 并没有任何效果。问题出在 remember(names) 这里,我们需要探究一下 remember() 在判断 key 相等这一点上的机制,以使用一个 key 的 remember() 为例:

@Composable
inline fun <T> remember(
    key1: Any?,
    crossinline calculation: @DisallowComposableCalls () -> T
): T {
    return currentComposer.cache(currentComposer.changed(key1), calculation)
}

先看 Composer 的 cache():

@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        // 缓存的值失效或首次执行,没有缓存值时
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

当 remember() 缓存的值失效或首次执行 remember() 时,才会执行 block 并缓存且返回 block 的结果。其中,缓存的值是否失效是由 invalid 参数,也就是 Composer 的 changed() 决定的:

	// ComposerImpl 是 Composer 接口的唯一实现类
	@ComposeCompilerApi
    override fun changed(value: Any?): Boolean {
        return if (nextSlot() != value) {
            updateValue(value)
            true
        } else {
            false
        }
    }

可以看到当新传入的 key 值与原来的 key 值不相等 != 时就认为 key 变了,才会重新计算并缓存 remember() 的 calculation。总结起来,就是用结构性相等(Kotlin 的 ==,Java 的 equals())判断 key 的变化,那么我们再看 mutableStateListOf() 的返回值类型,是 SnapshotStateList:

fun <T> mutableStateListOf(vararg elements: T) =
    SnapshotStateList<T>().also { it.addAll(elements.toList()) }

SnapshotStateList 并没有重写 Any 的 equals(),因此会使用 Any 默认的 equals() 进行结构性相等的判断,而 Kotlin 官方文档指出,Any 的 equals() 默认采用引用相等(===),这样最终的结果就是,remember() 在判断 List 类型的 key 时,采用了引用相等。这也就解释了,为什么点击 Text 后 Column 内没有增加新的 Text,因为通过 add() 向 names 中添加元素,只改变了 List 的内容,但没有改变 List 的引用,所以 remember() 没有重新计算而是拿的缓存值赋值给 processedNames。

因此,remember() 对于 key 是否发生变化的判断是不完美的,它判断不了集合类型的内部元素发生的变化。

解决方案

至于解决方案,不能简单地撤掉 remember(names),将其变为 val processedNames = names.map { it.uppercase() } 。这样改虽然能满足功能需求,但是由于没使用 remember(),导致每次进行重组时不论 names 是否发生了改变都会对 processedNames 进行计算,有性能损耗,不可取。

还有一种方法,就是在点击 Text 时重新创建一个 List 给到 names 然后再添加新的字符串:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    
        setContent {
            // 用 by 的话 names 的类型就是 List<String>,用 = 的话就是 MutableState<List<String>>
            var names by remember { mutableStateOf(listOf("android", "java")) }
            val processedNames = remember(names) { names.map { it.uppercase() } }
            Column {
                processedNames.forEach {
                    Text(it, Modifier.clickable {
                        names = names.toMutableList().apply { add("kotlin") }
                    })
                }
            }
        }
    }

这样做也能满足功能需求,但是有些啰嗦。

最后就又要回到 derivedStateOf(),以一种比较恰当的方式来解决这个问题:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
    
        setContent {
            var names = remember { mutableStateListOf("android", "java") }
            // remember 不能拿掉,原因与 mutableStateOf、mutableListStateOf 一样,防止重复初始化
            val processedNames by remember { derivedStateOf { names.map { it.uppercase() } } }
            Column {
                processedNames.forEach {
                    Text(it, Modifier.clickable { names.add("kotlin") })
                }
            }
        }
    }

由于点击 Text 时,names 的内容发生了变化,而 derivedStateOf() 能感知到这种变化,所以会触发重新计算,使 processedNames 拿到更新后的状态。

derivedStateOf() 受限的场景

上面讲了 derivedStateOf() 适用但 remember() 不适用的场景,那有没有反过来的情况呢?当然有!并且在 2.1 节中我们还提到过,来看示例代码:

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        
        setContent {
            var name by remember { mutableStateOf("android") }
            // 使用 derivedStateOf() 构建状态
            val processedName1 by remember { derivedStateOf { name.uppercase() } }
            // 使用 remember(Key) 构建状态
            val processedName2 = remember(name) { name.uppercase() }
            Text(processedName1, Modifier.clickable { name = "compose" })
        }
    }

如上述代码示例,Text 使用 processedName1 或 processedName2 都可实现点击 Text 后显示 “COMPOSE” 的效果。现在把这些代码抽取到一个 Composable 函数中,让 name 成为参数:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
        var name by remember { mutableStateOf("android") }
        ProcessedName(name) { name = "compose" }
    }
}

@Composable
private fun ProcessedName(name: String, onClick: () -> Unit) {
    val processedName1 by remember { derivedStateOf { name.uppercase() } }
    val processedName2 = remember(name) { name.uppercase() }
    Text(processedName2, Modifier.clickable(onClick = onClick))
}

测试发现,Text 用 processedName1 点击后文字不变,用 processedName2 点击后文字可变。

捋一下代码过程以找出问题的原因:

  • 点击 Text 会调用 onClick(),onClick() 内将 name 修改为 “compose”,由于 name 这个状态发生变化,因此所有监听 name 的位置都会被标记为失效进而触发重组
  • 重组会用 name 的新值去执行 ProcessedName(),由于状态作为参数时,传递的不是状态对象本身,而是状态的值,所以 ProcessedName() 的参数 name 就是一个 String 类型的值,而不是 State<String> 这个状态对象,因此:
    • derivedStateOf() 后面的大括号内实际上就没有状态了,name 不论如何变化都不会触发重新计算,再加上 remember() 也没有传 key 不会重新计算,所以 processedName1 不会随着 name 的变化而重新计算
    • remember() 使用结构性相等判断 key 是否变化,对于非状态的普通数据也可以进行比较,它可以感知到 name 的变化,因此可以进行重新计算得到新的 processedName2 以更新 Text 显示的内容

这里详细解释一下,为什么调用 ProcessedName() 时传入的 name 是一个状态对象,但是作为函数参数后它就不是状态对象了:

setContent {
    var name by remember { mutableStateOf("android") }
    ProcessedName(name) { name = "compose" }
}

将上述代码换成等价形式:

setContent {
    var name = remember { mutableStateOf("android") }
    ProcessedName(name.value) { name.value = "compose" }
}

这样就容易看出传入函数作为参数的实际上是状态的值,而不是状态对象本身。状态无法穿透函数参数传递到函数的内部,传递到函数内部的只有状态的值。

当然,你可以通过将函数参数声明为一个状态对象来解决该问题:

@Composable
private fun ProcessedName(name: State<String>, onClick: () -> Unit)

但是这样会强制调用者提供一个状态对象作为函数参数,但通常我们可能只有一个 String,而不是 State<String>,这样做收窄了函数的调用范围,因此通常我们不会这样做。常用方法还是在函数上声明一个非状态的数据类型,然后在函数内部使用 remember(key) 这种形式。

2.3 总结

关于 derivedStateOf() 与 remember() 使用方法上的结论:

  1. 监听没有内部状态的状态对象(如 mutableStateOf())从而自动刷新,有两种写法:
    • 带参数的 remember()
    • 不带参数的 remember() + derivedStateOf()
  2. 对于有内部状态的状态对象而言(如 mutableStateListOf()),不能使用带参数的 remember(),因为它无法监听到内部状态的改变,只能使用 derivedStateOf()
  3. 对于函数参数里的数据(String、Int 等,但 List 不行),监听链条会被掐断,所以不能用 derivedStateOf(),只能用带参数的 remember()

对于结论的第 3 点,如果参数是 List 类型,我们拿出来单独说明一下:

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setContent {
        val names = remember { mutableStateListOf("android", "kotlin") }
        ProcessedName(names) { names.add("compose") }
    }
}

@Composable
private fun ProcessedName(names: List<String>, onClick: () -> Unit) {
    val processedNames = remember(names) { names.map { it.uppercase() } }
    Column {
        processedNames.forEach {
            Text(it, Modifier.clickable(onClick = onClick))
        }
    }
}

当函数参数类型是 List 时,如果还按照第 3 点的指示,使用带参数的 remember() 构造 processedNames,很明显是行不通的。因为第 2 点已经说了,带参数的 remember() 监听不到内部状态的改变,所以还是需要使用 derivedStateOf():

@Composable
private fun ProcessedName(names: List<String>, onClick: () -> Unit) {
    val processedNames by remember { derivedStateOf { names.map { it.uppercase() } } }
    Column {
        processedNames.forEach {
            Text(it, Modifier.clickable(onClick = onClick))
        }
    }
}

但是这样并没有做到万无一失,因为当 names 传入不同的 List 对象时,derivedStateOf() 是感知不到的,此时需要将 names 作为 remember() 的参数才可以检测到 names 对象的变化,因此比较完善的写法是:

@Composable
private fun ProcessedName(names: List<String>, onClick: () -> Unit) {
    val processedNames by remember(names) { derivedStateOf { names.map { it.uppercase() } } }
    Column {
        processedNames.forEach {
            Text(it, Modifier.clickable(onClick = onClick))
        }
    }
}

再回头本节前面使用的官方文档中的例子,也是在 TodoList() 中使用了带参数的 remember() + derivedStateOf() 来保证对 List 类型参数的完整监听。

最后对比一下 remember() 与 derivedStateOf():

  • 带参数的 remember():可以判断对象的重新赋值,而 derivedStateOf() 不能完美做到,所以带参数的 remember() 适用于函数参数
  • derivedStateOf():适用于监听状态对象,通过 mutableListStateOf() 创建的对象,以及用 = mutableStateOf() 创建的对象都应该被 derivedStateOf() 监听

对于使用 by mutableStateOf() 所代理的对象,比如 var name by remember { mutableStateOf("xxx") },对 name 的重新赋值与状态改变是一回事,即用 = 赋值实际上就是在修改其状态。对于这种对象,使用带参数的 remember() 或 derivedStateOf() 监听都是可以的,这实际上是两种方式的作用重合区域,因此才引发初学者的迷惑,感觉这俩东西似乎一样。

作用上来讲,带参数的 remember() 用于判断对象的重新赋值,derivedStateOf() 用于判断状态内部变化。

使用上来讲,函数参数用带参数的 remember(),监听有内部状态的类型(一般是集合类型)用 derivedStateOf(),如果像 List<String> 作为函数参数这种情况就两个都用。

3、CompositionLocal

3.1 基本用法

CompositionLocal 按照字面意思可直译为 Composition 的局部变量。我们都知道,普通局部变量的作用域是其声明的代码块或函数:

setContent {
    val name = "Harden"
    User(name)
}

@Composable
fun User(nameParam: String) {
    Text(nameParam)
}

name 变量只在 setContent() 后的代码块内有效,并且不能穿透到代码块调用的 User 函数之内。穿透是指 User() 不能直接使用 name 变量,只能通过参数传递的形式间接访问:

setContent {
    val name = "Harden"
    User()
}

@Composable
fun User() {
    // 这里会报错的,User 函数的内部访问不到 name 变量
    Text(name)
}

Compose 提供的 CompositionLocal 可以帮助我们突破这个限制,让通过 CompositionLocal 创建的局部变量具备穿透函数的能力。具体的做法是,先用 compositionLocalOf() 创建一个变量:

// 大括号内是变量默认值,变量名要以 Local/local 开头
val LocalName = compositionLocalOf { "Default Name" }

Compose 对 CompositionLocal 创建的变量的命名规范:以 local 开头,如果是全局可用的变量则以 Local 开头。

然后在需要访问变量的位置,用 CompositionLocalProvider 提供一个访问 CompositionLocal 变量的作用域:

setContent {
    CompositionLocalProvider(LocalName provides "James") {
        User()
    }
}

CompositionLocalProvider() 内通过中缀函数 provides 指定 LocalName 的值为 James,在该函数内部被调用的函数可以直接通过 LocalName.current 取到在这个范围内 LocalName 内保存的值:

@Composable
fun User() {
    Text(LocalName.current)
}

运行程序,Text 会显示 James。

需要注意的是,CompositionLocal 的 current 属性只能在 Composable 函数中访问,因为 current 的 get() 被标记为 @Composable:

@Stable
sealed class CompositionLocal<T> constructor(defaultFactory: () -> T) {
    @Suppress("UNCHECKED_CAST")
    internal val defaultValueHolder = LazyValueHolder(defaultFactory)

    @Composable
    internal abstract fun provided(value: T): State<T>

    /**
     * 返回由最近的 [CompositionLocalProvider] 组件提供的值,该组件直接或间接调用了
     * 使用此属性的可组合函数。
     *
     * @sample androidx.compose.runtime.samples.consumeCompositionLocal
     */
    @OptIn(InternalComposeApi::class)
    inline val current: T
        @ReadOnlyComposable
        @Composable
        get() = currentComposer.consume(this)
}

可将 CompositionLocal 视为一个具备函数穿透功能的局部变量,也可看作可以隐式传递的函数参数。

3.2 用法细节

CompositionLocal 可以嵌套:

setContent {
    Column {
        CompositionLocalProvider(LocalBackground provides Color.Yellow) {
            TextWithBackground()
            CompositionLocalProvider(LocalBackground provides Color.Blue) {
                TextWithBackground()
                CompositionLocalProvider(LocalBackground provides Color.Green) {
                    TextWithBackground()
                }
            }
        }
        // 最外层 CompositionLocalProvider 之外的函数,使用的是 LocalBackground 的默认值
        TextWithBackground()
    }
}

@Composable
fun TextWithBackground() {
    Text(
        text = "有背景的文字",
        modifier = Modifier
        .background(LocalBackground.current)
        .clickable { themeBackground = Color.DarkGray }
    )
}

在最外层 CompositionLocalProvider 之外使用到 CompositionLocal 定义的变量,用到的就是默认值。如果默认值不知道该提供什么样的值,则可以通过抛异常的方式提前暴露该问题,因为这个时候取到的是一个没有意义的值。

CompositionLocalProvider() 可以提供多个 CompositionLocal 的值,中间用逗号隔开。

创建 CompositionLocal 可以使用 compositionLocalOf() 和 staticCompositionLocalOf(),二者的唯一区别是前者创建出的 CompositionLocal 会做使用跟踪,而后者创建出的则不会:

class MainActivity : ComponentActivity() {

    var themeBackground by mutableStateOf(Color.Blue)

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            Column {
                CompositionLocalProvider(LocalBackground provides Color.Yellow) {
                    TextWithBackground()
                    // themeBackground 是变量,当该变量发生变化时,后面大括号内会重组
                    CompositionLocalProvider(LocalBackground provides themeBackground) {
                        TextWithBackground()
                        CompositionLocalProvider(LocalBackground provides Color.Green) {
                            // 这个组件受 Color.Green 的这个 CompositionLocalProvider 控制,因此
                            // 蓝色背景的 CompositionLocalProvider 发生变化导致的重组不会波及到这里
                            TextWithBackground()
                        }
                    }
                }
                TextWithBackground()
            }
        }
    }

    @Composable
    fun TextWithBackground() {
        Text(
            text = "有背景的文字",
            modifier = Modifier
                .background(LocalBackground.current)
                .clickable { themeBackground = Color.DarkGray })
    }
}

val LocalBackground = compositionLocalOf { Color.Cyan }

LocalBackground 是 compositionLocalOf() 创建的,当它的值发生变化时,其作用域内会发生重组。也就是当点击 Text 时,蓝色背景文字会发生重组变化为深灰色背景。

compositionLocalOf() 实现这个跟踪功能实际上是在内部通过一个 mutableStateOf() 创建的变量实现的,用这个变量一直跟踪会有性能损耗。另一方面,staticCompositionLocalOf() 并没有跟踪机制,省去了变量带来的性能损耗,但是在其创建的 CompositionLocal 变量发生变化时,它会对作用域范围内的所有组件,包括内部嵌套的 CompositionLocalProvider 内的组件进行全量的刷新重组,这又带来了重组的性能损耗。

因此如果你创建的 CompositionLocal 如果经常改变,应该使用 compositionLocalOf(),毕竟一个变量的损耗要远远低于多次大范围重组所带来的损耗;反之如果 CompositionLocal 不经常变甚至不变,则应使用 staticCompositionLocalOf():

// 上下文一般很少变化,因此使用 staticCompositionLocalOf() 创建
val LocalContext = staticCompositionLocalOf<Context> {
    noLocalProvidedFor("LocalContext")
}

// 内容的颜色可能会经常变化,因此使用 compositionLocalOf() 创建
val LocalContentColor = compositionLocalOf { Color.Black }

CompositionLocal 一般用于表示上下文、环境、主题等内容,最好不要滥用。

参考资料:

derivedStateOf:将一个或多个状态对象转换为其他状态

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值