Compose 实践与探索二 —— 状态订阅与自动更新

这一篇文章开始,我们正式进入 Compose 各个知识点的系统学习,首先登场的是状态与重组。

目前使用的 Compose 版本是 1.3.0 - alpha01,如无特殊说明,就默认使用的是该版本代码。当然,随着内容的深入,使用的代码版本也会有一定的更新,届时会做出显著声明。

1、自定义 Composable

在进入正式内容先先声明两点:

  1. 通常,我们会把添加了 @Composable 注解的函数简称为 Composable 或 Composable 函数,中文也会称为可组合函数,后续使用相关称呼时不再做额外解释
  2. Compose 作为一个严格的声明式 UI 框架,开发者是拿不到组件本身对象的。它不像 View 体系作为命令式 UI 可以也必须要拿到组件本身,如 TextView、ImageView 这些组件。而在 Compose 中,我们经常会把 Text()、Image() 直接称为 Text 组件、Image 组件,实际上是不严谨的。Composable 函数本身并不是界面的元素,而是用于生成界面元素的,实际的组件对象开发者拿不到也无需拿到。但在后续的论述中,有时为了叙述方便,可能会直接把组件 Composable 函数直接称为某个组件,比如 Text 组件

1.1 自定义 Composable 的场景与原因

好了,下面进入正式内容,先考虑何时需要自定义 Composable?

很简单,写页面内容时就会用到。就像传统的 View 体系需要在 XML 内使用各种组件写 Activity 的布局一样,Compose 在定义页面内容时,也会用到各种 Compose 组件,比如 Text、Button、Box 等等。由于这些组件都是 Composable 函数,而 Compose 固定了 Composable 函数只能在另一个 Composable 函数中调用,所以我们在使用这些组件描述页面内容时,就必须根据页面内容自定义一个 Composable 函数。

那么问题又来了,为什么所有组件都要加 @Composable 注解才可以使用?

这是因为 Compose 需要通过 Compose 的编译器插件(Compose Compiler Plugin)在组件 Composable 函数中增加一些参数,这些参数在 UI 进行重组时,能起到提升重组效率,优化性能的作用。由于所有组件函数都需要添加这些参数以优化重组,每个组件函数都让开发者添加这些参数会造成不好的开发体验,因此 Compose 就让编译器插件来做这件事。但插入参数总要有个标准,不能对所有的函数都添加这些参数吧?因此,Compose 就让组件函数都加上 @Composable 注解,这样编译器插件就能识别出应该对哪些函数添加参数以让它们参与到重组的优化之中。

简言之,@Composable 注解起到一个识别符的作用,帮助编译器正确识别那些需要被插入参数的函数。

上面这种做法实际上是一种面向切面编程(AOP)的应用,通常 AOP 会借助以下两者之一:

  1. Annotation Processor
  2. 修改字节码(字节码插桩)

Compose 没有使用以上两种技术方案,主要是因为 Compose 要跨平台,而上述两种技术方案都是针对 JVM 的,在其他平台上无效。其次,编译器插件的功能要比 Annotation Processor 更强大。

1.2 Composable 函数的调用

自定义的 Composable 不是在任何位置都能被调用的,它只能在另一个 Composable 函数中被调用。通常,作为容器组件的 Composable 函数会让最后一个参数存放开发者自定义的 Composable,并为该参数加上 @Composable 注解以提供调用环境:

@Composable
inline fun Box(
    modifier: Modifier = Modifier,
    contentAlignment: Alignment = Alignment.TopStart,
    propagateMinConstraints: Boolean = false,
    content: @Composable BoxScope.() -> Unit
)

Box 的 content 参数就是用来存放开发者自定义的 Composable 内容的,这个函数参数加了 @Composable 使得它可以接收一个 Composable 函数。我们在 ComponentActivity 的 onCreate() 内调用的 setContent() 也是类似的情况:

public fun ComponentActivity.setContent(
    parent: CompositionContext? = null,
    content: @Composable () -> Unit
)

setContent() 的 content 参数接收的就是开发者定义的整个 Activity 页面的 Composable 函数,在内部执行 content 函数的内容时,会将其强转为一个 Function2 类型的函数 invokeComposable() 并执行它以显示界面内容。

