Jetpack Compose コア: SlotTable システムについての深い理解

序章

Compose の描画には、コンポジション > レイアウト > 描画の 3 つの段階があります。後の 2 つのプロセスは、合成が Compose に固有であることを除いて、従来のビューのレンダリング プロセスに似ています。Compose は、Compose フレームワークの中核機能である結合によってレンダリング ツリーを生成しますが、このプロセスは主に SlotTable によって実装されます。この記事では、SlotTable システムについて紹介します。

1. Compose レンダリング プロセスから開始する

Android のネイティブ ビューに基づく開発プロセスの本質は、ビューベースのレンダリング ツリーを構築することであり、フレーム信号が到着すると、ルート ノードから深くトラバースし始め、レンダリングされるまで、順番に測定/レイアウト/描画を呼び出します。ツリー全体が完成しました。Compose には、 Compositiionと呼ばれるレンダリング ツリーもあります。ツリー上のノードはLayoutNodeです。Composition は、LayoutNode を介して測定/レイアウト/描画のプロセスを完了し、最終的に UI を画面に表示します。合成は、コンポーザブル関数の実行に依存して作成および更新、いわゆる合成と再編成を行います

たとえば、上記のコンポーザブル コードは、実行後に右側にコンポジションを生成します。

関数は実行後にどのように LayoutNode に変換されるのでしょうか? Text のソース コードを詳しく調べたところ、内部で Layout が呼び出されていることがわかりました。Layout は、レイアウトをカスタマイズできるコンポーザブルです。直接使用するすべての種類のコンポーザブルは、Layout を呼び出すことで、最終的にさまざまなレイアウトを実現し、表示効果を実現します。

//Layout.kt
@Composable inline fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    
    
    val density = LocalDensity.current
    val layoutDirection = LocalLayoutDirection.current
    val viewConfiguration = LocalViewConfiguration.current
    ReusableComposeNode<ComposeUiNode, Applier<Any>>(
        factory = ComposeUiNode.Constructor, 
        update = {
    
    
            set(measurePolicy, ComposeUiNode.SetMeasurePolicy)
            set(density, ComposeUiNode.SetDensity)
            set(layoutDirection, ComposeUiNode.SetLayoutDirection)
            set(viewConfiguration, ComposeUiNode.SetViewConfiguration)
        },
        skippableUpdate = materializerOf(modifier),
        content = content
    )
}

Layout は、ReusableComposeNode を通じて内部的に LayoutNode を作成します。

  • factoryLayoutNodeを作成するファクトリーです
  • update後続のレンダリングのために更新されたノードのステータスを記録するために使用されます。

ReusableComposeNode に進みます。

//Composables.kt
inline fun <T, reified E : Applier<*>> ReusableComposeNode(
    noinline factory: () -> T,
    update: @DisallowComposableCalls Updater<T>.() -> Unit,
    noinline skippableUpdate: @Composable SkippableUpdater<T>.() -> Unit,
    content: @Composable () -> Unit
) {
    
    
    //... 
    $composer.startReusableNode()
    //...
    $composer.createNode(factory)
    //...
    Updater<T>(currentComposer).update()
    //...
    $composer.startReplaceableGroup(0x7ab4aae9)
    content()
    $composer.endReplaceableGroup()
    $composer.endNode()
}

Composable 関数はコンパイル後に Composer に渡され、渡された Composer に基づいてコード内で一連の操作が完了することがわかっています。主なロジックは非常に明確です。

  • Composer#createNodeノードを作成する
  • Updater#updateノードのステータスを更新する
  • content()引き続き内部のコンポーザブルを実行し、子ノードを作成します。

さらに、一部の startXXX/endXXX がコード内に散在しており、このようなペアの呼び出しは、ツリーを深くトラバースするときのプッシュ/ポップに似ています。

startReusableNode
    NodeData // Node数据
    startReplaceableGroup
        GroupData //Group数据
        ... // 子Group
    endGroup
endNode

ReusableComposeNode などの組み込み Composable だけでなく、自分で書いた Composable 関数本体のコンパイル コードにも startXXX/endXXX が大量に挿入されますが、これらは実際には Composer が SlotTable にアクセスする処理です。 SlotTableを書き込み、Composition を作成および更新します

次の図は、Composition、Composer、および SlotTable の関係クラス図です。

2. まず SlotTable について理解する

前回の記事では、Composable Compositio の実行後に生成されるレンダリング ツリーを「レンダリング ツリー」と呼びました。実際、より正確に言うと、Composition には 2 つのツリーがあり、1 つは実際にレンダリングを実行する LayoutNode ツリーで、LayoutNode は View と同様に測定/レイアウト/描画などの特定のレンダリング プロセスを完了できます。もう 1 つのツリーは SlotTable で、Composition 内のさまざまなデータ状態を記録します従来のビューの状態は View オブジェクトに記録されますが、Compose ではオブジェクト指向ではなく関数プログラミング指向であるため、これらの状態は SlotTable によって管理および維持される必要があります。

コンポーザブル関数の実行中に生成されるすべてのデータ (State、CompositionLocal、remember のキーと値などを含む) は SlotTable に保存されます。これらのデータは、関数がスタックからポップアウトされても消えることはなく、再結合しても存在できます。再編成中に新しいデータが生成されると、コンポーザブル関数は SlotTable を更新します。

