LoginSignup
35
29

More than 1 year has passed since last update.

Inside Jetpack Compose

Posted at

The English version is available here. (英語バージョンはこちら)
https://medium.com/@takahirom/inside-jetpack-compose-2e971675e55e

なぜ内部を知るか?

Jetpack Composeってまるで魔法みたいですよね?
例えば、関数に返り値がなくてもレイアウトされたり、勝手に差分更新がうまく動いたりしますよね?

compose.gif
https://developer.android.com/jetpack/compose?hl=en より

知りたいと思ったのは、どうやって動いているか分からないものを見ると中身を知りたくなるのが純粋な理由です。
しかし、これからJetpack ComposeがAndroid開発のデファクトスタンダードになっていくと思われ、知っておくことで開発中に一歩進んで問題などの原因が分かったりすると思います。
多分理解するとJetpack Composeすごいってなる部分が結構あるのでぜひ読んでみてください

どのように知るか?

まず、今のAndroidのComposeをそのまま使うと、Viewとの連携、Lifecycleとの関連などなどで、その部分はその部分で楽しいのですが、今回はComposeの仕組みが知りたいのに、なかなか読みたいJetpack Composeの仕組みの部分に集中できません。

(ComposeとAndroidが連携するために、さまざまなAndroid関連の処理が書かれている)
(https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/Wrapper.android.kt;drc=196281eb061d6e3eb1ad2f05b4cb8c5e5fbdd70f より )

Jetpack ComposeはUIを開発するAndroidのツールキットですが、その内部の仕組みは、Jetpack Compose for Webなどでも利用されています。
そのJetpack Composeの仕組みがどのように動いているのか、複雑なAndroidのコードからではなく、Jetpack Composeを使ったかんたんなサンプルコードを紹介し、状態を説明しながら、Composeへの理解を深めようと思います。

今回の例となるJetpack Composeを使ってシンプルに木構造を処理するコードの紹介

他の関数も合わせ、全体のコードはここにあるので、もしこの紹介で読みにくければ普通にコードを見てみてください。 130行ほどで、そんなに難しくはないはずです。

今回は説明のためにかなり簡単なものを使用します。println()で木が表示されるだけです。

以下のような木になっているものが、Node1が消えて、

RootNode
├── Node1(value=node1)
└── Node2(value=node2)

数秒後に以下になるだけです。

RootNode
└── Node2(value=node2)

以下のコードでは、少しだけComposeの基礎知識が必要になりますが、Content()関数はConposable関数で、LaunchedEffect{}でKotlin Coroutinesを起動後、3秒後にstateをfalseに変更し、それによってNode1が消えています。

このContent()関数は何度も出てくるので頭に残しておいてください。

@Composable
fun Content() {
    // ここで保存しているstateが3秒後にtrueからfalseに変わる
    var state by remember { mutableStateOf(true) }
    LaunchedEffect(Unit) {
        delay(3000)
        state = false
    }
    // stateがfalseに変わるとNode1()が呼び出されなくなり、消える
    if (state) {
        Node1()
    }
    Node2()
}

上記のコードがどのようにJetpack Composeで可能になるのか見ていきましょう。

まずは上記のように出力できるようにするために、木のノードが必要になりますね。
そのために、単にNodeのクラスを定義します。
ここでは親クラスにNodeクラスがいて子クラスにRootNode、Node1、Node2がいます。
このクラスはJetpack Composeのクラスなどを継承したりしない単純なクラスです。

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()
}

このNodeたちの木構造をJetpack Composeによって作らせる必要がありますがどのようにするでしょうか?
AbstractApplierというCompose runtimeにあるクラスを継承してクラスを作ることでJetpack Composeによって木構造を作らせるこれが可能になります。
ApplierはJetpack Composeで木構造の操作に関して行うクラスです。
これはNodeに対して子に追加する処理、動かしたり、消したりする処理を行います。

class NodeApplier(node: Node) : AbstractApplier<Node>(node) {
...
    override fun insertTopDown(index: Int, instance: 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では、factoryという引数でnodeを作成し、updateという引数で変更点を渡します。

@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 }
        },
    )
}