最后,作为知识扩展,我们先简单提一下,Compose 的编译器插件究竟为 Composable 函数插入了什么参数,以及为什么 setContent() 的 content 在执行时会被转换为 Function2 类型。实际上两个问题是有关联的。

一般情况下,编译器插件会为 Composable 函数插入两个参数:

  1. composer: Composer:主要作用有三条:
    • 跟踪当前 Composable 的位置状态,维护一个内部的“槽表(Slot Table)”
    • 记录组件的结构信息(如组件类型、参数值等),用于在重组时快速比对和更新
    • 管理状态记忆(Remember)副作用(Side Effects)(如 LaunchedEffect)。
  2. changed: Int:主要作用有两条:
    • 一个位掩码(bitmask),标记哪些参数在重组过程中发生了变化
    • 帮助运行时跳过未变化的参数,优化性能,避免不必要的计算

那么对于一个只有一个参数的 Composable 函数 Greeting 而言:

@Composable
fun Greeting(name: String) {
    Text(text = "Hello, $name!")
}

在被编译器插入两个参数后会变成大致如下的样子:

fun Greeting(
    name: String,
    $composer: Composer<*>,   // 自动注入的 composer
    $changed: Int             // 自动注入的 changed
) {
    val $dirty = $changed
    if ($dirty or composer.changed(name)) {
        // 参数变化时触发重组
        Text(text = "Hello, $name!", ...)
    }
}

那么对于 setContent() 的 content 参数而言,它是一个无参的 Composable 函数,在经过编辑器插件注入参数后,就有了两个参数,对应的函数类型就是 Function2。

2、Compose 中的状态

2.1 什么是状态

Compose 官方文档认为,状态指可以随时间变化的任何值。这是一个非常宽泛的定义,从 Room 数据库到类的变量,全部涵盖在内。下面是 Android 应用中的一些状态示例:

  • 在无法建立网络连接时显示的信息提示控件。
  • 博文和相关评论。
  • 在用户点击按钮时播放的涟漪效果。
  • 用户可以在图片上绘制的贴纸。

总结就是,UI 内部不断变化的数据,就是 UI 的状态。

接下来把状态放到 Compose 的使用场景中。我们说,Compose 是一个声明式 UI 框架,当组件显示的状态发生变化时,新的状态会自动刷新到 UI 上,无需开发者手动干预。那么,像 Int、Float、String 这些普通类型的数据,它们作为组件依赖的状态,在发生变化时,会导致 UI 自动刷新吗?

答案是不会。因为数据变化导致 UI 刷新这个过程中,势必存在着数据被 UI 监听,被监听的数据变化时,监听者拿着新的数据去更新 UI 这样一个过程。因此,状态绝不会是普通的数据类型,而应该是一种可以被监听的监听者。

Compose 的 State 接口用于描述 Compose 中的状态,它把真正的数据封装为 value 属性:

/**
 * 一个值容器,当在 [Composable] 函数执行期间读取其 [value] 属性时,
 * 当前的 [RecomposeScope] 将自动订阅该值的变更。
 * 
 * 该机制用于实现 Compose 的响应式状态追踪。
 */
@Stable
interface State<out T> {
    val value: T
}

通常使用 mutableStateOf() 来创建 State 的子接口 MutableState 的实现对象:

val name = mutableStateOf("James")

访问 value 属性即可拿到 name 内保存的 String:

Text(name.value)

2.2 状态的自动订阅

我们将状态对象 MutableState 放到 Composable 函数后,并没有显式的执行订阅操作,组件就可以在 MutableState 内的数据发生变化时就触发自动刷新,这是因为 Compose 框架帮我们完成了状态的自动订阅。

本节我们就来看 Compose 是如何实现状态的自动订阅的。首先我们要看 mutableStateOf() 创建 MutableState 的过程:

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

createSnapshotMutableState() 返回的是 MutableState 的子接口 SnapshotMutableState 的实现类的子类对象 ParcelableSnapshotMutableState:

internal actual fun <T> createSnapshotMutableState(
    value: T,
    policy: SnapshotMutationPolicy<T>
): SnapshotMutableState<T> = ParcelableSnapshotMutableState(value, policy)