SlotTable のデータは Slot に格納され、1 つ以上の Slot がグループに属します。グループはツリー上の各ノードとして理解できます。SlotTable はツリーであると言われていますが、実際には、実際のツリー データ構造ではありません。ツリーのセマンティクスを表現するために線形配列が使用されています。これは、SlotTable の定義からわかります

//SlotTable.kt
internal class SlotTable : CompositionData, Iterable<CompositionGroup> {
    
    

    /**
     * An array to store group information that is stored as groups of [Group_Fields_Size]
     * elements of the array. The [groups] array can be thought of as an array of an inline
     * struct.
     */
    var groups = IntArray(0)
        private set
 
    /**
     * An array that stores the slots for a group. The slot elements for a group start at the
     * offset returned by [dataAnchor] of [groups] and continue to the next group's slots or to
     * [slotsSize] for the last group. When in a writer the [dataAnchor] is an anchor instead of
     * an index as [slots] might contain a gap.
     */
    var slots = Array<Any?>(0) {
    
     null }
        private set

SlotTable には 2 つの配列メンバーがあり、groups配列にはグループ情報が格納され、slotsグループによって管理されるデータが格納されます。構造化ストレージの代わりに配列を使用する利点は、「ツリー」へのアクセス速度を向上できることです。Compose での再編成の頻度は非常に高く、再編成プロセス中、SlotTable は継続的に読み書きされ、配列へのアクセスの時間計算量はわずか O(1) であるため、線形配列構造を使用するとパフォーマンスの向上に役立ちます。組織再編のこと。

groups は IntArray であり、5 つの Int ごとに情報のグループが形成されます。

  • key: SlotTable 内のグループの識別子。親グループの範囲内で一意です。
  • Group info: Int のビットには、ノードであるかどうか、データが含まれているかどうかなど、いくつかのグループ情報が格納されます。これらの情報は、ビット マスクを通じて取得できます。
  • Parent anchor: グループ内の親の位置、つまり配列ポインタを基準としたオフセット
  • Size: Group: 含まれるスロットの数
  • Data anchor: スロット配列内の関連するスロットの開始位置

スロットはデータが実際に保存される場所であり、コンポーザブルの実行中に任意のタイプのデータを生成できるため、配列タイプは ですAny?各グループに関連付けられるスロットの数は可変であり、スロットは、所属するグループの順序でスロットに格納されます。

グループやスロットはリンクリストではないため、容量が足りない場合は拡張されます。

3. グループに対する深い理解

グループの役割

SlotTable のデータは Slot に格納されますが、ツリー上のノードとなるユニットが Slot ではなく Group なのはなぜですか? Group は次の機能を提供するためです。

  • ツリー構造の構築: Composable の最初の実行時に、startXXXGroup にグループ ノードが作成され、SlotTable に保存され、Parent アンカーを設定することでグループの親子関係が構築されます。グループはレンダリング ツリーを構築するための基礎となります。

  • 認識構造の変更: コンパイル中に startXXXGroup コードが挿入されると、$keyコードの位置に基づいて識別可能な (親スコープ内で一意の) コードが生成されます。初めて結合したときは$key、 Group とともに SlotTable に保存され、再編成中、Composer は$key比較に基づいて Group の追加、削除、位置の移動を認識できます。つまり、SlotTable に記録された Group は位置情報を保持するため、この仕組みはPositional Memoizationとも呼ばれます。位置メモ化は、SlotTable の構造の変更を検出し、最終的に LayoutNode ツリーの更新に変換できます。