あとは上記のコードを実行する仕組みを動かせば動きます。このあたりも他の説明が終わると分かるようになるかもしれません。

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()
        }
    }
}

今回紹介する全体像

image.png

この図は以下のリポジトリで確認できます。

大まかに言うと以下の流れになります。これを見ていくとJetpack Composeの中身を理解していけるはず。

ビルド時

  • 0. Composable関数をCompose Kotlin Compiler PluginでSlotTableを作れるようにする

アプリ起動後

  • 1. Composable関数を呼び出して、情報をSlotTableに格納する
  • 2. 3秒後のMutableStateへの変更
  • 3. Snapshot Systemが変更をキャッチ
  • 4. Recompose
  • 5. GapBufferを使ったSlotTableへの変更の反映

ステップ0. Composable関数をCompose Kotlin Compiler PluginでSlotTableを作れるようにする

Jetpack Composeは差分更新ができます。そのためには差分になるための情報を持っておかないといけないですよね?そのための情報がComposable関数によってSlotTableというもの(後述)に保存されます。
そのためにSlotTableを作れるように変更を加えます。

Androidの開発ではKotlinをJavaバイトコードに変換し、そのJavaバイトコードをdex形式に変換します。
このKotlinをJavaバイトコードに変換するときにKotlinコンパイラでは一度Kotlin IRと呼ばれる中間表現に変換します。Jetpack ComposeはこのKotlin IRを書き換えるようです。

つまりこの変換後のJavaのBytecodeをデコンパイルすることで、Compiler Pluginの変換結果を見ることができます。 (かんたんにこのデコンパイル結果が見られるライブラリのDecomposerもよろしくお願いします https://github.com/takahirom/decomposer )

軽くでコンパイルしたContent()のComposable関数を確認してみましょう。
特徴としてはstartRestartGroup()、endRestartGroup()がある。startReplaceableGroup()、endReplaceableGroup()があるというのは分かると思います。Composeは内部でGroupという概念を持っています。またGroupによる木を作れるようにします。
また再度呼び出し用の関数の登録を行えるようにします。
また他にも処理がスキップできそうなskipなどの文字も見えますね :eyes:

   @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とMutableState関連のコード
         $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関数を呼び出して、現在のComposable関数の情報をSlotTableに格納する

さて、ここでアプリが実際に起動して、先程のContent()などの関数が実行されます。
このときに、Jetpack ComposeではSlotTableというものにデータを格納します。この記事では更新部分に力を入れるため、あまり細かくこのステップについて見ていきません。
Jetpack Composeは差分更新ができます。そのためには差分になるための情報を持っておかないといけないですよね?そのための情報がComposable関数によってSlotTableというものに保存されます。

SlotTableについて知る

SlotTableは以下ほぼ以下2つのデータ構造からできていて、これだけでうまく木構造を格納します。

groups: IntArray
slots: Array<Any?>

先程でてきたGroupと同じ単語のgroupsが出てきましたね。
groupsのIntArrayの配列は1グループが5個区切りになっていて、5個ずつ取り出す以下のようなコードを書くとうまくデータを取り出せます。

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")
    }

slotsはgroupが保持するデータが入っているようです。
そしてslotへのindexはdataAnchorに格納されています。
またparentAnchorがあることで親のindexを格納し、IntArrayではありますが木構造ができています。

起動後にComposable関数を呼び出すことによってSlotTableを作成していきます。今回は変更について説明していきたいので、一つ一つのプロパティについて説明しませんがこんな感じのデータが起動直後に入っています。

image.png

groups:

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:

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()メソッドを呼ぶことで、仮想的に木構造にビジュアライズしたデータ見みることができます。

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への変更、Snapshot Systemが変更をキャッチ

以下のコードでMutableStateへの変更が走ります。

