vitaviva

V1

2022/05/09阅读:22主题:默认主题

一文看懂 Jetpack Compose 快照系统

1. 引言

Compose 通过名为“快照(Snapshot)”的系统支撑状态管理与重组机制的运行。快照作为一个底层设施,在我们的日常开发中很少直接接触,本文就为大家揭开快照的神秘面纱。我们在开头先抛出几个问题,希望在文章结束时大家能够找到答案,对快照也就算有了初步了解了。

  • 快照能做什么?
  • 快照与状态的关系?
  • 快照与线程的关系?
  • 快照与重组的关系?

注意:本文出现的源码基于版本 1.2.0-alpha06。本文重在帮助大家建立认知,对源码的介绍只是点到为止,请放松阅读。

我们知道 Compose 库从上到下分为多层:Material > UI > Runtime > Compiler 。快照系统位于 Runtime 层 androidx/compose/runtime/snapshots。 它自成体系,可以脱离 Compose UI 甚至 Compiler 单独使用,只依赖 Runtime 即可使用快照功能,本文出现的示例代码均可以不依赖 UI 运行。

implementation "androidx.compose.runtime:runtime:$compose_version"

2. 快照的基本操作

快照并非 Compose Runtime 的原创概念,它其实是一个 MVCC 系统的实现,MVCC 全称 Multiversion Concurrency Control (多版本并发控制),常用于数据库管理系统,实现事务并发,提升数据库性能,其模型与 Git 分支管理系统也有点类似,因此我们可以类比数据库的事务或者 Git 的分支来理解快照机制。

快照的创建

先看下面的例子:

fun test() {
    // 创建状态(主线开发)
    val state = mutableStateOf(1)

    // 创建快照(开分支)
    val snapshot = Snapshot.takeSnapshot()

    // 修改状态(主线修改状态)
    state.value = 2

    println(state.value) // 打印1

    snapshot.enter {//进入快照(切换分支)
        // 读取快照状态(分支状态)
        println(state.value) // 打印1 
    }

    // 读取状态(主线状态)
    println(state.value) // 打印2

    // 废弃快照(删除分支)
    snapshot.dispose()
}

例子中展示了快照的基本功能:隔离访问Snapshot.takeSnapshot() 创建了一个快照,通过调用其 enter() 进入此快照。在快照上只能看到快照被创建时刻的最新状态,看不到此后的变化。

将快照类比成 Git 系统,程序默认处于 GlobalSnapshot 全局快照中,这相当于 Git 的 Main 分支。从全局快照上创建并进入子快照,就如同在 Main 上创建并切换分支,分支代码保持分支创建时的状态,看不到主线或其他分支的修改。当然 Git 的隔离对象是代码,而快照的隔离对象是“状态”,也就是 mutableStateOf 创建的一个 StateObject 实例。

使用下面这些方法都可以创建 StateObject 对象,它们都可以被快照隔离:

  • mutableStateOf/MutableState
  • mutableStateListOf/SnapshotStateList
  • mutableStateMapOf/SnapshotStateMap
  • derivedStateOf
  • rememberUpdatedState
  • collect*AsState

快照的修改 & 提交

上面的例子中 enter() 内只是读取了快照状态,如果我们试图更新状态则会抛出异常。takeSnapshot() 创建的是一个只读快照,不允许对状态有写操作。如果需要更新状态,需要使用 takeMutableSnapshot() 创建可写的快照:

// 创建可写的快照
val snapshot = Snapshot.takeMutableSnapshot()

snapshot.enter {
    // 对快照状态进行变更
    state.value = 2

    println(state.value) // 打印2
}

// snaphot之外看不到对快照状态的修改。
println(state.value) // 打印1

如上,我们对状态的修改同样会被快照隔离。快照中的状态修改只对当前快照可见,在快照之外看不到,如果我们希望快照的修改通知到全局,可以使用 apply 提交这个修改。类比到 Git 就好似通过 merge 将分支合并回了主线。

snapshot.enter {
    // ...
}

// 提交snapshot中的状态修改
snapshot.apply()

// 快照外可以看到snapshot中的修改
println(state.value) // 打印2

我们还可以使用 withMutableSnapshot 简化代码,它可以在“切换回主线”时自动提交变更

Snapshot.withMutableSnapshot {
    state.value = 2
}