ParcelableSnapshotMutableState 继承了 SnapshotMutableStateImpl 也实现了 Parcelable,对 MutableState 内 value 属性的实现就来自于 SnapshotMutableStateImpl:

/**
 * 一个被 Compose 观察读写操作的单一值容器。
 *
 * 对该值的写入操作将作为 [快照(Snapshot)] 系统的一部分进行事务处理。
 *
 * @param value 被包装的原始值
 * @param policy 控制可变快照中变更处理方式的策略
 *
 * @see mutableStateOf          // 创建可变状态的工厂方法
 * @see SnapshotMutationPolicy  // 快照变更策略接口
 */
internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
    // value 属性的实现
    @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 }
            }
        }
    
    // StateRecord 链表结构与头节点
    private var next: StateStateRecord<T> = StateStateRecord(value)
    override val firstStateRecord: StateRecord
        get() = next
}

这部分我们要关注 StateRecord 链表以及 value 的 getter 和 setter。

StateRecord 与 StateObject

上面贴出的 SnapshotMutableStateImpl 实现了 StateObject 接口:

/**
 * 所有具备快照感知能力的状态对象均需实现的接口。本模块通过此接口维护状态对象的状态记录。
 */
@JvmDefaultWithCompatibility
interface StateObject {
    /**
     * 状态记录链表中的首个节点
     */
    val firstStateRecord: StateRecord

    /**
     * 将新状态记录添加至链表头部。调用后[firstStateRecord]应为[value]
     */
    fun prependStateRecord(value: StateRecord)

    /**
     * 基于冲突的状态变更生成合并后的状态
     * 此方法禁止修改任何传入的状态记录,即使对于[applied]记录也应视为不可变对象
     */
    fun mergeRecords(
        previous: StateRecord,
        current: StateRecord,
        applied: StateRecord
    ): StateRecord? = null
}

StateObject 内定义了一个 StateRecord 链表 firstStateRecord,StateRecord 是一个表示状态对象的快照局部值的抽象类,它本身就是一个链表结构:

/**
 * 状态对象的快照局部值
 */
abstract class StateRecord {
    /**
     * 创建此记录时所属快照的ID
     */
    internal var snapshotId: Int = currentSnapshot().id

    /**
     * 指向下一个状态记录的引用。状态记录以链表形式存储
     */
    internal var next: StateRecord? = null

    /**
     * 从同类型状态记录复制值到当前记录
     */
    abstract fun assign(value: StateRecord)

    /**
     * 为同一状态对象创建新记录
     */
    abstract fun create(): StateRecord
}

它的子类 StateStateRecord 刚好是 SnapshotMutableStateImpl 的嵌套类:

	private var next: StateStateRecord<T> = StateStateRecord(value)
    override val firstStateRecord: StateRecord
        get() = next

	private class StateStateRecord<T>(myValue: T) : StateRecord() {
        override fun assign(value: StateRecord) {
            @Suppress("UNCHECKED_CAST")
            this.value = (value as StateStateRecord<T>).value
        }

        override fun create(): StateRecord = StateStateRecord(value)

        var value: T = myValue
    }

StateRecord 类是 Jetpack Compose 状态管理系统的核心组件,直接服务于 Compose 的事务功能,主要用于实现多版本状态管理快照隔离机制

Compose 的事务操作包含原子性提交、嵌套事务、冲突自动合并、状态隔离以及延迟应用变更等内容。像原子提交就是指一组状态要么全部生效,要么全部不生效,可能会涉及到快照内生成的新纪录无法生效需要回滚到原始链表的操作。既然涉及到回滚,那么 Compose 就不仅需要保存变量的新值,还需要保存旧值以便回滚。所以,Compose 在管理变量时会保存同一个变量的多个新旧值,这就是需要用链表来保存一个状态的原因了(用于维护不同快照下的状态历史版本)。

本节最后我们来思考一个问题,是哪一个接口或类为 MutableState 提供了被订阅的功能?