  • 再編成の最小単位: Compose の再編成は「インテリジェント」であり、コンポーザブル関数または Lambda は再編成中に不要な実行をスキップできます。SlotTtable では、これらの関数またはラムダは 1 つずつ RestartGroups にパッケージ化されるため、Group が再編成に参加する最小単位となります。

グループの種類

Composable は、コンパイル中にさまざまなタイプの startXXXGroup を生成します。これらが SlotTable のグループに挿入されると、さまざまな機能を実現するための補助情報が保存されます。

startXXXグループ 説明する
startNode/startReusableNode ノードを含むグループを挿入します。たとえば、記事の冒頭の ReusableComposeNode の例では、startReusableNode が呼び出され、次に createNode が呼び出されて LayoutNode が Slot に挿入されることが示されています。
スタートリスタートグループ 繰り返し可能なグループを挿入します。これは再編成によって再度実行される可能性があるため、RestartGroup が再編成の最小単位になります。
startReplaceableGroup 置換可能なグループを挿入します。たとえば、if/else コード ブロックは ReplaceableGroup であり、再編成時に SlotTable に挿入したり、SlotTable から削除したりできます。
startMovableGroup 移動可能なグループを挿入すると、再編成時に兄弟グループ間で位置移動が発生する可能性があります。
startReusableGroup LazyList の同じ種類の項目など、LayoutNode 間で内部データを再利用できる再利用可能な Group を挿入します。

もちろん、startXXXGroup は新しいグループを挿入するために使用されるだけでなく、再編成中に SlotTable 内の既存のグループを追跡し、
現在実行されているコードと比較するためにも使用されます。次に、いくつかの異なる種類の startXXXGroup がどのようなコードに現れるかを見てみましょう。

4. コンパイル中に生成される startXXXGroup

startXXXGroup については先ほど数種類紹介しましたが、平日に Compose コードを書いているときは意識することがありませんが、どのような状況で生成されるのでしょうか。いくつかの一般的な startXXXGroup 生成タイミングを見てみましょう。

startReplaceableGroup

位置メモ化の概念については前述しました。つまり、グループが SlotTable に格納されると、位置ベースの生成が実行され$key、SlotTable の構造変化を識別するのに役立ちます。次のコードは、この機能をより明確に説明しています。

@Composable
fun ReplaceableGroupTest(condition: Boolean) {
    
    
    if (condition) {
    
    
        Text("Hello") //Text Node 1
    } else {
    
    
        Text("World") //Text Node 2
    }
}

このコードは、条件が true から false に変化すると、レンダー ツリーが古いテキスト ノード 1 を削除し、新しいテキスト ノード 2 を追加する必要があることを意味します。ソースコードではTextに識別可能なキーを追加していないため、ソースコードに従って実行するだけでは、プログラムはカウント変更前後のノードの違いを認識できず、古いノード状態になる可能性があります。残り、UI が期待を満たしていません。

Compose はこの問題をどのように解決しますか? コンパイル後の上記のコード (疑似コード) を見てください。

@Composable
fun ReplaceableGroupTest(condition: Boolean, $composer: Composer?, $changed: Int) {
    
    
    if (condition) {
    
    
        $composer.startReplaceableGroup(1715939608)
        Text("Hello")
        $composer.endReplaceableGroup()
    } else {
    
    
        $composer.startReplaceableGroup(1715939657)
        Text("World")
        $composer.endReplaceableGroup()
    }
}

ご覧のとおり、コンパイラは if/else の条件分岐ごとに RestaceableGroup を挿入し、別の分岐を追加します$keyこのようにして、変更がcondition発生する、グループが変更されたことを識別できるため、元のノードを更新するだけでなく、SlotTable が構造的に変更されます。

複数のコンポーザブルが if/else 内で呼び出された場合でも (たとえば、複数の Text が表示される場合があります)、それらは常に一緒に挿入/削除され、グループを個別に生成する必要がないため、1 つの RestartGroup にラップされるだけです。

startMovableGroup

@Composable
fun MoveableGroupTest(list: List<Item>) {
    
    
    Column {
    
    
        list.forEach {
    
     
            Text("Item:$it")
        }
    }
}

上記のコードはリストを表示する例です。リストの各行は for ループで生成されるため、コード位置に基づいて位置メモ化を実装することはできません。新しい項目の挿入など、パラメーター リストが変更された場合、Composer はこの時点でグループの変位を認識できません。削除して再構築するため、再編成のパフォーマンスに影響します。

コンパイラで$key生成、Compose が解決策を提供します。key {...}一意のインデックス キーを手動で追加して、新しい項目を簡単に識別し、再編成のパフォーマンスを向上させることができます。最適化されたコードは次のとおりです。

//Before Compiler
@Composable
fun MoveableGroupTest(list: List<Item>) {
    
    
    Column {
    
    
        list.forEach {
    
     
            key(izt.id) {
    
     //Unique key
                Text("Item:$it")
            }
            
        }
    }
}

上記のコードがコンパイルされた後、startMoveableGroup に挿入されます。

@Composable
fun MoveableGroupTest(list: List<Item>, $composer: Composer?, $changed: Int) {
    
    
    Column {
    
    
        list.forEach {
    
     
            key(it.id) {
    
    
                $composer.startMovableGroup(-846332013, Integer.valueOf(it));
                Text("Item:$it")
                $composer.endMovableGroup();
            }
        }
    }
}

startMoveableGroup のパラメータには、GroupKey に加えて、補助的な DataKey が渡されます。MoveableGroupは、入力されたリストデータに追加・削除や移動があった場合、DataKeyに基づいて破壊・再構築ではなく移動であるかを識別することができ、再編成のパフォーマンスを向上させることができます。

スタートリスタートグループ

RestartGroup は再編成可能なユニットです。毎日のコードで定義する各コンポーザブル関数は独立して再編成に参加できるため、startRestartGroup/endRestartGroup は関数本体に挿入されます。コンパイル前とコンパイル後のコードは次のとおりです。

// Before compiler (sources)
@Composable
fun RestartGroupTest(str: String) {
    
    
    Text(str)
}

// After compiler
@Composable
fun RestartGroupTest(str: String, $composer: Composer<*>, $changed: Int) {
    
    
    $composer.startRestartGroup(-846332013)
    // ...
    Text(str)
    $composer.endRestartGroup()?.updateScope {
    
     next ->
        RestartGroupTest(str, next, $changed or 0b1)
    }
}

startRestartGroup の動作を見てみましょう

//Composer.kt
fun startRestartGroup(key: Int): Composer {
    
    
    start(key, null, false, null)
    addRecomposeScope() 
    return this
}

private fun addRecomposeScope() {
    
    
    //...
    val scope = RecomposeScopeImpl(composition as CompositionImpl)
    invalidateStack.push(scope) 
    updateValue(scope)
    //...
}

ここでは主にSlotTableの作成RecomposeScopeImplと保存を行います。

