深入浅出 JetPack Compose UI 自动更新原理

翻译至之前发掘到的 Compose 相关不错的文章,会省略一些我认为不太重要的部分,有英语基础的同学还是建议阅读原文哈,附上原文地址:medium.com/@takahirom/...

为什么需要学习 Compose 的底层原理?

我想学习底层原理的主要原因是我对 Jetpack Compose 是如何运作的非常感兴趣。相对于传统的 View 系统,JetPack Compose 写起来相当神奇。具体来说,Composable 函数不会有返回值;它会自动更新。

当然,考虑到它会是未来 Android UI 开发主流框架,我相信探究它的底层原理绝不是在浪费时间。并且如后面正文描述那样,我发现 Jetpack Compose 的实现非常有意思以至于我忍不住想要与你们分享。

我们如何学习?

我会展示一个简单的 Compose 代码片段并解释它如何工作来加深你对其中原理的认知。

这篇文章的使用例子是一段简单的 Compose UI 树代码,如下所示

scss 复制代码
@Composable
fun Content() {
    // 3秒后 state 变为 false
    var state by remember { mutableStateOf(true) }
    LaunchedEffect(Unit) {
        delay(3000)
        state = false
    }
    //当 state 由 true 变为 false 时,Node1()会消失
    if (state) {
        Node1()
    }
    Node2()
}

所有的代码都在这个仓库里,如果你看不懂文章下面的介绍,你也可以选择直接看代码,只有130行,应该不会很难。

github.com/takahirom/s...

UI 树如下所示,并且 Node1 将会3秒后消失。

ini 复制代码
RootNode
├── Node1(value=node1)
└── Node2(value=node2)

3秒后UI树变成这样↓

ini 复制代码
RootNode
└── Node2(value=node2)

让我们看看 Compose 是如何实现上面的功能的。

首先,我们需要构造一棵上述的树,我们先简单地定义一个 Node 类。这里,我们定义了 Node 类,和它的三个子类: RootNode, Node1, Node2. 上面的几个类不会继承任何 Compose 框架里的类。

kotlin 复制代码
sealed class Node {
    val children = mutableListOf<Node>()

    class RootNode : Node() {
        override fun toString(): String {
            return rootNodeToString()
        }
    }

    data class Node1(
        var name: String = "",
    ) : Node()

    data class Node2(
        var name: String = "",
    ) : Node()
}

我们要如何让 Compose 使用上面的类来构造我们想要的树呢?

Compose 框架里有个 Applier 类,就是用来处理 UI 树的,其中的 UI 节点增删改查,都是由它来进行。

kotlin 复制代码
class NodeApplier(node: Node) : AbstractApplier<Node>(node) {
...
    override fun insertTopDown(index: Int, instance: Node) {
        // Now add a child node!
        current.children.add(index, instance)
    }

    override fun move(from: Int, to: Int, count: Int) {
        current.children.move(from, to, count)
    }

    override fun remove(index: Int, count: Int) {
        current.children.remove(index, count)
    }
}

为了让 Applier 添加我们自定义的 Node 类,我们要让 Compose 去管理 Node.

通过使用 ReusableComposeNode, 我们可以在 Compose 内部里添加 Node 类。在构造 ReusableComposeNode 的时候,需要我们传入两个 lmbda 参数,我们由参数名就可以理解到它们各自的作用:其中 factory 用于构造自定义类, update 用于当参数改变的时候发生的回调。

kotlin 复制代码
@Composable
private fun Node1(name: String = "node1") {
    ReusableComposeNode<Node.Node1, NodeApplier>(
        factory = {
            Node.Node1()
        },
        update = {
            set(name) { this.name = it }
        },
    )
}

@Composable
private fun Node2(name: String = "node2") {
    ReusableComposeNode<Node.Node2, NodeApplier>(
        factory = {
            Node.Node2()
        },
        update = {
            set(name) { this.name = it }
        },
    )
}