先来捋一捋 MutableState 对象的真实类型:

  • mutableStateOf() 调用 createSnapshotMutableState() 这个简便函数得到的是一个 ParcelableSnapshotMutableState
  • ParcelableSnapshotMutableState 继承了 SnapshotMutableStateImpl 且实现了 Parcelable,这一步肯定是选 SnapshotMutableStateImpl 了
  • SnapshotMutableStateImpl 实现了 StateObject 和 SnapshotMutableState 两个接口

乍一看,MutableState 是可变状态,那可被订阅的特性应该是它底层的 SnapshotMutableState 吧?可是点进去一看:

interface SnapshotMutableState<T> : MutableState<T> {
    val policy: SnapshotMutationPolicy<T>
}

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

interface State<out T> {
    val value: T
}

这一系列接口都没有提供出可订阅的功能,而反观 StateObject 提供状态版本管理,并且提供了真正实现订阅的快照系统的接入能力,使订阅机制能够生效。

getter

value 的 get() 做的是一个订阅并取值返回的动作:

override var value: T
    get() = next.readable(this).value

readable() 是以 StateRecord 为上限的类型的扩展函数,它返回的是所有可用 StateRecord 对象中最新的那一个:

/**
* 返回当前快照的当前可读状态记录。假定[this]是[state]的第一个记录。
*/
fun <T : StateRecord> T.readable(state: StateObject): T {
    // 返回当前线程的活动快照,如果没有则使用全局快照
    val snapshot = Snapshot.current
    // 触发读取观察者,用于跟踪状态依赖关系
    snapshot.readObserver?.invoke(state)
    // 两次调用三个参数 readable() 拿到可用且最新的 StateRecord,首次尝试无锁快速读取,如读取
    // 失败才会在同步块中进行二次读取,避免脏读。这种双重检查机制平衡了性能与线程安全,确保始终
    // 返回正确的快照版本。
    return readable(this, snapshot.id, snapshot.invalid) ?: sync {
        // 当全局快照已被另一个线程推进,并且在该线程暂停期间写入对象的状态被覆盖时,可读状态可能
        // 返回 null。在这里重复读取是有效的,因为要么这将返回与上一次调用相同的结果,要么将找到
        // 一个有效记录。在 sync 块中阻止其他线程写入该状态对象,直到读取完成。
        val syncSnapshot = Snapshot.current
        readable(this, syncSnapshot.id, syncSnapshot.invalid)
    } ?: readError()
}

前两行代码是拿到一个 Snapshot 对象并执行该 Snapshot 的 readObserver 回调函数:

sealed class Snapshot(
    id: Int,
    /**
     * A set of all the snapshots that should be treated as invalid.
     */
    internal open var invalid: SnapshotIdSet
) {
	/*
     * The read observer for the snapshot if there is one.
     */
    internal abstract val readObserver: ((Any) -> Unit)?

    /**
     * The write observer for the snapshot if there is one.
     */
    internal abstract val writeObserver: ((Any) -> Unit)?
}

readObserver 是重组阶段的依赖跟踪器(如 Recomposer),记录被读取的状态对象,这样如果后面 state 发生变化需要重组时,就能找到它被读取的位置,对该位置进行更新。

最后会执行两次 readable():

// invalid 是已失效的快照 ID 集合(需排除)
private fun <T : StateRecord> readable(r: T, id: Int, invalid: SnapshotIdSet): T? {
    // The readable record is the valid record with the highest snapshotId
    var current: StateRecord? = r
    var candidate: StateRecord? = null
    while (current != null) {
        if (valid(current, id, invalid)) {
            candidate = if (candidate == null) current
            else if (candidate.snapshotId < current.snapshotId) current else candidate
        }
        current = current.next
    }
    if (candidate != null) {
        @Suppress("UNCHECKED_CAST")
        return candidate as T
    }
    return null
}

readable() 会从传入的参数 r 开始向后遍历 StateRecord 链表,找到合法的 StateRecord 中 snapshotId 最大的,也就是最新的那一个作为返回结果。

总结一下流程,当外界访问 MutableState 的 value 时会调用其 get() 进而执行到 readable()。readable() 在返回可用且最新的 StateRecord 前,记录下这个 value 所属的 state 被访问/使用过,完成自动订阅,这样一旦 state 发生变化时,可以将其标记为失效,在下一帧画面要刷新时会进行重组(Recompose)。