  • コンポーザブル関数は RecomposeScopeImpl でラップされています。再結合に参加する必要がある場合、Compose は SlotTable からそれを見つけて、無効化RecomposeScopeImpl#invalide()をマークする。再結合が発生すると、コンポーザブル関数が再実行されます。
  • RecomposeScopeImpl はキャッシュされinvalidateStackComposer#endRestartGroup()に。
  • updateScope再編成に参加する必要があるコンポーザブル関数を設定するには、実際には現在の関数を再帰的に呼び出します。なお、endRestartGroup の戻り値は nullable ですが、RestartGroupTest が状態に依存しない場合は再編成に参加する必要がなく、この時点で null が返されます。

Compsoable が再編成に参加する必要があるかどうかに関係なく、生成されたコードは同じであることがわかります。これにより、コード生成ロジックの複雑さが軽減され、判断はランタイム処理に委ねられます。

5. SlotTable の差分と走査

SlotTable の差分

宣言型フレームワークでは、レンダリング ツリーの更新は Diff を通じて実装されます。たとえば、React は、UI 更新のパフォーマンスを向上させるために、VirtualDom の Diff を通じて Dom ツリーの部分的な更新を実装します。

SlotTable は Compose の「Virtual Dom」で、Composable を初めて実行すると、Group と対応する Slot データが SlotTable に挿入されます。Composable が再編成に参加すると、コードのステータスと SlotTable の状態に基づいて Diff が実行され、Composition で更新する必要がある状態が検出され、最終的に LayoutNode ツリーに適用されます。

この Diff プロセスも startXXXGroup プロセスで完了し、具体的な実装は以下に集中していますComposer#start()

//Composer.kt
private fun start(key: Int, objectKey: Any?, isNode: Boolean, data: Any?) {
    
    
    //...
    
    if (pending == null) {
    
    
        val slotKey = reader.groupKey
        if (slotKey == key && objectKey == reader.groupObjectKey) {
    
    
            // 通过 key 的比较,确定 group 节点没有变化,进行数据比较
            startReaderGroup(isNode, data)
        } else {
    
    
            // group 节点发生了变化,创建 pending 进行后续处理
            pending = Pending(
                reader.extractKeys(),
                nodeIndex
            )
        }
    }
    //...
    if (pending != null) {
    
    
        // 寻找 gorup 是否在 Compositon 中存在
        val keyInfo = pending.getNext(key, objectKey)
        if (keyInfo != null) {
    
    
            // group 存在,但是位置发生了变化,需要借助 GapBuffer 进行节点位移
            val location = keyInfo.location
            reader.reposition(location)
            if (currentRelativePosition > 0) {
    
    
                // 对 Group 进行位移
                recordSlotEditingOperation {
    
     _, slots, _ ->
                    slots.moveGroup(currentRelativePosition)
                }
            }
            startReaderGroup(isNode, data)
        } else {
    
    
            //...
            val startIndex = writer.currentGroup
            when {
    
    
                isNode -> writer.startNode(Composer.Empty)
                data != null -> writer.startData(key, objectKey ?: Composer.Empty, data)
                else -> writer.startGroup(key, objectKey ?: Composer.Empty)
            }
        }
    }
    
    //...
}

start メソッドには 4 つのパラメータがあります。

  • key: コンパイル時にコードの位置に基づいて生成されます。$key
  • objectKey: key{} を使用して追加された補助キー
  • isNode: 現在のグループがノードであるかどうか、startXXXNode ではここに true が渡されます
  • data: 現在のグループにデータがあるかどうかに関係なく、プロバイダーは startProviders に渡されます。

start メソッドにはリーダーとライターへの呼び出しが多数ありますが、これらについては後ほど紹介しますが、ここで知っておく必要があるのは、これらが SlotTable 内の現在位置を追跡し、読み取り/書き込み操作を完了できることだけです。上記のコードは改良されており、ロジックは比較的明確です。