有了上面的代码我们就可以让 Compose 帮我们构造出上面的自定义 Node 类了,你可能需要等其他的解释完了才能理解这部分代码。

scss 复制代码
fun runApp() {
    val composer = Recomposer(Dispatchers.Main)

    GlobalSnapshotManager.ensureStarted()
    val mainScope = MainScope()
    mainScope.launch(DefaultChoreographerFrameClock) {
        composer.runRecomposeAndApplyChanges()
    }

    val rootNode = Node.RootNode()
    Composition(NodeApplier(rootNode), composer).apply {
        setContent {
            Content()
        }
    }
}

这篇文章的总览图

图片实在太大了,直接贴上来被压得妈都不认得,原图在地址在这里,个人觉得可以先阅读完整篇文章后再看图来作为回顾:github.com/takahirom/i...

之前提及的示例代码在运行前后会发生一些事情,梳理出这些应该可以帮助你理解 Compose 的内部机制:

在编译时

  1. 为了创建 SlotTable, Compose 的 Kotlin 编译插件会改写 Composable 函数

在运行时

  1. 调用 Composable 函数并在 SlotTable 中储存一些信息

  2. 3秒后改变 MutableState

  3. Snapshot 系统监听到改变

  4. 重组

  5. 将发生的改变映射到 SlotTable(内部改变时使用 GapBuffer 算法)

下面我们一步一步细说

第0步:为了创建 SlotTable, Compose 的 Kotlin 编译插件会改写 Composable 函数

Compose 既然能在参数改变的时候进行更新,那它一定是储存了一些信息用于判断 Composable 参数是否改变了。Compose 框架中的 SlotTable(后面会细说) 正是担任这样的角色,这些参数信息是随着 Composable 函数储存在 SlotTable 中的。

我们的代码在编译成 Android 运行的应用时,Kotlin 代码会被转化为 Java 字节码,然后这些 Java 字节码再被转化为虚拟机字节码。而 Compose 看起来重写了 Kotlin IR 编译器。

因此我们可以把我们的 Kotlin 代码编译出来的字节码反编译,我们就能知道 Compose 的编译器插件做了什么事情。

现在就让我们看看反编译后的 Content() 函数。

  • 你会注意到代码中多了 startRestartGroup(), endRestartGroup(), startReplaceableGroup(), endReplaceableGroup() 这几个不是我们写的方法。 Compose 框架内部有组的概念,并且允许我们使用组来创建 UI 树。

  • 把成组的代码以某种方式保存使得可以在需要的时候被重复执行

  • 你也可以看到代码里还有看起来像是跳过执行的方法

php 复制代码
   @Composable
   public static final void Content(@Nullable Composer $composer, final int $changed) {
      // ↓↓↓↓RestartGroup↓↓↓↓ 
      $composer = $composer.startRestartGroup(-337788314);
      ComposerKt.sourceInformation($composer, "C(Content)");
      if ($changed == 0 && $composer.getSkipping()) {
         $composer.skipToGroupEnd();
      } else {
... LaunchedEffect and MutableState related code
         $composer.startReplaceableGroup(-337788167);
         if (Content$lambda-2(state$delegate)) {
            Node1((String)null, $composer, 0, 1);
         }

         $composer.endReplaceableGroup();
         Node2((String)null, $composer, 0, 1);
      }

      ScopeUpdateScope var18 = $composer.endRestartGroup();
      // ↑↑↑↑RestartGroup↑↑↑↑
      // ↓↓↓↓保存上面的组代码使得可以在被需要的时候重复执行↓↓↓↓ 
      if (var18 != null) {
         var18.updateScope((Function2)(new Function2() {
            public final void invoke(@Nullable Composer $composer, int $force) {
               MainKt.Content($composer, $changed | 1);
            }
         }));
      }
      // ↑↑↑↑保存上面的组代码使得可以在被需要的时候重复执行↑↑↑↑

   }


   @Composable
   private static final void Node1(final String name, Composer $composer, final int $changed, final int var3) {
      $composer = $composer.startRestartGroup(1815931657);
// ...
      ScopeUpdateScope var10 = $composer.endRestartGroup();
      if (var10 != null) {
         var10.updateScope((Function2)(new Function2() {
            public final void invoke(@Nullable Composer $composer, int $force) {
               MainKt.Node1(name, $composer, $changed | 1, var3);
            }
         }));
      }

   }

