Jetpack Compose 笔记(1) - mutableStateOf

在 Compose 中显示数据,通常我们要将其存储在变量中,并用 mutableStateOf 包裹,以便实现自动监听与更新。这一步有下列三种写法:

1
2
3
val name = mutableStateOf("Bob")               // 1
val name by mutableStateOf("Bob")              // 2
val name by remember { mutableStateOf("Bob") } // 3

它们之间有什么区别?如何选择?这节先来解决最基本的问题 —— mutableStateOf() 究竟做了什么?

Compose 过程

在深挖之前,有必要先了解 Compose 从构建到显示的完整过程。

Compose 分为三个步骤:组合 (compose)、布局、绘制。前者是 Compose 独有的,后面两个与传统 View 类似。所谓「组合」,就是根据我们写的代码创建出实际的界面,组合的结果称为 Composition

插一句题外话,Compose 函数虽然看起来很像是创建一个对象,例如 Text(text = name.value) 很像创建一个 Text 对象,但实际上不是这样。跟踪源码可以发现,这只不过是一个普通的函数,它没有返回值,因此类似 Text().text = name.value 的写法完全错误 ❌ 这与传统 View 中 TextView().text = "xxx" 不是一回事。

—— 所以需要「组合」

如果还是无法理解,可以将「组合」类比传统 View 中 inflat,解析 xml 文件创建实际的对象。只不过曾经我们可以绕过 xml 手动创建 view,而现在不行了。

MutableState

最基本的用例大概是这样:

1
2
3
4
5
6
7
8
9
val name = mutableStateOf("Bob")
setContent {
    Column {
        Text(text = name.value)
        Button(onClick = { name.value = "2" }) {
            Text(text = "Change Name")
        }
    }
}

定义了一个 Text 和 Button,点击按钮,显示的文本就会改变。

跟踪源码轻易看出,mutableStateOf() 就是创建一个 ParcelableSnapshotMutableState 对象然后返回它。ParcelableSnapshotMutableState 本身并没有什么有价值的东西,主要是对 parcelable 的实现便于进程通信。真正储存数据的是它的父类 SnapshotMutableStateImpl

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {
    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 }
            }
        }

    private var next: StateStateRecord<T> = StateStateRecord(value)

    override val firstStateRecord: StateRecord
        get() = next
    // 省略其他的 ...
}

显然 value 就是保存实际数据的东西,可惜它依然不是最终实现。继续探索便遇到一个关键类 StateRecordStateStateRecord 是它的子类),要搞清楚它,首先要看看 SnapshotMutableStateImpl 所实现的两个接口。现在我们已经知道 mutableStateOf() 可以返回一个容器对象,它能够被订阅并且在内部值改变时通知订阅者,以此实现 UI 自动刷新。「被订阅」这种特性其实是 StateObject 接口提供的,很反直觉。

再次套娃🪆,StateObject 本身又是一个容器,以链表的形式存储着一串 StateRecord ⬅️ 这个真正保存了数据。为什么是一串?因为它不仅保存了变量的最新值,还保留了曾经的值,以便实现「撤销」等功能。既然是链表,那肯定存在第一个节点,就是 StateObject.firstStateRecord ,后继节点就是 StateRecord.next。注意这个 next 和 SnapshotMutableStateImpl.next 无关,恰好重名而已。事实上 SnapshotMutableStateImpl 里有一个链表保存着所有值(实现了 StateObject 接口呀),它的首个节点是 SnapshotMutableStateImpl.nextSnapshotMutableState

小结:StateObject 以链表形式真正储存了数据,StateRecord 是链表的节点,SnapshotMutableStateImpl 是一个实现。

get

get 操作调用 readable 取得 StateRecord 进而取得其内部包装的实际值返回。

readable() 有三个重载版本。每当取值的时候会调用单参数版本,内部调用双参数版本,最终调用三参数版本获取真正的值。

  • 单参数:双参数的便捷函数
  • 双参数:记录本次使用(订阅)
  • 三参数:遍历 StateRecord 链表,找到最新的、可用的 StateRecord

「最新」的好理解,「有效」后面再深入

双参数的函数是自动刷新 UI 的核心。确保每次取值的时候,这个动作都会被记录,后边值有变化的时候就可以更新了。

set

设置值时先套了一层 withCurrent,这个函数比较简单,就是调用三参数的 readable() 找到最新可用的 StateRecord,作为执行 lambda 表达式的参数:

1
2
3
4
5
6
next.withCurrent { it: StateStateRecord<T> ->
    // it 就是最新可用的 StateRecord
    if (!policy.equivalent(it.value, value)) {
        next.overwritable(this, it) { this.value = value }
    }
}

lambda 判断若新旧值不相等则执行赋值操作。与取值对应,我们猜测 overwritable 内实现了通知机制。具体的跟踪进去看看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
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)
    }
}

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
    if (candidate.snapshotId == id) return candidate

    // ② ---------------------------------------
    val newData = newOverwritableRecord(state, snapshot)
    newData.snapshotId = id
    snapshot.recordModified(state)
    return newData
}

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