@Composable
fun Content() {
    var state by remember { mutableStateOf(true) }
    LaunchedEffect(Unit) {
        delay(3000)
        state = false
    }

さてこのbyっていうのはどういうものでしょうか、remember{}とは? mutableStateOf(true) とは?
remember{}mutableStateOf(true)は完全に関係のないものですが、うまく一緒に動くものです。
remember{}はインスタンスの生存期間を伸ばしてくれるもので、 by はKotlinのDelegated Propertyによるもので、
細かいところは違いますが、基本的には以下のコードと同じです。

つまりMutableStateのvalueを変更しているということです。

// トップレベルで保持。(実際は少しスコープが違う)
var state: MutableState<Boolean> = mutableStateOf(true)

@Composable
fun Content() {
    LaunchedEffect(Unit) {
        delay(3000)
        // state.valueを書き換える
        state.value = false
    }

さて、ここで、LaunchedEffectによる別の場所からのMutableStateの変更をどのようにJetpack Composeがキャッチしているのかを学んでいきましょう。

Snapshot Systemについて知る

少し今までの例から離れて、Snapshot Systemについて学んでいきましょう。これを学ぶことで、Composeについてもっと知ることができます。
今回のコードは以下に置いてあります。

フレーム間の変更をどのように検知するか?

Snapshot Systemを使ってComposeは変更を検知しています。

こんなコードがあったとします。

class ViewModel {
    val state = mutableStateOf("initialized")
}

fun main() {
    val viewModel = ViewModel()
    viewModel.state.value = "one"
}

ComposeのコンパイラなしでComposeのRuntimeを使うことで、Snapshotを使って遊ぶことができます。

以下のコードでは何が出力されるでしょうか?

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()を追加した以下のコードではどうなるでしょう?

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()
}

Snapshot.sendApplyNotifications()を追加することでregisterApplyObserver()で渡しているapply observerが反応します

registerApplyObserver:MutableState(value=one)@1831932724

この仕組みを使ってJetpack Composeはフレームごとに溜まった変更を処理していきます。今回ではLaunchedEffectでのMutableStateの変更でregisterApplyObserver()のObserverが呼ばれます。

実際のComposeのコードで変更を受け取る部分

image.png

ではフレームの間はいいとしてComposeが実際にRecompose中、つまりContent()などを呼んでいる時に別スレッドから触られたらどうなってしまうのでしょうか?
Javaの経験がある方だとこれはヤバイというイメージがあるかもしれません。
これをJetpack ComposeのSnapshot Systemは解決します。

MutableStateのマルチスレッドの問題をJetpack Composeではどのように解決しているのか?

実は今までの例ではトップレベルで保持されているGlobalSnapshotを使っていましたが、
ComposeはContent()などを呼び出しなおすRecomposeをする前にSnapshot.takeMutableSnapshot()でSnapshotを作成し、そのsnapshot内でRecomposeします。
Snapshotはゲームのセーブポイントだと思っていただければ大丈夫です。

    // ここはGlobalSnapshot

    // Snapshot作成
    val snapshot = Snapshot.takeMutableSnapshot()
    snapshot.enter {
         // ここでRecomposeされる
    }

以下のようにSnapshotを作り利用することができます。
Snapshot.takeMutableSnapshot()でSnapshotを取得します。
Snapshotのenter{}を呼んでいる中はSnapshot.takeMutableSnapshot()した時点のデータが取得できるため、情報が途中で変更されるなどの問題が発生しません。

class ViewModel {
    val state = mutableStateOf("init")
}

fun main() {
    val viewModel = ViewModel()

    viewModel.state.value = "before snapshot"

    val snapshot = Snapshot.takeMutableSnapshot()
    // change from other thread
    thread { // 別スレッドを起動
        viewModel.state.value = "changes from other thread"
    }
    snapshot.enter {
        // wait change from other thread
        Thread.sleep(100)
        println("in snapshot:" + viewModel.state)
    }
}

明らかに別スレッドから書き換えられていそうだが、 changes from other thread になっていない!!

in snapshot:MutableState(value=before snapshot)@1777466639

では、このenter{}の中でthreadと同時に書き換えたらどうなるのでしょうか?つまり変更のコンフリクトを起こしたらどうなるでしょうか?

Snapshot.apply()を呼ぶことでSnapshot内(enter{}内)での変更をGlobalSnapshotに反映することができます。

    val snapshot = Snapshot.takeMutableSnapshot()
    // change from other thread
    thread { // 別スレッドを起動
        viewModel.state.value = "changes from other thread"
    }
    snapshot.enter {
        // wait change from other thread
        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というのを引数でもっていて、以下のようにポリシーを変更する事もできます。このようにマルチスレッドに対応してコーディングしていくことができます。

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
        }
    })
}
in snapshot before change:MutableState(value=before snapshot)@2058170434
in snapshot after change:MutableState(value=change in snapshot)@2058170434
after apply:MutableState(value=change in snapshot)@2058170434