第1步:调用 Composable 函数并且把关键信息储存至 SlotTable

现在,运行APP,示例代码中的 Content() 函数会被执行。

在这个时候, Compose 会把数据储存至 SlotTable. 在这篇文章里我们不会花太多篇幅在这步上,因为我们主要关心 UI 自动更新的部分。

关于 SlotTable

SlotTable 内部包含两个数据结构,都是用于储存 UI 树中的组信息。

sql 复制代码
groups: IntArray
slots: Array<Any?>

这个名为 groups 的整型数组为每个组储存了5个元素,因此 groups 的大小是组数量的5倍。有了这个信息后我们也可以通过下面的代码来窥探 groups 里储存的数据:

kotlin 复制代码
groups.toList().windowed(
    size = 5,
    step = 5,
    partialWindows = false
)
    .forEachIndexed { index, group ->
        val (key, groupInfo, parentAnchor, size, dataAnchor) = group
        println("index: $index, " +
                "key: $key, " +
                "groupInfo: $groupInfo, " +
                "parentAnchor: $parentAnchor, " +
                "size: $size, " +
                "dataAnchor: $dataAnchor")
    }

看起来 groups 储存的数据是属于 slots 的信息。

组数据里储存着对应 slots 的下标,在上面名为 dataAnchor 的变量中。组数据里也储存着对应父组的 slots 的下标,在上面名为 parentAnchor 的变量中。

groups 中的信息:

yaml 复制代码
index: 0, key: 100, groupInfo: 2, parentAnchor: -1, size: 16, dataAnchor: 0
index: 1, key: 1000, groupInfo: 2, parentAnchor: 0, size: 15, dataAnchor: 1
index: 2, key: 200, groupInfo: 536870914, parentAnchor: 1, size: 14, dataAnchor: 1
index: 3, key: -985533309, groupInfo: 2, parentAnchor: 2, size: 13, dataAnchor: 2
index: 4, key: -337788314, groupInfo: 268435458, parentAnchor: 3, size: 12, dataAnchor: 4
index: 5, key: -3687241, groupInfo: 268435456, parentAnchor: 4, size: 1, dataAnchor: 6
index: 6, key: -3686930, groupInfo: 268435456, parentAnchor: 4, size: 1, dataAnchor: 8
index: 7, key: 1036442245, groupInfo: 268435456, parentAnchor: 4, size: 2, dataAnchor: 11
index: 8, key: -3686930, groupInfo: 268435456, parentAnchor: 7, size: 1, dataAnchor: 12
index: 9, key: -337788167, groupInfo: 1, parentAnchor: 4, size: 4, dataAnchor: 15
index: 10, key: 1815931657, groupInfo: 1, parentAnchor: 9, size: 3, dataAnchor: 15
index: 11, key: 1546164276, groupInfo: 268435457, parentAnchor: 10, size: 2, dataAnchor: 16
index: 12, key: 125, groupInfo: 1073741824, parentAnchor: 11, size: 1, dataAnchor: 17
index: 13, key: 1815931930, groupInfo: 1, parentAnchor: 4, size: 3, dataAnchor: 19
index: 14, key: 1546164276, groupInfo: 268435457, parentAnchor: 13, size: 2, dataAnchor: 20
index: 15, key: 125, groupInfo: 1073741824, parentAnchor: 14, size: 1, dataAnchor: 21
index: 16, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 17, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 18, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 19, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 20, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 21, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 22, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 23, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 24, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 25, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 26, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 27, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 28, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 29, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 30, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0
index: 31, key: 0, groupInfo: 0, parentAnchor: 0, size: 0, dataAnchor: 0