setter

再看 setter 的实现:

override var value: T
    set(value) = next.withCurrent { // it: StateStateRecord<T>
        if (!policy.equivalent(it.value, value)) {
            next.overwritable(this, it) { this.value = value }
        }
    }

withCurrent() 会直接执行它的尾随 lambda 函数,并传入 current() 作为参数:

inline fun <T : StateRecord, R> T.withCurrent(block: (r: T) -> R): R =
    block(current(this))

@PublishedApi
internal fun <T : StateRecord> current(r: T) =
    Snapshot.current.let { snapshot ->
        // 三个参数的 readable(),只取值,不订阅
        readable(r, snapshot.id, snapshot.invalid) ?: sync {
            Snapshot.current.let { syncSnapshot ->
                readable(r, syncSnapshot.id, syncSnapshot.invalid)
            }
        } ?: readError()
    }

current() 也是对 readable() 进行二次读取,拿到一个可用且最新的 StateRecord,这样 withCurrent() 尾随 lambda 内部的逻辑就是判断刚刚获取的 StateRecord 的 value 与 set() 的参数 value 是否相等,如果不等,说明要给当前的 MutableState 对象赋新值了,这时才执行 overwritable():

/**
 * 对给定记录执行可覆盖写入操作的代码块。假设该操作用于状态对象的首条记录。
 * 仅当记录的所有字段将被完整覆盖时使用(例如记录仅含单个字段且该字段将被写入)
 *
 * 警告:若调用者未覆盖状态记录的所有字段,将导致对象状态不一致,未写入的字段几乎必然错误。
 * 若[block]可能不会写入所有字段,请改用[writable]方法
 *
 * @param state 包含此记录的状态对象
 * @param candidate 通过[withCurrent]获取的当前快照候选记录
 * @param block 将修改记录所有字段的代码块
 */
internal inline fun <T : StateRecord, R> T.overwritable(
    state: StateObject,
    candidate: T,
    block: T.() -> R
): R {
    var snapshot: Snapshot = snapshotInitializer
    return sync {
        // 获取当前激活的快照
        snapshot = Snapshot.current
        // 获取可覆盖记录并执行操作
        this.overwritableRecord(state, snapshot, candidate).block()
    }.also {
        // 触发写操作通知(如重组观察)
        notifyWrite(snapshot, state)
    }
}

这一步主要看 overwritableRecord():

internal fun <T : StateRecord> T.overwritableRecord(
    state: StateObject,
    snapshot: Snapshot,
    candidate: T
): T {
    if (snapshot.readOnly) {
        // If the snapshot is read-only, use the snapshot recordModified to report it.
        snapshot.recordModified(state)
    }
    val id = snapshot.id

    // 1.如果 candidate 的 snapshotId 与 snapshot 的 id 匹配,就返回 candidate
    if (candidate.snapshotId == id) return candidate

    // 2.如果上一步没返回,说明 id 不匹配,就生成一个新的 StateRecord 并使用 id
    val newData = newOverwritableRecord(state)
    newData.snapshotId = id

    snapshot.recordModified(state)

    return newData
}

完成记录覆盖后,还需要调用 overwritable() 进行写操作的通知:

@PublishedApi
internal fun notifyWrite(snapshot: Snapshot, state: StateObject) {
    snapshot.writeObserver?.invoke(state)
}

writeObserver 也是一个回调函数,会去查找这个 state 在哪里被读取了,然后去读取它的位置将状态标记为失效,这样重组时就可对该位置进行刷新。

订阅过程总结

实际上,上面这一套订阅工作并不完整,Compose 使用了两套订阅机制同时工作才能完成订阅。两个订阅过程如下:

  1. 对 SnapShot 中读写 StateObject 对象的订阅,分别订阅读和写,所以有两个接收者:readObserver 和 writeObserver。发生时间:订阅是在 SnapShot 创建时,通知是在读和写的时候
  2. 对每一个 StateObject 的应用做订阅。发生时间:订阅发生在第一个订阅的 readObserver 被调用(通知)的时候;通知发生在 StateObject 新值被应用的时候

第一个过程就是我们上面一路看下来的流程,第二个过程后续到合适的章节会讲。