这里面出现了新概念 Snapshot。之前说到,StateRecord 是一串,每个节点对应 Compose 不同状态下的值。一个变量对应一串 StateRecord。那么各串 StateRecord 哪些节点属于同一个状态呢?这就由 Snapshot 来记录。一个 StateRecord 只能对应一个 Snapshot,而一个 Snapshot 可以对应多个 StateRecord。多个 Snapshot 有先后关系,后者是建立在前者基础上的。举个例子:

如图,Record name 与 Snapshot1 对应。Snapshot2 基于 S1。虽然 S2 没有与 name 直接对应,但 S2 中如果取 name 的值,可以获取到 “bob”,这是因为 name 的 Snapshot1 对 Snapshot2 是 有效的

上面源码中 ① 部分就是判断,如果传入的 StateRecord 与 Snapshot 直接对应,那么就返回它。否则 ② 部分 就再搞一个 StateRecord 让它对应上,再返回。总是不管怎么样,必须返回一个对应上的就完事了。 具体的后面再研究。

搞清楚这个之后 overwritable() 第一部分就清晰了:把新值存到与当前 Snapshot 对应的 StateRecord 里。

also 块里顾名思义就是通知啦!它找到所有读取这个变量的地方,将它们标记为失效。那么下一帧这些地方就会重组 (recompose)。

订阅?通知?

看到这有同学可能发现了端倪。前面说 readable 是订阅,overwritable 是通知。但从代码看,两者都是对某个 Observer 调用了 invoke(),也就是说,这两个都是「通知」。WTF?

其实, snapshot.readObserver?.invoke(state) 既是通知,也是订阅。Compose 的订阅机制其实有两部分。

第一部分

第一部分是 Snapshot 中 StateObject 读和写事件的订阅。这两个事件的「通知」就是刚刚提到的两个 invoke 调用。而「订阅」是在 Snapshot 创建的时候发生的,本节没有分析到订阅的代码。

⚠️ 注意:writeObserver 只有在 compose 过程中发生修改,才会被通知,考虑下面的代码:

1
2
3
4
5
6
7
val name = mutableStateOf("Bob")
setContent {
    Box(Modifier.clickable { name.value = "2" }) {
        Text(name.value) // ①
        name.value = "1" // ②
    }
}

① 处发生了 name 的读事件,被记录。② 处发生了写事件,且处于 compose 过程中,此时 writeObserver 会被通知。而我们点击 Box 时,虽然也修改了 name,但这不是 compose 过程中,因此不会触发 write 通知。

虽然点击 Box 时不会通知,但是在「读」事件中已经订阅了「应用」事件(见下文)。UI 依然会刷新。

第二部分

第二部分是对每一个 StateObject「应用」事件的订阅。这个订阅是在「读」事件发生时进行的。所以 snapshot.readObserver?.invoke(state) 本身是通知,调用了「读」事件的观察者。而观察者内部所进行的操作,正是订阅「应用」事件。那么哪里通知呢?还没研究到嘿嘿。

说人话:我通知了你明天发年终奖,而你转手就打电话给苹果旗舰店,说 mbp 到货了立刻通知你。所以我的行为,既是一种通知,同时也引发了新的订阅。

所谓「应用」,就是让新值全局生效。可以类比成 sql 操作中事务的提交。

小结

SnapshotMutableStateImpl 中以链表形式储存了一串 StateRecord,每一个节点就是这个变量在某一状态时的值,与某个 Snapshot 对应。Snapshot 有先后关系,储存了某一状态下所有变量的值。

get 的过程中发生了「读」事件,监听器中又实现了对「应用」事件的订阅。

set 过程中发生了「写」事件,不过只有处于 compose 过程中这个事件才会真正触发,标记 UI 失效。

由于 Compose 存在两套订阅机制,因此既是不在 compose 过程中发生修改,UI 也会借助「应用」事件刷新。

by 关键字

上面已经解决掉了第一个问题,接下来看看把 = 换成 by 的作用是什么。

为了能自动刷新,我们把值进行了包装,这就导致每次读写时,不得不加上一个 .value 属性。同时哪怕是可以可变的值在声明时也可以写成 val 很有迷惑性。为解决这些问题,借助了 kotlin 原生的委托语法

val test by Test() 意思就是 test 这个变量的读与写都由右边的对象来具体实现。为此,右边对象必须有两个固定名称与参数的函数,作为读、写的实现:

1
2
3
4
5
6
7
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
    return "$thisRef, thank you for delegating '${property.name}' to me!"
}

operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
    println("$value has been assigned to '${property.name}' in $thisRef.")
}

跟踪进源码,可以清晰地看到这俩的实现:

1
2
3
4
5
inline operator fun <T> State<T>.getValue(thisObj: Any?, property: KProperty<*>): T = value

inline operator fun <T> MutableState<T>.setValue(thisObj: Any?, property: KProperty<*>, value: T) {
    this.value = value
}

如此一来,我们可以像使用原生变量那样对包装的对象进行赋值,而一个可变的变量也必须声明为 var

那么回答问题:如何选择使用?答:直接用 by 就行了!有简单的干嘛要找麻烦呢?

至于最后一种写法,下一节继续讨论。

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