Slots 中的信息:

kotlin 复制代码
0: {}(class androidx.compose.runtime.external.kotlinx.collections.immutable.implementations.immutableMap.PersistentHashMap)
1: OpaqueKey(key=provider)(class androidx.compose.runtime.OpaqueKey)
2: androidx.compose.runtime.RecomposeScopeImpl@4fb4ae6(class androidx.compose.runtime.RecomposeScopeImpl)
3: androidx.compose.runtime.internal.ComposableLambdaImpl@3b52827(class androidx.compose.runtime.internal.ComposableLambdaImpl)
4: C(Content)(class java.lang.String)
5: androidx.compose.runtime.RecomposeScopeImpl@b882ad4(class androidx.compose.runtime.RecomposeScopeImpl)
6: C(remember):Composables.kt#9igjgp(class java.lang.String)
7: MutableState(value=true)@167707773(class androidx.compose.runtime.ParcelableSnapshotMutableState)
8: C(remember)P(1):Composables.kt#9igjgp(class java.lang.String)
9: MutableState(value=true)@167707773(class androidx.compose.runtime.ParcelableSnapshotMutableState)
10: Function2<kotlinx.coroutines.CoroutineScope, kotlin.coroutines.Continuation<? super kotlin.Unit>, java.lang.Object>(class com.github.takahirom.compose.MainKt$Content$1$1)
11: C(LaunchedEffect)P(1)336@14101L58:Effects.kt#9igjgp(class java.lang.String)
12: C(remember)P(1):Composables.kt#9igjgp(class java.lang.String)
13: kotlin.Unit(class kotlin.Unit)
14: androidx.compose.runtime.LaunchedEffectImpl@8d3f428(class androidx.compose.runtime.LaunchedEffectImpl)
15: androidx.compose.runtime.RecomposeScopeImpl@7421fc3(class androidx.compose.runtime.RecomposeScopeImpl)
16: C(ReusableComposeNode):Composables.kt#9igjgp(class java.lang.String)
17: Node1(name=node1)(class com.github.takahirom.compose.Node$Node1)
18: node1(class java.lang.String)
19: androidx.compose.runtime.RecomposeScopeImpl@81cf51f(class androidx.compose.runtime.RecomposeScopeImpl)
20: C(ReusableComposeNode):Composables.kt#9igjgp(class java.lang.String)
21: Node2(name=node2)(class com.github.takahirom.compose.Node$Node2)
22: node2(class java.lang.String)
23: null(null)
24: null(null)
25: null(null)
26: null(null)
27: null(null)
28: null(null)
29: null(null)
30: null(null)
31: null(null)

另外,我们可以通过调用 SlotTable#asString() 来把数据可视化:

scss 复制代码
Group(0) key=100, nodes=2, size=16, slots=[0: {}]
 Group(1) key=1000, nodes=2, size=15
  Group(2) key=200, nodes=2, size=14 objectKey=OpaqueKey(key=provider)
   Group(3) key=-985533309, nodes=2, size=13, slots=[2: androidx.compose.runtime.RecomposeScopeImpl@4fb4ae6, androidx.compose.runtime.internal.ComposableLambdaImpl@3b52827]
    Group(4) key=-337788314, nodes=2, size=12 aux=C(Content), slots=[5: androidx.compose.runtime.RecomposeScopeImpl@b882ad4]
     Group(5) key=-3687241, nodes=0, size=1 aux=C(remember):Composables.kt#9igjgp, slots=[7: MutableState(value=false)@167707773]
     Group(6) key=-3686930, nodes=0, size=1 aux=C(remember)P(1):Composables.kt#9igjgp, slots=[9: MutableState(value=false)@167707773, Function2<kotlinx.coroutines.CoroutineScope, kotlin.coroutines.Continuation<? super kotlin.Unit>, java.lang.Object>]
     Group(7) key=1036442245, nodes=0, size=2 aux=C(LaunchedEffect)P(1)336@14101L58:Effects.kt#9igjgp
      Group(8) key=-3686930, nodes=0, size=1 aux=C(remember)P(1):Composables.kt#9igjgp, slots=[13: kotlin.Unit, androidx.compose.runtime.LaunchedEffectImpl@8d3f428]
     Group(9) key=-337788167, nodes=1, size=4
      Group(10) key=1815931657, nodes=1, size=3, slots=[15: androidx.compose.runtime.RecomposeScopeImpl@7421fc3]
       Group(11) key=1546164276, nodes=1, size=2 aux=C(ReusableComposeNode):Composables.kt#9igjgp
        Group(12) key=125, nodes=0, size=1 node=Node1(name=node1), slots=[18: node1]
     Group(13) key=1815931930, nodes=1, size=3, slots=[19: androidx.compose.runtime.RecomposeScopeImpl@81cf51f]
      Group(14) key=1546164276, nodes=1, size=2 aux=C(ReusableComposeNode):Composables.kt#9igjgp
       Group(15) key=125, nodes=0, size=1 node=Node2(name=node2), slots=[22: node2]

在运行时上面这些数据都会储存在 SlotTable 中

第2、3步:3秒后改变 MutableState, 快照系统监测到改变发生

下面这段代码改变 MutableState

kotlin 复制代码
// It's written at the top level. (The exact scope is different).
var state: MutableState<Boolean> = mutableStateOf(true)