どのように変更があったMutableStateを見ているComposable関数をComposeは見つけるのか?

Composeは変更があったMutableStateを見ているComposable関数を見つけて、再度呼び出し(Reompose)をします。
これをどのように行うでしょうか?
Snapshot.takeMutableSnapshot()は引数にreadObserverを渡すことができます。

    val snapshot = Snapshot.takeMutableSnapshot(readObserver = { state ->
        // stateが読まれたときに呼ばれる
    })

今のscopeを保持しておいて、それを書き換えていくことで、以下のように行っています。

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 を Content() が見ている ということがobservationsに保存されている事がわかります。

MutableState(value=init)@1096979270 is observed by Content()

実際のComposeのコードでも、同様にobservationsにMutableStateに対してScopeがセットされます。

image.png

Content()のデコンパイルされたコードでは再度呼び出すためのラムダが定義されていましたね!実際にはこれを呼び出すというわけです。

   @Composable
   public static final void Content(@Nullable Composer $composer, final int $changed) {
...
      // ↓↓↓↓再度呼び出し用の関数の登録↓↓↓↓ 
      if (var18 != null) {
         var18.updateScope((Function2)(new Function2() {
            public final void invoke(@Nullable Composer $composer, int $force) {
               MainKt.Content($composer, $changed | 1); // 再度Content()を呼び出すときに使う
            }
         }));
      }
      // ↑↑↑↑再度呼び出し用の関数の登録↑↑↑↑

   }

これによって、変更の検知、変更されたときに呼び出し直す必要があるスコープ。両方が取れるようになったのであとは呼び出すだけですね!

Snapshotって結局何なのか?

multiversion concurrency control (MVCC)と呼ばれるもののようです。

MultiVersion Concurrency Control (MVCC, マルチバージョン コンカレンシー コントロール) は、データベース管理システムの可用性を向上させる制御技術のひとつ。複数のユーザから同時に処理要求が行われた場合でも同時並行性を失わずに処理し、かつ情報の一貫性を保証する仕組みが提供される。日本では多版型同時実行制御、多重バージョン並行処理制御などと訳される。また単にマルチバージョンとも呼ばれる。

データベースで使われる仕組みのようです。
Snapshotクラスのコメントには以下の論文へのリンクもあります。
https://arxiv.org/pdf/1412.2324.pdf

実際どのように実現されているのかというとJavaのThreadLocalの仕組みを上手く使っているそうです。

ステップ4. Recompose

Snapshotの変更検知によってContent()で変更があったことが分かるので、またContent()が呼び出されます。
ここではSlotTableに今の状態が保存されていて、今このNode1が消えようとしています。しかしNode2はそのまま残ります。

@Composable
fun Content() {
    var state by remember { mutableStateOf(true) }
    LaunchedEffect(Unit) {
        delay(3000)
        state = false
    }
    if (state) {
        Node1() // ← ここが消えようとしている
    }
    Node2()
}

変更があったComposable関数を再度呼び出してSlotTableの内容と比較。変更がないComposable関数は呼び出さない。 (donut-hole skipping)

ただ、Composeは変更があった場所をただ呼び出して再構築するだけでしょうか? Composeにはもっとすごい最適化が行われています。

前と同じデータになるところはスキップします。
これは donut-hole skipping と呼ばれる最適化で、穴が空いたようにContent()は再実行されてもNode2()はRecompose(再実行)されません!

image.png

これは魔法でしょうか??
少し前にSlotTableとして以下のようなデータが保持されているという話がありましたね。このデータと比較することで変更がないところは実行しないという最適化が行われます。

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
       // **↓ここで保持しているデータと一緒!なのでrecomposeしない!となる**
       Group(15) key=125, nodes=0, size=1 node=Node2(name=node2), slots=[22: node2]