println(state.value) // 打印2

注意:git merge 可以在任意分支之间进行合并,而快照的 apply 永远是从当前快照提交到“父快照”。快照上允许嵌套创建快照,因此快照存在父子关系。

3. 访问隔离的实现原理

前面介绍了快照的基本功能是对状态的访问隔离。Compose 状态本质上是一个 StateObject 实例,为什么在不同快照下访问同一个 StateObject 实例,却能读取到不同结果呢?研究源码后会发现,与其说是快照隔离了状态,倒不如说是状态关联了快照

状态关联快照

StateObject 内部维护了一个 StateRecord 链表。

所有快照在创建时都会被赋予一个全局递增的 id,即 SnapshotId,StateObject 被写入的状态值会关联当前快照的 snapshotId ,然后保存在 StateRecord 中。当我们在不同快照下访问 StateObject 时,通过遍历 SatateRecord 链表只能看到当前快照允许看到的值

可见,Compose 的 State 天生支持在快照中访问,所以 Compose 的状态也经常被称为快照状态( Snapshot State),快照状态通过 snapshotId 实现“多版本并发控制”的目的。

管理 SnapshotId

那么“当前快照允许看到的值”是如何确定的呢?到这里大家应该很容易想到,其实就是比较访问中的 StateRecord 与当前快照的 snapshotId 。当我们在快照上读取 StateObject 时,会走到 Snapshot.kt 的 readable 中 :

//androidx/compose/runtime/snapshots/Snapshot.kt