@Composable
fun Content() {
    LaunchedEffect(Unit) {
        delay(3000)
        // Rewrite state.value
        state.value = false
    }

现在让我们探究一下 Compose 框架是如何监测到 State 的改变的

关于快照 (Snapshot) 系统

作者在这里推荐了另一篇关于快照的文章:dev.to/zachklipp/i...

我们要如何监测 UI 里的数据改动?

Compose 框架使用快照系统去做这个事情,假设我们有如下代码,我们可以先猜猜代码会输出什么

kotlin 复制代码
class ViewModel {
    val state = mutableStateOf("initialized")
}

fun main() {
    val viewModel = ViewModel()
    Snapshot.registerApplyObserver { changedSet, snapshot ->
        changedSet.forEach {
            println("registerApplyObserver:" + it)
        }
    }
    viewModel.state.value = "one"
}

正确答案是什么都不会输出。

但是当我再增加一行 Snapshot.sendApplyNotifications() 结果就会不一样:

kotlin 复制代码
class ViewModel {
    val state = mutableStateOf("initialized")
}

fun main() {
    val viewModel = ViewModel()
    Snapshot.registerApplyObserver { changedSet, snapshot ->
        changedSet.forEach {
            println("registerApplyObserver:" + it)
        }
    }
    viewModel.state.value = "one"
    // ↓ **Add the following**
    Snapshot.sendApplyNotifications()
}

//控制台输出
//registerApplyObserver:MutableState(value=one)@1831932724

Compose 框架正是使用上面的机制来以监测每帧之间的 UI 数据变化。在上面的例子中,在 registerApplyObserver() ****注册的回调会在 state 改变后调用

如果 Compose 的重组发生在非主线程会发生什么事情呢?

从我们以前的经验来说,感觉这样做会出现异常。但是通过使用快照系统,没问题!

Compose 是如何妥善处理 state 被多个线程更改的?

事实上,在上一个例子里,我们使用了一个最顶层的快照 (GlobalSnapshot) 。通过调用 Snapshot.takemutableSnapshot() 就可以创建出快照,重组就是在快照里面发生。

java 复制代码
    // Here is GlobalSnapshot

    // Create Snapshot
    val snapshot = Snapshot.takeMutableSnapshot()
    snapshot.enter {
         // Recompose here
    }

你可以把快照想象成游戏里的存档。

在创建快照后,通过 enter{} 方法进入快照,此时数据会定格在进入快照的时候,之后无论外面的数据怎么变化,都不会影响到该快照里面的数据。

kotlin 复制代码
class ViewModel {
    val state = mutableStateOf("init")
}

fun main() {
    val viewModel = ViewModel()

    viewModel.state.value = "before snapshot"

    val snapshot = Snapshot.takeMutableSnapshot()
    // 尝试从其他线程更改数据
    thread { // 启动一个其他线程更改 state
        viewModel.state.value = "changes from other thread"
    }
    snapshot.enter {
        // 等待上面的线程更改数据
        Thread.sleep(100)
        println("in snapshot:" + viewModel.state)
    }
}

//控制台的输出:
//in snapshot:MutableState(value=before snapshot)@1777466639

从上面的代码结果可以看出,在进入快照后,数据之后在外面发生的更改不会影响到快照内的数据。

那么当我们同时在快照里和快照外更改数据后会发生什么呢?

通过调用 Snapshot.apply() 我们可以把快照里的更改提交。

scss 复制代码
    val snapshot = Snapshot.takeMutableSnapshot()
    thread {
        viewModel.state.value = "changes from other thread"
    }
    snapshot.enter {
        Thread.sleep(100)
        println("in snapshot before change:" + viewModel.state)
        viewModel.state.value = "change in snapshot"
        println("in snapshot after change:" + viewModel.state)
    }
    snapshot.apply()
    println("after apply:" + viewModel.state)
    
//控制台的输出:
// in snapshot before change:MutableState(value=before snapshot)@1170114219
// in snapshot after change:MutableState(value=change in snapshot)@1170114219
// after apply:MutableState(value=changes from other thread)@1170114219

运行没有出错,但是看起来快照的更改没有生效。

事实上,MutableState 还有一个名为 SnapshotMutationPolicy 的参数(如下所示),用于快照提交时如果发生冲突时回调,让你自定义逻辑,而默认实现是会放弃提交的。快照系统正是通过这个来应对多线程修改。

kotlin 复制代码
fun <T> mutableStateOf(
    value: T,
    policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy()
): MutableState<T> = createSnapshotMutableState(value, policy)


class ViewModel {
    val state = mutableStateOf("init", object : SnapshotMutationPolicy<String> {
        override fun equivalent(a: String, b: String): Boolean {
            return a == b
        }

        override fun merge(previous: String, current: String, applied: String): String {
            return applied
        }
    })
}

Compose 是如何找到使用了更改的 state 的 Composable 函数的?

当一个 Composable 函数使用到的 MutableState 发生改变的时候,函数会再次调用,这个过程也叫重组 (Recompose) 。

这是如何做到的?

锵锵!Snapshot.takeMutableSnapshot() 方法还有一个名为 readObserver 的参数,问题的答案就在其中:

perl 复制代码
    val snapshot = Snapshot.takeMutableSnapshot(readObserver = { state ->
        // state 被读取的时候发生回调
    })

非常直观,当 state 被读取的时候,把当前的 Scope 存下来,如下所示。

kotlin 复制代码
class ViewModel {
    val state = mutableStateOf("init")
}

fun main() {
    val viewModel = ViewModel()
    lateinit var currentScope: String
    val observations = mutableMapOf<Any, String>()
    val snapshot = Snapshot.takeMutableSnapshot(readObserver = {
        observations[it] = currentScope
    })
    snapshot.enter {
        currentScope = "Root()"

        currentScope = "Content()"
        // read
        viewModel.state.value
        currentScope = "Root()"
    }
    snapshot.apply()
    observations.forEach { mutableState, scope ->
        println("`$mutableState` is observed by `$scope`")
    }
}

//控制台输出:
//`MutableState(value=init)@1096979270` is observed by `Content()`

还记得我们一开始的那个简易 UI 树例子的反编译后的代码吗? Compose 的编译器插件为我们生成了一个再次调用该 Composable 的方法,事实上,重组时的 UI 更新就是通过调用这个方法做到的:

less 复制代码
   @Composable
   public static final void Content(@Nullable Composer $composer, final int $changed) {
...
      // ↓↓↓↓Register the function to be called again↓↓↓↓ 
      if (var18 != null) {
         var18.updateScope((Function2)(new Function2() {
            public final void invoke(@Nullable Composer $composer, int $force) {
               MainKt.Content($composer, $changed | 1);
            }
         }));
      }
      // ↑↑↑↑Register the function to be called again↑↑↑↑

   }

至此,我们知道了 :

  • Compose 如何监测数据发生改变

  • 数据改变后哪些关联的 Scope 被影响到

  • 被影响到的 Scope 如何更新UI

相信你已经可以推理出完整的过程了!

快照究竟是什么?

引用下wiki:

多版本并发控制 (Multiversion concurrency control, MCCMVCC ),是数据库管理系统常用的一种并发控制,也用于程序设计语言实现事务内存。([1])

MVCC意图解决读写锁造成的多个、长时间的读操作饿死写操作问题。每个事务读到的数据项都是一个历史快照,并依赖于实现的隔离级别。写操作不覆盖已有数据项,而是创建一个新的版本,直至所在操作提交时才变为可见。快照隔离使得事务看到它启动时的数据状态。

作者还给了一篇论文链接:arxiv.org/pdf/1412.23...

快照的实现借助了 Java 的 ThreadLocal

第四步:重组 (Recompose)

当快照检测到 Content() 里读取的数据发生改变,Content() 会被再次调用。对于这个具体例子来说, state 被储存在 SlotTable 中,现在 Node1() 将要消失,而 Node2() 依旧会显示。

scss 复制代码
@Composable
fun Content() {
    var state by remember { mutableStateOf(true) }
    LaunchedEffect(Unit) {
        delay(3000)
        state = false
    }
    if (state) {
        Node1() // ← This place is about to disappear.
    }
    Node2()
}

只重调发生改变的 Composable 函数(donut-hole skipping [我想翻译为部分调用,但是感觉不太准确,还是把原文的叫法贴出来了])

但是 Compose 会调用整个受影响的地方吗? Compose 其实做了很多精妙的优化。

Compose 会跳过数据没有变化的部分。这个优化被称为 'donut-hole skipping' ,具体来说,Content() 会被调用,但不会完全调用(笑),里面的 Node2() 不会发生重组,就像把 Content() 比作一个甜甜圈(donut),而 Node2() 正是甜甜圈里的洞 (hole)。

作者在这里推荐了一篇讲 donut-hole skipping 的文章:www.jetpackcompose.app/articles/do...

非常神奇吧!在上文中我们提及过数据会被储存在 SlotTable 中,通过比对新旧数据,是可以做到跳过没有发生更改的部分的。

scss 复制代码
Group(0) key=100, nodes=2, size=16, slots=[0: {}]
 Group(1) key=1000, nodes=2, size=15
...
     Group(13) key=1815931930, nodes=1, size=3, slots=[19: androidx.compose.runtime.RecomposeScopeImpl@81cf51f]
      Group(14) key=1546164276, nodes=1, size=2 aux=C(ReusableComposeNode):Composables.kt#9igjgp
       // **↓I won't recompose it because it's the same data I'm holding here!**
       Group(15) key=125, nodes=0, size=1 node=Node2(name=node2), slots=[22: node2]

通过反编译的代码👇,我们可以看到第9行从 SlotTable 中读取数据比较。(事实上这里的默认参数并不准确,不过这个不是重点,我们这里不展开)

php 复制代码
   @Composable
   private static final void Node2(final String name, Composer $composer, final int $changed, final int var3) {
      $composer = $composer.startRestartGroup(1815931962);
      int $dirty = $changed;
      if ((var3 & 1) != 0) {
         $dirty = $changed | 6;
      } else if (($changed & 14) == 0) {
         // ↓ 与 SlotTable 中的数据比较
         $dirty = $changed | ($composer.changed(name) ? 4 : 2);
      }

      if (($dirty & 11 ^ 2) == 0 && $composer.getSkipping()) {
         // 根据比较结果决定是否跳过
         $composer.skipToGroupEnd();
      } else {
...
         $composer.startReplaceableGroup(1546164276);
         // ... Here's the original process.
         $composer.endReplaceableGroup();
      }

...
   }

如何把改变映射到 SlotTable 中?

上一小节我们谈及到了未更改的部分不会重新运行,但是 SlotTable 的数据也是需要更新的。在重组期间,所有的改变都会被储存在一个 change list 中,最后一次过对 SlotTable 应用所有的改变。

第五步:使用 GapBuffer 算法优化 SlotTable 内部数据改动效率

现在是时候把更改列表储存的所有更改全部应用了。如果我们不想太多直接用常规方法实现,工作起来如下图所示,很直观,没有毛病。

那如果用上面的方法在4个元素的数组里移除3个呢?

由于删除数组中的其中一个数据后需要把后面所有的元素一个一个往回补,这样的操作会花费大量的时间👇

在前文我们已知 SlotTable 使用的数据结构就是数组,更何况现实中的 UI 元素可远不止个位数,显然不能用这种默认实现去操作 SlotTable。

Compose 使用 Gap Buffer 算法来优化。

附上Gap Buffer 的 wiki: en.wikipedia.org/wiki/Gap_bu...

简单来说,gap buffer 是一个动态数组,实现了相邻位置元素的高效的增删,这个算法常用于文本编辑器中。

通过移动数据来创建间隙,只需使用一个变量来表示间隙的长度,然后更改该变量即可完成删除。

使用 GapBuffer 删除 Node1 的过程👇

总结

可以看到,Compose 内部做了非常多有趣的优化,我不认为只使用 Compose 去编写 UI 能够发掘出这些东西。

  • Compose 的编译器插件会对我们写的 Composable 函数作处理,生成 SlotTable 所需要的组信息以及用于重组时调用的 Lambda
  • SlotTable 会在应用运行后创建
  • Composable 监听使用到的 State 发生改变是由快照系统实现的
  • 重组发生时会从 SlotTable中取出旧数据对比是否发生改变,以跳过不必的重组
  • 发生改变时不会马上触发重组,而是把信息储存在一个容器中,周期性(帧间)地触发所有容器中相关的重组
  • SlotTable 中的数据更新使用 Gap Buffer 算法优化
相关推荐
一航jason8 天前
Android Jetpack Compose 现有Java老项目集成使用compose开发
android·java·android jetpack
帅次10 天前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
IAM四十二12 天前
Jetpack Compose State 你用对了吗?
android·android jetpack·composer
Wgllss13 天前
那些大厂架构师是怎样封装网络请求的?
android·架构·android jetpack
x0241 个月前
Android Room(SQLite) too many SQL variables异常
sqlite·安卓·android jetpack·1024程序员节
alexhilton1 个月前
深入理解观察者模式
android·kotlin·android jetpack
Wgllss1 个月前
花式高阶:插件化之Dex文件的高阶用法,极少人知道的秘密
android·性能优化·android jetpack
上官阳阳1 个月前
使用Compose创造有趣的动画:使用Compose共享元素
android·android jetpack
沐言人生1 个月前
Android10 Framework—Init进程-15.属性变化控制Service
android·android studio·android jetpack
IAM四十二1 个月前
Android Jetpack Core
android·android studio·android jetpack