  • キー (SlotTable のレコードとコード ステータス) に基づいてグループが同じかどうかを比較します。グループが変更されていない場合は、startReaderGroup を呼び出して、グループ内のデータが変更されたかどうかをさらに判断します。
  • グループが変更された場合は、先頭のグループを追加または移動する必要があることを意味します。pending.getNext を使用して、コンポジションにキーが存在するかどうかを確認します。キーが存在する場合は、グループを移動する必要があることを意味します。シフトはslot.moveGroupを通じて実行されます
  • グループを追加する必要がある場合は、グループのタイプに応じて、別の Writer#startXXX を呼び出して、グループを SlotTable に挿入します。

グループ内のデータ比較は startReaderGroup で実行され、実装が比較的簡単です

private fun startReaderGroup(isNode: Boolean, data: Any?) {
    
    
    //...
    if (data != null && reader.groupAux !== data) {
    
    
        recordSlotTableOperation {
    
     _, slots, _ ->
            slots.updateAux(data)
        }
    }
    //...    
}
  • reader.groupAux現在のスロットのデータを取得し、データと比較します。
  • 異なる場合は、呼び出しrecordSlotTableOperationてデータを更新します。

後で説明するように、SlotTble への更新は即時ではないことに注意してください。

スロットリーダーとスロットライター

上で見たように、開始プロセスにおける SlotTable の読み取りと書き込みは、Composition のリーダーとライターによって完了する必要があります。

ライターとリーダーの両方に、対応する startGroup/endGroup メソッドがあります。ライターの場合、startGroup はグループの挿入や削除など、SlotTable へのデータ変更を表し、リーダーの場合、startGroup は currentGroup ポインターを最新の位置に移動することを表します。currentGroupそして、SlotTable 内で現在アクセスされているグループとスロットの位置currentSlotを指します。

SlotWriter#startGroupグループを挿入する実装を見てください。

private fun startGroup(key: Int, objectKey: Any?, isNode: Boolean, aux: Any?) {
    
    

    //...
    insertGroups(1) // groups 中分配新的位置
    val current = currentGroup 
    val currentAddress = groupIndexToAddress(current)
    val hasObjectKey = objectKey !== Composer.Empty
    val hasAux = !isNode && aux !== Composer.Empty
    groups.initGroup( //填充 Group 信息
        address = currentAddress, //Group 的插入位置
        key = key, //Group 的 key
        isNode = isNode, //是否是一个 Node 
        hasDataKey = hasObjectKey, //是否有 DataKey
        hasData = hasAux, //是否包含数据
        parentAnchor = parent, //关联Parent
        dataAnchor = currentSlot //关联Slot地址
    )
    //...
    val newCurrent = current + 1
    this.parent = current //更新parent
    this.currentGroup = newCurrent 
    //...
}
  • insertGroupsグループ内にグループを挿入するためのスペースを確保するために使用されますが、ここにはギャップ バッファーの概念が関係しますが、これについては後で詳しく説明します。
  • initGroup: startGroup によって渡されたパラメータに基づいてグループ情報を初期化します。これらのパラメータはコンパイル中にさまざまなタイプの startXXXGroup で生成され、実際にはここで SlotTable に書き込まれます。
  • 最後に、currentGroup の最新の位置を更新します。

SlotReader#startGroup実装をもう一度見てください:

fun startGroup() {
    
    
    //...
    parent = currentGroup
    currentEnd = currentGroup + groups.groupSize(currentGroup)
    val current = currentGroup++
    currentSlot = groups.slotAnchor(current)
    //...
}

コードは非常に単純で、主なことは、currentGroup、currentSlot などの位置を更新することです。

SlotTable は、openWriter/openReader を通じてライター/リーダーを作成し、使用後に閉じるためにそれぞれの close を呼び出す必要があります。Reader は同時に複数開くことができますが、Writer は一度に 1 つしか開くことができません。同時実行性の問題を回避するために、ライターとリーダーを同時に実行することはできないため、SlotTable への書き込み操作は再編成後まで遅らせる必要があります。したがって、ソース コードには、書き込み操作を ChangeList への Change として記録し、結合が完了した後にまとめて適用する、recordXXX メソッドが多数存在します。

6. SlotTable の変更による遅延効果

変更を使用して Composer に変更リストを記録する

//Composer.kt
internal class ComposerImpl {
    
    
    //...
    private val changes: MutableList<Change>,
    //...
    
    private fun record(change: Change) {
    
    
        changes.add(change)
    }
}

Change特定の変更ロジックを実行する関数であり、関数のシグネチャとパラメータは次のとおりです。

//Composer.kt
internal typealias Change = (
    applier: Applier<*>,
    slots: SlotWriter,
    rememberManager: RememberManager
) -> Unit
  • applier: Applier は、LayoutNode ツリーに変更を適用するために渡されます。Applier については後で詳しく紹介します。
  • slots: SlotWriter を渡して SlotTable を更新します
  • rememberManger: RememberManager を導入して、Composition ライフ サイクル コールバックを登録します。これにより、特定の時点で特定のサービスを完了できます。たとえば、初めて Comboposition に入るときに LaunchedEffect が CoroutineScope を作成し、Composition を終了するときに DisposableEffect が onDispose を呼び出します。これらはすべてコールバックを登録することで実装されます。ここの。

レコードの変更

remember{}として、変更がどのように記録されるかを見てみましょう。
remember{} のキーと値は、Composition の状態として SlotTable に記録されます。再編成中に、記憶のキーが変更されると、値が再計算され、SlotTable が更新されます。

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

//Composer.kt
@ComposeCompilerApi
inline fun <T> Composer.cache(invalid: Boolean, block: () -> T): T {
    
    
    @Suppress("UNCHECKED_CAST")
    return rememberedValue().let {
    
    
        if (invalid || it === Composer.Empty) {
    
    
            val value = block()
            updateRememberedValue(value)
            value
        } else it
    } as T
}

上記はrememberのソースコードです