//遍历链表,根据 snapshotId 返回符合当前快照读取条件的 StateRecord
private fun <T : StateRecord> readable(r: T, id: Int, invalid: SnapshotIdSet): T? {
    var current: StateRecord? = r
    var candidate: StateRecord? = null
    //while 循环中遍历链表
    while (current != null) {
        //valid 方法检查 StateRecord 是否符合条件
        if (valid(current, id, invalid)) {
            // 符合条件且 snapshotId 最大的 StateRecord 作为结果返回。
            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
}

/**
  * 检查 StateRecord 是否可以被读取:
  * 1. StateRecord#snapshotId != INVALID_SNAPSHOT。
  * 2. StateRecord#snapshotId 不大于当前快照 id。
  * 3. StateRecord#snapshotId 不在 invalid 集合中
*/

private fun valid(currentSnapshot: Int, candidateSnapshot: Int, invalid: SnapshotIdSet)Boolean {
    return candidateSnapshot != INVALID_SNAPSHOT && candidateSnapshot <= currentSnapshot &&
        !invalid.get(candidateSnapshot)
}

代码很清晰,如大家所料,这里通过 snapshotId 的比较来决定 StateRecord 是否可读。因为快照被赋予了全局自增 id,理论上小于当前 snapshotId 的状态值是快照创建前被写入的,所以应该对当前快照可见。我们注意到除了 snapshotId 的比较之外,还要求 StateRecord#snapshotId 不能位于 invalid 集合中。

//androidx/compose/runtime/snapshots/Snapshot.kt

open class MutableSnapshot internal constructor(
    id: Int// 快照id
    invalid: SnapshotIdSet, //快照黑名单
    override val readObserver: ((Any) -> Unit)?, // 读回调,后文介绍
    override val writeObserver: ((Any) -> Unit)? // 写回调,后文介绍
) : Snapshot(id, invalid) 

MutableSnapshot 的定义如上,其中 invalid 成员代表一个快照黑名单。处于黑名单中的 id,即使比当前快照 id 小,也视为不可见内容。我们前面介绍过快照的提交,在子快照未提交之前,即使它的 id 小于全局快照也不应该被全局看见,因此在正式提交前之前会被加入全局快照的这个黑名单。

创建/提交快照时的 id 变化如上图所示:

  1. 我们在 GlobalSnapshot 中创建子快照,id 赋值为 2
  2. 为了让子快照中访问不到父快照后续的状态变化,子快照创建后 GlobalSnapshot 的 id 升级至 3
  3. 为了让 GlobalSnapshot 看不到子快照的状态变化,将 2 加入 invalid
  4. 子快照提交后,GlobalSnapshot 的 invalid 中移除 2,子快照状态全局可见。

上面过程中出现了 id 升级的概念,可见快照提交的本质就是通过升级父快照 id 让子快照状态全局可见。这与 git merge 之后移动分支的 head 位置也有着异曲同工之处。

4. 状态读写感知

快照系统除了对状态的读写进行隔离,还可以对状态的读写进行感知,前面 MutableSnapshot 的定义中看到 readObserverwriteObserver 成员,它们就是快照上对状态进行读写操作时的回调。

val state = mutableStateOf(1)

// 监听状态读操作
val readObserver: (Any) -> Unit = { readState ->
    if (readState == state) {
        println("readObserver: $readState"// 打印 2
    }
}
// 监听状态写操作
val writeObserver: (Any) -> Unit = { writtenState ->
    if (writtenState == state) {
        println("writeObserver: $writtenState"// 打印 2
    }
}

val snapshot = Snapshot.takeMutableSnapshot(
    readObserver = readObserver,
    writeObserver = writeObserver
)

snapshot.enter {
    // 写操作,触发 writeObserver 回调
    state.value = 2 

    // 读操作,触发 readObserver 回调
    val value = state.value 

    println(value) // 打印 2
}
snapshot.apply()

snapshot.dispose()

上面代码中,我们在创建快照时传入读写回调,快照中读写状态时依次触发回调,因此上面代码的日志输出如下:

writeObserver: 2
readObserver: 2
2

快照对状态读写的感知是 Compose 状态更新后自动触发重组的基础,我们在后文会详细介绍。

5. 全局快照

我们知道 GlobalSnapshot 是程序所处的默认快照,它也是所有快照的 Root。由于不再存在父快照,所以全局快照上对状态的修改不需要追加提交操作(apply),作为 Root 它更重要的职责是“被提交”。全局快照上的状态变化通常是通过子快照的提交发生的,就如同 Main 上的代码变动大多来自各分支的 MR 。

监听全局状态变化

子快照上的状态修改最终会通过 apply 提交到父快照。registerApplyObserver 可以监听子快照提交后的状态变化。Compose 组合阶段的代码都执行在子快照上,所以组合阶段的状态变化都可以通过 ApplyObserver 获取。

提示: Composae 渲染分有三个阶段:组合,布局,绘制,文中提到的组合就是其中第一个阶段 https://developer.android.google.cn/jetpack/compose/phases

有些状态变化发生在组合阶段之外,比如 onClick 或者一个异步请求的返回都可能触发状态变化,组合之外的代码不执行在子快照,因此它们会直接在全局快照上修改状态。全局快照上没有 apply 操作,但是我们通过主动调用 Snapshot.sendApplyNotifications() 同样可以向 ApplyObserver 发送通知获知全局状态的修改。sendApplyNotifications 通过升级全局快照 id 来确定需要通知哪些状态的变化,即自上次升级 id 以来的所有状态

ApplyObserver 的通知可能来自子快照的提交,也可能来自 sendApplyNotifications 的直接调用,但用途都是为了监听全局状态的变化。

下面的例子展示了 sendApplyNotifications 的使用效果

val state = mutableStateOf(1)

Snapshot.registerApplyObserver { set, _ ->
    // 将响应 sendApplyNotifications 的调用

    // 获取有变更的状态
    println("$set"// [MutableState(value=3)]
}

state.value = 2
state.value = 3 // 向 ApplyObserver 通知最后一次变化

// 通知变化
Snapshot.sendApplyNotifications()

除了使用 ApplyObserver 监听全局变化,我们还可以监听全局快照上对单个状态的写操作,由于全局快照不使用 takeSnapshot 创建,无法通过传入 writeObserver 注册回调,全局快照的写回调通过使用 Snapshot.registerGlobalWriteObserver 注册:

val state = mutableStateOf(1)

val observer = Snapshot.registerGlobalWriteObserver { writtenState ->
    // MutableState(value=2) 和 MutableState(value=3) 都会收到
    println("$writtenState"
}

state.value = 2
state.value = 3

observer.dispose()

每次状态修改都可以通过 registerGlobalWriteObserver 监听。注意全局快照不提供读操作的回调注册,因为 Compose 只会在组合阶段追踪对状态的读取,所以在子快照监听足以。

非 Compose 中使用快照

文章开头就提到,Compose 快照系统可以脱离 Compose UI 单独使用。下面的例子中,我们通过监听全局快照的状态,实现基于 View 的状态管理。

class MainActivity : AppCompatActivity() {

    private lateinit var binding: ActivityMainBinding

    private var counter by mutableStateOf(0)

    private val observer = Snapshot.registerGlobalWriteObserver {
        Snapshot.sendApplyNotifications()
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivityMainBinding.inflate(layoutInflater)
        setContentView(binding.root)

        lifecycleScope.launch {
            snapshotFlow {
                // 将 Counter 的变化更新至 TextView
                binding.textCounter.text = "$counter"
            }.collect()
        }

        binding.buttonIncrement.setOnClickListener {
            counter++
        }
        binding.buttonDecrement.setOnClickListener {
            counter--
        }
    }

    override fun onDestroy() {
        super.onDestroy()
        observer.dispose()
    }
}

snapshotFlow 是 Compose 提供的状态管理 API ,可以监听全局快照的状态变化并转化为 Flow 发送出去。具体实现我们就不看了,只需要知道它内部通过 ApplyObserver 观察状态变化,因此我们通过 registerGlobalWriteObserver 监听到状态修改后,通过 sendApplyNotifications 发送通知。

这段代码同时也揭示了 Compose 的 State 可以像 RxJava/LiveData/Flow 那样成为一种通用的响应式工具,而且还可以省掉冗余的 subscribe/observe/collect 代码,snapshotFlow { } 中会自动追踪所有被读取的状态,当它们发生变化时,block 会触发执行,响应式逻辑更加简洁。

6. 并发与冲突解决

前面的例子都是跑在单线程中的,而作为一个 MVCC 系统,只有在并发场景中使用才更有意义。通常并发环境下对数据访问,为了保证线程安全需要添加各种读写锁,而快照系统通过访问隔离实现无锁操作,提高并发性能。此外快照的提交机制也保证了容错性,进一步套用数据库事务的说法就是保证了 ACID 中的原子性、隔离性和一致性

多线程下的快照保存

当快照在多线程环境下使用时,当前快照信息保存在 ThreadLocal 中的。Compose 在组合执行过程中,通过 currentSnapshot() 获取当前快照

//androidx.compose.runtime.SnapshotThreadLocal

//如果不存在当前快照,则返回全局快照
internal fun currentSnapshot(): Snapshot =
    threadSnapshot.get() ?: currentGlobalSnapshot.get()
    
private val threadSnapshot = SnapshotThreadLocal<Snapshot>()

//使用 ThreadLocal 管理快照
internal actual class SnapshotThreadLocal<T{
    private val map = AtomicReference<ThreadMap>(emptyThreadMap)
    private val writeMutex = Any()

    @Suppress("UNCHECKED_CAST")
    actual fun get(): T? = map.get().get(Thread.currentThread().id) as T?

    actual fun set(value: T?) {
        val key = Thread.currentThread().id
        synchronized(writeMutex) {
            val current = map.get()
            if (current.trySet(key, value)) return
            map.set(current.newWith(key, value))
        }
    }
}

单线程中同时只有一个快照处于活动中,活动中的快照通过 SnapshotThreadLocal 保存在 ThreadLocal 中,Compose 在组合阶段通过 currentSnapshot() 可以获取当前线程的活动快照。活动快照 dispose 后从 ThreadLocal 移走,之前非活动的快照进入活动状态。 从 Snapshot#enter 方法的实现可知,进入快照的本质就是将快照存入 SnapshotThreadLocal

inline fun <T> enter(block: () -> T): T {
    val previous = makeCurrent()
    try {
        return block()
    } finally {
        restoreCurrent(previous)
    }
}

internal open fun makeCurrent(): Snapshot? {
    val previous = threadSnapshot.get()
    threadSnapshot.set(this)
    return previous
}

mergeRecords 解决冲突

并发环境必然要考虑冲突的发生。当我们在子线程快照中修改了某 StateObject,同时它在父快照中也发生了变化,那么当提交子快照时就会遇到冲突,此时就要像 git merge 冲突一样,要么放弃提交,要么对冲突进行解决。记得前面 StateObject 的类图中曾经出现了一个 mergeRecords 方法,StateObject 就是用它来处理状态冲突的:

//androidx/compose/runtime/SnapshotState.kt

override fun mergeRecords(
    previous: StateRecord// 子快照创建之前的全局状态
    current: StateRecord// 全局快照最新状态
    applied: StateRecord // 待提交的子快照状态
)
: StateRecord? {
    val previousRecord = previous as StateStateRecord<T>
    val currentRecord = current as StateStateRecord<T>
    val appliedRecord = applied as StateStateRecord<T>
    //父快照与待提交子快照的状态比较
    return if (policy.equivalent(currentRecord.value, appliedRecord.value))
        current
    else {//如果状态不相等,进行merge操作
        val merged = policy.merge(
            previousRecord.value,
            currentRecord.value,
            appliedRecord.value
        )
        if (merged != null) {//merge成功则返回merge结果
            appliedRecord.create().also {
                (it as StateStateRecord<T>).value = merged
            }
        } else {
            null
        }
    }
}

当子快照提交时,对全局快照的 previouscurrent 会进行比较,如果不相等则意味着本次提交有冲突的可能,此时会通过 mergeRecords 解决冲突,进入上面的代码。逻辑很清晰,重点是对 policy 的两个方法调用,equivalent 用来比较 currentapplied,如果不相等则调用 merge 进行合并操作,解决冲突。

policy 是一个 SnapshotMutationPolicy 对象,代表快照冲突时的解决策略,我们使用 mutableStateOf 创建状态时可以传入自定义 Policy,Compose 也提供了三个默认 Policy,它们的区别主要是 equivalent 的不同:

  • structuralEqualityPolicy:结构化比较,即通过 == 比较状态值是否相等,这也是 SnapshotState 目前默认的策略
  • referentialEqualityPolicy – 引用比较,通过 === 比较,只有同一实例才相等
  • neverEqualPolicy :永远判定为不相等

以上无论哪种 Policy 在 merge 的默认实现上都一样,即不合并,状态提交失败。因为 merge 本身属于业务范畴,很难给出默认实现,需要开发者根据需要自己实现。

注意:当我们更新 StateObject 时,需要判断是否发生变化以决定是否应该重组,这个判断也是使用 SnapshotMutationPolicy#equivalent 完成的。

7. 如何支持 Compose 重组?

前面讲的那么多,基本都是围绕快照系统自身的工作原理在做介绍,甚至展示了快照在非 Compose 场景的使用。那么回归 Compose 的主题,快照是如何对 Compose UI 提供帮助的呢?快照对于 Compose UI 的最主要意义是支持了重组机制的运行,这得益于也正是得益于前文介绍过的两个特点:读写感知 & 读写隔离

读写感知:标记 RecomposeScope

我们知道 Compose 通过状态变化驱动重组进而完成 UI 的刷新,而且 Compose 的重组是“智能的”,遵循范围最小化原则。每个返回 Unit@Composable 函数(或 lambda)都是一个 RecomposeScope,Scope 会追踪内部访问的状态,当状态发生变化时该 Scope 会参与重组,如果状态无变化则会跳过重组。这整个过程正是依靠快照读写感知的机制实现的。

Compose 通过调用 Recomposer#composing 方法完成组合。

//androidx.compose.runtime.Recomposer
private inline fun <T> composing(
    composition: ControlledComposition,
    modifiedValues: IdentityArraySet<Any>?,
    block: () -> T
)
: T {
    //创建快照
    val snapshot = Snapshot.takeMutableSnapshot(
        readObserverOf(composition), writeObserverOf(composition, modifiedValues)
    )
    try {
        // 进入快照
        return snapshot.enter(block)
    } finally {
        applyAndCheck(snapshot)
    }
}

可以看到,组合开始时先创建了一个可变快照,并调用 readObserverOfwriteObserverOf 创建状态读写回调传入传入快照。接着调用 enter 进入快照执行组合阶段的 Composable 函数,所以 Composalbe 在快照上的状态读写都会被监听到。

Composable 中读取状态时触发回调,最终调用到 recordReadOf,将修改的 StateObject 连同 currentRecomposeScope 一并注册到 observationsobservations 记录了哪些 Scope 访问了哪些 State。

 override fun recordReadOf(value: Any) {
        if (!areChildrenComposing) {
            composer.currentRecomposeScope?.let {
                it.used = true
                observations.add(value, it)
                ...
            }
        }
    }

当 Composable 对状态进行写入时调用 recordWriteOf 方法,从 observations 中找到关联的 Scope 标记为 invalid。

 override fun recordWriteOf(value: Any) = synchronized(lock) {
        invalidateScopeOfLocked(value)

        derivedStates.forEachScopeOf(value) {
            invalidateScopeOfLocked(it)
        }
    }

  private fun invalidateScopeOfLocked(value: Any) {
          observations.forEachScopeOf(value) { scope ->
            if (scope.invalidateForResult(value) == InvalidationResult.IMMINENT) {
                observationsProcessed.add(value, scope)
            }
        }
    }

在下次帧信号到达时,invalid 的 scope 会在重组中执行,基于最新状态完成组合,同时重复上述过程,设置监听感知状态的下一次变化。

全局快照上的状态修改发生在组合阶段以外,但同样可以确定 RecomposeScope,这是通过前面讲 registerApplyObserver 实现的。当全局快照中发生状态写操作时,GlobalSnapshotManager 会发送 SendApplyNotification

//androidx.compose.runtime.Recomposer#recompositionRunner
val unregisterApplyObserver = Snapshot.registerApplyObserver { changed, _ ->
    synchronized(stateLock) {
        if (_state.value >= State.Idle) {
            snapshotInvalidations += changed
            deriveStateLocked()
        } else null
    }?.resume(Unit)
}

如上,RecomposerApplyObserver 中获得变化的状态 changed,然后调用 deriveStateLocked() 方法,最终也会执行 invalidateForResult 找到 changed 关联的 Scope 并标记为 invalid。

读写隔离:支持重组并行化

官方文档告诉我们重组是并行的:

Compose can optimize recomposition by running composable functions in parallel. This lets Compose take advantage of multiple cores.

但截至目前重组仍然跑在单线程上,并行化还在开发中,但是依托快照系统并行化重组随时可能开启,所以我们现在就需要带着并行的意识开发自己的代码,避免届时出现 Bug。重组的并行化得益于快照的隔离机制,重组在执行过程中,不会受到其它线程对状态修改的影响,杜绝并发异常的发生。

结合下面的时序图,我们梳理一下 Compose 重组的整个过程,看看快照在其中是如何发挥作用的。假定场景是在 onClick 中修改了某个状态,且并行化已启动。如前文所述,onClick 的状态修改发生在全局快照

注意:图中的箭头并非源码中真实的方法调用,只表示一个依赖关系

  1. 全局快照的状态变化会通过 sendApplyNotifications 通知出来
  2. Recomposer 接收到变化的状态,在下一帧到来之前将需要重组的 Scope 标记为 invalid
  3. 当帧信号达到时,Recomposer 查找 invalid 的 Scope,获取空闲子线程并创建快照,在快照上执行 Scope 代码
  4. Scope 代码执行中如果读取了某状态,则作为状态的观察者记录到 observations
  5. Scope 内部如果对某状态进行了修改,则从 observations 查找观察者状态,标记为 invalid。
  6. Scope 执行结束后,如果期间状态有修改,则通过快照提交,将状态变化同步给全局。
  7. 全局状态变化通过 ApplyObserver 回调 Recomposer,然后重复过程 2。

8. 回顾&总结

以上就是快照的基本工作原理以及其支持重组的整个过程。最后让我们回顾一下本文开头的几个问题,巩固所学的内容:

  • 快照能做什么? Compose 快照是一个可以感知状态读写的 MVCC 系统,它主要功能是隔离和感知状态的变化。
  • 快照与状态的关系? 快照隔离和感知的对象是状态,状态通过 snapshotId 与快照建立关联,实现访问隔离。
  • 快照与线程的关系? 快照可以在单线程下运行,但是它更适合在并发环境下使用,快照帮助多线程任务实现线程安全
  • 快照与重组的关系? Compose 的重组借助快照实现了并发执行,同时通过快照的读写感知确定参与下次重组的范围。

参考

  1. https://dev.to/zachklipp/introduction-to-the-compose-snapshot-system-19cn
  2. https://juejin.cn/post/6972692477505437733
  3. https://juejin.cn/post/6974974061466091556
  4. https://juejin.cn/post/6964185100971950093
  5. https://blog.chrnie.com/2021/10/10/Jetpack-Compose-%E6%BA%90%E7%A0%81%E8%A7%A3%E6%9E%90%E4%B9%8B-Snapshot/

分类:

移动端开发

标签:

Android

作者介绍

vitaviva
V1