これは実際には以下のバイトコードで composer.changed(name) になっている部分で、SlotTableの中身を取り出し、比較を行います。

   @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);
// ここに実際のもともと入っていた処理が入っている。
         $composer.endReplaceableGroup();
      }

...
   }

image.png

(ここでは実際はデフォルト引数による最適化が行われるのですが、かなり細かい挙動の違いだと思うので気になる方は調べてみてください。)

どうやってSlotTableに変更を反映していくのか?

さて、一個前のところで同じところは再実行しないという話がありましたが、変更があった部分に関してはSlotTableを変更して最新の状態に保つ必要が出てきます。
呼び出しながら変更があった部分をchange listにためていって、最後に一気に反映します。

image.png

ステップ5. GapBufferを使ったSlotTableへの変更の反映

さて、ためたchange listを反映するのですが、
何も考えずに普通にやるとこんな感じになりますよね。シンプルで何も問題なさそうです。

image.png

例えば 上記のアルゴリズムで消えるNodeが3個あったらどうなるでしょうか??

一個ずつ消すのに他のものを一個ずつずらす必要があるので、かなり時間がかかります。

image.png

groupsはIntArrayなので、一回の操作ごとにこの消えた場所より後ろがすべて一個ずつずれていかないといけないです。これはかなり計算量が増えてしまいます。同様の問題がgroupの追加時にも起こります。
また比較的単純なDroidKaigi公式アプリでも371個このグループがあったりするのでこの方法だとこの反映作業だけでComposeが重くなっちゃう可能性があります。 :sob:

これに対してJetpack ComposeではGap Bufferという仕組みが使われています。これはテキストエディタなどで使われるアルゴリズムで、連続での追加や変更時に計算量を節約できます。
データをずらしてgapを作り、単にgapの長さを変数で持っていて、その変数を変更するだけで、削除が完了します。
image.png

GapBufferを使って以下のようにNode1が削除されます。

image.png

このGroupの削除のタイミングで、最初に話していた木の操作をするApplierがNode1を消してくれます。これでこの変更は終わりです。

まとめ

このように普通にアプリを作っているだけでは思いつかないようなさまざまな最適化が行われており、めちゃくちゃ面白いですね。

  • Compose Kotlin Compiler PluginによってComposable関数が書き換えられ、SlotTableに対応するGroup、再実行できるラムダが実装される。
  • その書き換えられた関数を使ってSlotTableが作成される。
  • 変更があったこと、変更を適応するComposable関数はSnapshot Systemを使うことによって検知される。Snapshot SystemはMVCCと呼ばれるもの。
  • Recompose、Commposable関数の再実行ではSlotTableと比較し同じところは実行をスキップしながら効率的に実行される(donut-hole skipping)
  • 比較して変更があった部分はchange list (changes)に貯められて、一気に反映される
  • 反映時にはGap Bufferによって効率的に反映される。

image.png

コードリーディング資料

https://qiita.com/takahirom/items/b29b7db652efe277498a
https://qiita.com/takahirom/items/d2a89560f8ff2065a7c0
https://qiita.com/takahirom/items/0e72bee081de8cf4f05f
https://qiita.com/takahirom/items/11e3ed72eb2f83440b12
https://qiita.com/takahirom/items/0e0a3559d95b49399c3b
https://qiita.com/takahirom/items/8e978eeb6d85bf48a330
https://qiita.com/takahirom/items/64bd9aa3278035671558

参考

CustomTreeCompositionSamples
https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/runtime/runtime/samples/src/main/java/androidx/compose/runtime/samples/CustomTreeCompositionSamples.kt
Jetpack Compose Internals
https://jorgecastillo.dev/book/
Under the hood of Jetpack Compose
https://medium.com/androiddevelopers/under-the-hood-of-jetpack-compose-part-2-of-2-37b2c20c6cdd
Introduction to the Compose Snapshot system
https://dev.to/zachklipp/introduction-to-the-compose-snapshot-system-19cn
Donuts hole skipping
https://www.jetpackcompose.app/articles/donut-hole-skipping-in-jetpack-compose

35
29
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
35
29