  • Composer#changedこのメソッドでは、SlotTable に格納されているキーが読み取られ、key1 と比較されます。
  • Composer#cache、rememberedValue は、SlotTable にキャッシュされている現在の値を読み取ります。
  • このときキーの比較で違いが見つかった場合は、ブロックを呼び出して新しい値を計算して返し、updateRememberedValue を呼び出してその値を SlotTable に更新します。

updateRememberedValue は最終的に呼び出されますComposer#updateValue。具体的な実装を見てみましょう。

//Composer.kt
internal fun updateValue(value: Any?) {
    
    
    //...
    val groupSlotIndex = reader.groupSlotIndex - 1 //更新位置Index
    
    recordSlotTableOperation(forParent = true) {
    
     _, slots, rememberManager ->
        if (value is RememberObserver) {
    
    
            rememberManager.remembering(value) 
        }
        when (val previous = slots.set(groupSlotIndex, value)) {
    
    //更新
            is RememberObserver ->
                rememberManager.forgetting(previous)
            is RecomposeScopeImpl -> {
    
    
                val composition = previous.composition
                if (composition != null) {
    
    
                    previous.composition = null
                    composition.pendingInvalidScopes = true
                }
            }
        }
    }
    //...
}

//记录更新 SlotTable 的 Change

private fun recordSlotTableOperation(forParent: Boolean = false, change: Change) {
    
    
    realizeOperationLocation(forParent)
    record(change) //记录 Change
}

ここでのキーコードは、次のrecordSlotTableOperation呼び出し。

  • Change を変更リストに追加します。Change の内容は、SlotWriter#set を通じて SlotTable の指定された位置に値を更新することであり、groupSlotIndexスロット内の計算された値のオフセットです。
  • previous一部の後処理に使用できる、remember の古い値を返します。ここから、RememberObserver と RecomposeScopeImpl も Composite の状態であることがわかります。
    • RememberObserver はライフサイクル コールバックであり、RememberManager#forgetting によって登録され、以前が構成から削除されると RememberObserver に通知されます。
    • RecomposeScopeImpl は再構成ユニットです。pendingInvalidScopes = trueこれは、この再構成ユニットが構成から離れることを意味します。

覚えておくことに加えて、ノードの削除や移動など、SlotTable 構造に関連する他の変更も変更によって遅延します (挿入操作はリーダーにほとんど影響を与えないため、すぐに適用されます)。この例では、リメンバーシーンの変更は LayoutNode の更新を伴わないため、 RecordSlotTableOperation ではApplierパラメーター。ただし、競合によって SlotTable 構造が変更される場合は、その変更を LayoutNoel ツリーに適用する必要があり、この時点で Applier が使用されます。

変更を適用

前述したように、記録された変更は、組み合わせが完了するまで待ってから実行されます。

コンポーザブルの合成は、最初に実行されるときにコンポーザブルRecomposer#composeIntial内で行われます。

//Composition.kt
override fun setContent(content: @Composable () -> Unit) {
    
    
    //...
    this.composable = content
    parent.composeInitial(this, composable)
}

//Recomposer.kt
internal override fun composeInitial(
    composition: ControlledComposition,
    content: @Composable () -> Unit
) {
    
    
    //...
    composing(composition, null) {
    
    
        composition.composeContent(content) //执行组合
    }
    //...

    composition.applyChanges() //应用 Changes
    //...
}

ご覧のとおり、合成の直後にComposition#applyChanges()applychanges同様に、再編成が行われるたびに applyChanges が呼び出されます。

override fun applyChanges() {
    
    
      
      val manager = ...
      //...
      applier.onBeginChanges()
      // Apply all changes
      slotTable.write {
    
     slots ->
          val applier = applier
          changes.fastForEach {
    
     change ->
              change(applier, slots, manager)
          }
          hanges.clear()
       }
       applier.onEndChanges()
       //...
}

applyChanges 内の変更の走査と実行を確認します。さらに、applyChanges の開始と終了は Applier によってコールバックされます。

7. UiApplier と LayoutNode

SlotTable 構造の変更は LayoutNode ツリーにどのように反映されますか?

先ほど、コンポーザブル コンポジションの実行後に生成されるレンダリング ツリーを呼び出しました。実際、Composition はこのレンダリング ツリーをマクロに認識したもので、正確に言うと、Applier を通じて LayoutNode ツリーを維持し、特定のレンダリングを実行します。SlotTable 構造の変更は、Change リストの適用により LayoutNode ツリーに反映されます。

View と同様に、LayoutNode は、measure/layout/drawなどの。また、サブツリー構造の変更を実現するためのinsertAt/removeAtなどのメソッドも提供します。これらのメソッドは UiApplier で呼び出されます。

//UiApplier.kt
internal class UiApplier(
    root: LayoutNode
) : AbstractApplier<LayoutNode>(root) {
    
    

    override fun insertTopDown(index: Int, instance: LayoutNode) {
    
    
        // Ignored
    }

    override fun insertBottomUp(index: Int, instance: LayoutNode) {
    
    
        current.insertAt(index, instance)
    }

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

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

    override fun onClear() {
    
    
        root.removeAll()
    }

}

UiApplier は、LayoutNode ツリーの更新と変更に使用されます。