2.3 Snapshot 简述

快照系统是比较复杂的知识,我们不打算在系列的开篇部分就详细地去剖析它,但是状态的订阅涉及到了 Snapshot,为了更好地理解上面的过程,我们简单说说 Snapshot。

从上面的源码分析过程我们知道了,当我们修改 MutableState 的 value 时,StateRecord 会把修改前后的新旧值都存起来串成一个链表,链表上的各个节点都对应了某一时刻 Compose 的整个内部状态。而具体各个链表上的哪些节点属于同一个状态,它也有记录,这个记录就是 SnapShot。

SnapShot 的功能就是对 Compose 内的各个变量做快照。SnapShot 记录整个系统的状态,可以对应多个 StateRecord,而一个 StateRecord 只对应一个 SnapShot。

有了快照功能后,就可以在某些变量值发生变化的时候,不必马上将这个变化应用到界面上,而是在跑完整个流程之后,把所有发生变化的变量一起应用。直接拿着最终结果去进行接下来的布局和绘制,这样性能会好一些。SnapShot 对这种批量应用改变提供了底层的技术可行性支持。

当然,SnapShot 不止有这一个作用,它还是 Compose 支持多线程同步对界面进行计算的下层技术支持:

  1. 系统有多个 SnapShot 时,它们是有先后关系的
  2. 同一个 StateObject 的每个 StateRecord 都有它们对应的 SnapShot 的 id。StateRecord 和 SnapShot 就算不直接对应,只要 StateRecord 的 SnapShot 对另一个是有效的,另一个就能取到这个 StateRecord

2.4 状态机制的背后

在前面的章节中,我们讲了什么是状态,以及通常使用 mutableStateOf() 来创建一个状态对象,现在我们深入一点,看个示例:

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

        var nums by mutableStateOf(mutableListOf(1, 2, 3))

        setContent {
            Column {
                Button(onClick = { nums.add(nums.last() + 1) }) {
                    Text(text = "加 1")
                }

                for (num in nums) {
                    Text(text = "第 $num 块文字")
                }
            }
        }
    }

预期结果是点击按钮后 Column 展示新增的 Text,但实际运行却发现点击按钮毫无作用。这是因为 add() 不是赋值操作,因此 nums 对象没有发生改变,发生改变的是列表里的内容。由于没有触发 MutableState 的 setValue(),因此不会重组。

一个直白的解决方法是创建新的 List 对象赋值给 nums,这样可以触发重组:

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

        var nums by mutableStateOf(mutableListOf(1, 2, 3))

        setContent {
            Column {
                Button(onClick = {
                    // 为 nums 列表重新赋值以触发重组
                    nums = nums.toMutableList().apply {
                        add(nums.last() + 1)
                    }
                }) {
                    Text(text = "加 1")
                }

                for (num in nums) {
                    Text(text = "第 $num 块文字")
                }
            }
        }
    }

这种方式可以达到重组目的,但是不够优雅,可以直接使用 Compose 提供的 mutableListStateOf():

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

        // 这里用 = 而不是用 by,因为它没有扩展的 getValue 和 setValue
        val nums = mutableStateListOf(1, 2, 3)

        setContent {
            Column {
                Button(onClick = {
                    nums.add(nums.last() + 1)
                }) {
                    Text(text = "加 1")
                }

                for (num in nums) {
                    Text(text = "第 $num 块文字")
                }
            }
        }
    }

与之类似的还有 mutableStateMapOf()。

3、重组作用域与 remember()

重组作用域(Recomposition Scope)是 Compose 运行时用于精确控制 UI 更新范围的核心机制。它定义了当状态变化时,哪些部分的 Composable 函数需要重新执行以更新 UI。

重组作用域实际上是 Compose 编译器生成的智能标记,为每个 Composable 函数生成作用域标识符,通过代码插桩实现细粒度更新控制:

// 编译前代码
@Composable
fun MyButton(text: String) {
    Button(onClick = {}) {
        Text(text)
    }
}