  • down()/up()現在の位置を移動してツリー上のナビゲーションを完了するために使用されます。
  • insertXXX/remove/moveツリーの構造を変更するために使用されます。insertTopDowninsertBottomUp新しいノードを挿入するために使用されますが、挿入方法は異なります。1 つはボトムアップで、もう 1 つはトップダウンです。ツリー構造ごとに異なる挿入順序を選択すると、パフォーマンスの向上に役立ちます。たとえば、Android 側の UiApplier は主に insertBottomUp に依存して新しいノードを挿入します。これは、Android のレンダリング ロジックでは、子ノードの変更が親ノードの再測定に影響するためです。それ以来、下向きの挿入により、多くのノードへの影響を回避できます。親ノードのアタッチは最後に行われるため、パフォーマンスが向上します。

Composable の実行プロセスは、Applier 抽象インターフェイスにのみ依存します。UiApplier と LayoutNode は、Android プラットフォームの対応する実装にすぎません。理論的には、Applier と Node をカスタマイズすることで独自のレンダリング エンジンを作成できます。たとえば、Jake Wharton には Mosaic というプロジェクトがあり、Applier と Node をカスタマイズすることでカスタム レンダリング ロジックを実装しています。

ルートノードの作成

Android プラットフォームでは、Activity#setContent次のように。

//Wrapper.android.kt
internal fun AbstractComposeView.setContent(
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    
    
    //...
    val composeView = ...
    return doSetContent(composeView, parent, content)
}

private fun doSetContent(
    owner: AndroidComposeView,
    parent: CompositionContext,
    content: @Composable () -> Unit
): Composition {
    
    
    //...
    val original = Composition(UiApplier(owner.root), parent)
    val wrapped = owner.view.getTag(R.id.wrapped_composition_tag)
        as? WrappedComposition
        ?: WrappedComposition(owner, original).also {
    
    
            owner.view.setTag(R.id.wrapped_composition_tag, it)
        }
    wrapped.setContent(content)
    return wrapped
}
  • doSetContentでコンポジションインスタンスを作成し、ルートノードにバインドされたアプライヤーを渡します。Root NodeがAndroidComposeView保持されており、ViewワールドからのdispatchDrawKeyEventなどtouchEventがここからRoot Nodeを経由してComposeワールドに渡されます。
  • WrappedCompositionこれはデコレータであり、Composition と AndroidComposeView の間の接続を確立するためにも使用されます。たとえば、私たちが一般的に使用する Android の CompositionLocals の多くはここで構築されLocalContextますLocalConfiguration

8. SlotTable とコンポーザブルのライフサイクル

Composable のライフ サイクルは次の 3 つの段階に要約できます。SlotTable について理解したので、SlotTable の観点から説明することもできます。

  • Enter: startRestartGroup で、Composable に対応するグループを SlotTable に格納します
  • Recompose: SlotTable で Composable (RecomposeScopeImpl による) を検索し、再実行して SlotTable を更新します
  • Leave: コンポーザブルに対応するグループが SlotTable から削除されます。

Composable で副作用 API を使用すると、コンポーザブルのライフサイクル コールバックとして機能して、

DisposableEffect(Unit) {
    
    
    //callback when entered the Composition & recomposed
    onDispose {
    
     
        //callback for leaved the Composition
    }
}

DisposableEffect を例として、SlotTable システムに基づいてライフ サイクル コールバックがどのように完了するかを見てみましょう。DisposableEffect の実装を見てください。コードは次のとおりです。

@Composable
@NonRestartableComposable
fun DisposableEffect(
    key1: Any?,
    effect: DisposableEffectScope.() -> DisposableEffectResult
) {
    
    
    remember(key1) {
    
     DisposableEffectImpl(effect) }
}


private class DisposableEffectImpl(
    private val effect: DisposableEffectScope.() -> DisposableEffectResult
) : RememberObserver {
    
    
    private var onDispose: DisposableEffectResult? = null

    override fun onRemembered() {
    
    
        onDispose = InternalDisposableEffectScope.effect()
    }

    override fun onForgotten() {
    
    
        onDispose?.dispose()
        onDispose = null
    }

    override fun onAbandoned() {
    
    
        // Nothing to do as [onRemembered] was not called.
    }
}

DisposableEffect の本質は、RememberObserver の実装である SlotTable に DisposableEffectImpl を格納するために remember を使用することであることがわかります。DisposableEffectImpl はonRememberedonForgotten親グループが SlotTable に出入りするときに受信してコールバックします。

前に述べた applyChanges を思い出してください。これは再編成が完了した後に発生します。

override fun applyChanges() {
    
    
      
  val manager = ... // 创建 RememberManager
  //...
  // Apply all changes
  slotTable.write {
    
     slots ->
      //...
      changes.fastForEach {
    
     change ->
          //应用 changes, 将 ManagerObserver 注册进 RememberMananger
          change(applier, slots, manager)
      }
      //...
  }
  //...
  manager.dispatchRememberObservers() //分发回调
}

前述したように、SlotTable の書き込み操作中に発生する変更はここで一律に適用されますが、もちろん DisposableEffectImpl の挿入/削除時のレコードの変更も含まれます。具体的には、コールバックされる ManagerObserver の登録です。以下dispatchRememberObservers

リストラは楽観的

公式サイトの資料には再編の導入にこんな一節がある:再編は「楽観的」

再構成がキャンセルされると、Compose は再構成から UI ツリーを破棄します。表示中のUIに依存する副作用がある場合、合成をキャンセルしても副作用は適用されます。これにより、アプリの状態が不一致になる可能性があります。

オプティミスティックな再構成を処理できるように、すべてのコンポーズ可能な関数とラムダが冪等で副作用がないことを確認します。

https://developer.android.com/jetpack/compose/mental-model#optimistic

この部分を一見しただけでは理解できない人も多いと思いますが、ソースコードを読めばその意味が理解できると思います。ここでのいわゆる「楽観的」とは、Compose の再編成が常に中断されないと想定されていることを意味します。中断が発生すると、Composable で実行された操作は実際には SlotTable に反映されません。ソース コードから、applyChanges がその後に発生することがわかっているためです。合成は正常に終了しました。

合成が中断された場合、Composable 関数で読み取った状態は最終的な SlotTable と矛盾する可能性があります。したがって、Composition の状態に基づいて副作用処理を実行する必要がある場合は、ソース コードを通じて、DisposableEffect のコールバックが applyChanges によって実行されることがわかっているため、DisposableEffect などの副作用 API パッケージを使用する必要があります。時間が経過すると、再編成が完了し、取得された状態が SlotTable と同じであることを確認でき、一貫性が保たれます。

9. SlotTable と GapBuffer

前述したように、startXXXGroup は SlotTable の Group と Diff を行います。比較が等しくない場合は、SlotTable の構造が変更されたことを意味するため、Group を挿入/削除/移動する必要があります。この処理は Gap Buffer に基づいて実装されます。

ギャップ バッファの概念は、テキスト エディタのデータ構造から来ています。これは、線形配列内のスライド可能でスケーラブルなバッファ領域として理解できます。具体的には、SlotTable では、グループ内の未使用領域です。この領域は移動できます。 groups を使用して、SlotTble 構造が変更された場合の更新効率を向上させるために、次の例を示します。

@Composable
fun Test(condition: Boolean) {
    
     
    if (condition) {
    
    
        Node1()
        Node2()
    }
    Node3()
    Node4()
}

SlotTable には最初は Node3 と Node4 しかありませんが、状態の変化に応じて Node1 と Node2 を挿入する必要があります。このプロセス中にギャップ バッファーがない場合、SlotTable の変化は次の図に示されています。

新しいノードが挿入されるたびに、SlotTable 内の既存のノードが移動されるため、非効率的です。Gap Buffer を導入した後の動作を見てみましょう。

新しいノードを挿入する場合、配列内のギャップを挿入する位置に移動してから、新しいノードが挿入されます。ノード 1、ノード 2、さらにはそれらのサブノードを挿入すると、ギャップの空き領域がすべて埋められ、ノードの動きには影響しません。
モバイル ギャップの具体的な実装を見てください。関連するコードは次のとおりです。

//SlotTable.kt
private fun moveGroupGapTo(index: Int) {
    
    

    //...
            val groupPhysicalAddress = index * Group_Fields_Size
            val groupPhysicalGapLen = gapLen * Group_Fields_Size
            val groupPhysicalGapStart = gapStart * Group_Fields_Size
            if (index < gapStart) {
    
    
                groups.copyInto(
                    destination = groups,
                    destinationOffset = groupPhysicalAddress + groupPhysicalGapLen,
                    startIndex = groupPhysicalAddress,
                    endIndex = groupPhysicalGapStart
                )
            } 
      //...     
}
  • Indexグループを挿入する位置です。つまり、ギャップをここに移動する必要があります
  • Group_Fields_Sizeこれはグループ内のユニット Group の長さであり、現在は定数 5 です。

いくつかの一時変数の意味も非常に明確です。

  • groupPhysicalAddress: 現在、グループのアドレスを挿入する必要があります
  • groupPhysicalGapLen: 現在のギャップの長さ
  • groupPhysicalGapStart: 現在のギャップの開始アドレス

そのindex < gapState時点、新しい挿入の準備として、ギャップをインデックス位置まで前方に移動する必要があります。copyInto次のパラメータから、ギャップの前方への移動は、実際にはグループを後方に移動することによって実現されることがわかります。つまりstartIndex、下の図に示すように、ノードがギャップの新しい位置にコピーされます。

この方法では、実際にギャップを移動する必要はなく、ギャップの開始ポインタを に移動するgroupPyhsicalAddressだけで、新しい Node1 がここに挿入されます。もちろん、グループを移動した後は、アンカーなどの関連情報もそれに応じて更新する必要があります。

最後に、ノードを削除するときのギャップの動きを見てみましょう。原理は同様です。

削除するグループの前にギャップを移動してからノードを削除すると、削除プロセスは実際にはギャップの終了位置を移動するだけとなり、非常に効率的であり、ギャップの連続性が保証されます。

10. まとめ

SlotTable システムは、合成から画面へのレンダリングに至る Compose のプロセス全体において最も重要なリンクです。次の図と組み合わせて、プロセス全体を確認してみましょう。

  1. コンポーズ可能なソース コードは、SlotTable のツリー トラバーサルのコンパイル中に startXXXGroup/endXXXGroup テンプレート コードに挿入されます。
  2. Composable の最初の組み合わせでは、startXXXGroup が SlotTable にグループを挿入し、$key を使用してコード内のグループの位置を識別します。
  3. 再編成中、startXXXGroup は SlotTable を走査して比較し、変更による SlotTable の更新を遅らせ、同時に LayoutNode ツリーに適用します。
  4. レンダリングされたフレームが到着すると、LayoutNode は変更された部分に対して測定 > レイアウト > 描画を実行して、UI の部分的な更新を完了します。

おすすめ

転載: blog.csdn.net/vitaviva/article/details/125478624