// 编译后伪代码
fun MyButton(text: String, $composer: Composer, $changed: Int) {
    // 编译器注入作用域标识
    $composer.startRestartGroup(123)
    
    // 检查参数变化
    if (changed.parameterTextChanged(text)) {
        // 标记需要重组
        $composer.setParameterChanged(0)
    }
    
    // 重组逻辑
    if ($composer.shouldSkipCurrentGroup()) {
        return
    }
    
    Button(...)
    $composer.endRestartGroup()?.updateScope { ... }
}

Compose 会根据代码结构与状态访问自动划分重组作用域:

划分依据示例重组范围
独立的 @Composable 函数Column { Item1(); Item2() }每个 Item 独立作用域
控制流语句(if/for)if (condition) { Text("A") } else { Text("B") }条件分支各自独立
状态访问的位置val count by remember { mutableStateOf(0) } 在函数内部 vs 参数传入作用域随状态位置变化

现在我们以 Composable 函数为例,看看在重组作用域内进行重组时会遇到哪些问题:

@Composable
fun RecomposeScopeSample() {
    var name by mutableStateOf("Jetpack") 
    Text(name)
    // 启动协程在 3s 后将 name 变为 Compose
    LaunchedEffect(Unit) {
        delay(3000)
        name = "Compose"
        println("name has been changed to $name")
    }
}

RecomposeScopeSample() 想通过启动一个协程,在 3s 后修改 name 状态的值,触发重组让 Text 展示的内容由 Jetpack 变为 Compose。初学时乍一看似乎没什么问题,但运行起来发现虽然协程内的 log 已经打印了 name has been changed to Compose,但 Text 仍然显示 Jetpack。

这是因为重组时,会重新执行重组作用域,也就是 RecomposeScopeSample() 的内容。第一句就会重新对 name 做初始化赋值为 Jetpack 然后由 Text 显示,即便上一次运行该函数时已经在协程中改变了 name 的值,但重组时又对 name 进行初始化把已经改变的值覆盖了。

这时候注意到在 mutableStateOf() 下有红线提示:Creating a state object during composition without using remember,意思是在组合过程中没有使用 remember 创建状态对象,因此使用 remember() 把 mutableStateOf() 包起来:

@Composable
fun RecomposeScopeSample() {
    var name by remember { mutableStateOf("Jetpack") }
    Text(name)
    // 启动协程在 3s 后将 name 变为 Compose
    LaunchedEffect(Unit) {
        delay(3000)
        name = "Compose"
        println("name has been changed to $name")
    }
}

再次运行,发现 3s 后 Text 的显示内容确实由 Jetpack 变为 Compose 了。

remember() 是一个 Composable 函数,可以防止由于重组导致的预期之外的某些变量的反复初始化,进而带来意外的结果:

/**
* 记住 [calculation] 产生的值,[calculation] 只在组合过程中进行计算,重组时只会返回组合期间产生的值
*/
@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T =
    currentComposer.cache(false, calculation)

@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: @DisallowComposableCalls () -> T): T {
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
        // invalid 为 false 或者 rememberedValue() 为 Empty 时才执行 block 并记住且返回
        // block 的结果,否则就返回 rememberedValue() 已经记住的值
        if (invalid || it === Composer.Empty) {
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

可以看到 remember() 可以保证状态不会被重复初始化。此外,remember() 还有可以传入 key 值的版本:

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

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

// 还有 3 个 key 以及 vararg keys 的版本...

只有在传入的 key 发生变化时,才会重新计算 calculation 参数内的值。像第一个介绍的没有 key 值的 remember(),它就永远不会重新计算 calculation。

4、状态管理

4.1 无状态与状态提升

Compose 官方称其是无状态的(Stateless),无状态是指组件的 Composable 函数内不保持任何状态。

比如说 TextView 保存的文字内容就是一个状态,你可以通过 getText() 与 setText() 获取与设置文字。但在 Compose 中,组件没有状态,也就是其内部不会保存这些数据,在将数据设置到 UI 上之后,它们就被“扔掉了”。

但需要注意的是,无状态作为 Compose 的一个特点,它是允许组件无状态,而不是说组件绝对没有状态。比如:

@Composable
fun Hello() {
    var text = "Hello"
    Text(text)
}

text 变量存在于 Hello() 中,因此 Hello 是有状态的,而 Text 内部没有保存任何变量,因此 Text 是无状态的。

通过上面这个例子可以看出,Compose 组件是内部无状态,但是状态可以(以参数的形式)存在于外部。当调用方不需要控制状态,并且不必自行管理状态便可使用状态的情况下,“有状态”会非常有用。但是,具有内部状态的可组合项往往不易重复使用,也更难测试

那么,Compose 组件如何从外部获取状态呢?可以分为两种情况:

  • 对于无状态组件,由于组件内部无状态,而这个状态是存在于外部的(就好比 Hello 内的 Text 是无状态的,但是它的状态 text 是在外部提供的),因此可以直接在外部获取这个状态
  • 对于有状态组件,由于状态在函数内是一个局部变量,从语言角度上说,你无法在一个函数的外部获取到该函数的局部变量,因此你可以效仿无状态组件,将状态提出到函数之外,将有状态组件变成无状态组件,然后像无状态组件那样获取状态

以 Hello 为例,它内部的状态 text 可以提出到外部,在外部获取这个状态:

setContent {
    var text = "Hello"
    Hello(text)
    // 获取状态,也可以修改
    text = "HaHaHa"
}

// 状态提出去之后,作为参数接收这个状态
@Composable
fun Hello(value: String) {
    Text(value)
}

Hello 在提取状态之后,从有状态组件变成了无状态组件。这种将状态从子组件移动到父组件,以便在整个组件层次结构中共享和管理状态的模式叫做状态提升(State Hoisting)。状态提升是实现无状态的一种简单方法。

状态提升有一个原则:尽量不往上提。因为状态提的越高,能访问该状态的范围就越广,代码出错的概率就越高。因此状态要尽可能地往下放。

对于可以互动的组件,除了提取状态之外,还需要提取交互的函数到外部:

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }
	// name 是要显示的当前值,onNameChange 是请求更改值的事件
    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

像 HelloContent 就是使用了 Compose 常规的状态提升模式,把状态变量替换成两个参数:

  • value: T:要显示的当前值
  • onValueChange: (T) -> Unit:请求更改值的事件,其中 T 是建议的新值

4.2 单项数据流

当应用内的数据来源有多个渠道时,如何安排这些数据呢?

比如一个新闻应用,通过网络 + 本地数据库两种方式来展示数据:

  • 第一次打开应用时,本地数据库是空的,从网络获取新闻列表,显示到 UI 上并存到数据库中
  • 滑动到底部加载下一页数据,取到新的数据后合并到内存后显示到 UI 上,同时也要存入数据库中
  • 杀死应用后重新打开,可以从本地数据库加载数据显示,同时从网络获取数据,获取之后合并到内存后显示,仍需要保存到数据库

第三种情况涉及到数据有效性的问题。通常数据库读取数据要比网络请求数据要快很多,因此先读取数据库的数据显示到 UI 上,然后等拿到网络请求的数据之后再显示网络数据。但是也有极端情况,可能网络数据比读取数据库的速度快,那么可能会出现数据库中较老的数据覆盖了网络请求的较新数据。

像这种双数据来源都需要解决这种数据有效性或数据同步性的问题。较为常用的解决方式是采用单数据来源,让多个数据源串行合并为单个数据源。比如这里我们就可以让网络数据源作为本地数据库的上游,即网络数据先存入数据库,数据库再为 UI 提供单一的数据来源,即单一信息源(Single Source of Truth)。

单一信息源是 Compose 官方建议使用的,当然这种模式在 Compose 之前的 Jetpack 开始就已经被 Google 官方推荐了(ViewModel 的 Repository 也有数据库和网络两个数据源,官方也是建议让网络数据存入数据库,数据库再为 UI 提供数据这种方式)。Compose 建议所有界面中会用到的数据都采用这种形式。

单向数据流(Unidirectional Data Flow)怎么用?把 Composable 函数做好封装,做状态提升时提的完整一点。如果有用户交互,在提状态时应该把与之相关的用户交互也往上提,即把用户事件做成函数类型以函数参数的形式暴露出来,把这个用户事件也交给上层来调用。

对于 Compose 而言,实现了单向数据流,也就实现了单一信息源。

参考资料:

状态管理

Compose 编程思想 - 重组

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值