深入理解 Jetpack Compose:SlotTable 系統

引言

Compose 的繪制有三個階段,組合 > 布局 > 繪制。后兩個過程與傳統視圖的渲染過程相近,唯獨組合是 Compose 所特有的。Compose 通過組合生成渲染樹,這是 Compose 框架的核心能力,而這個過程主要是依賴 SlotTable 實現的,本文就來介紹一下 SlotTable 系統。

1. 從 Compose 渲染過程說起

基于 Android 原生視圖的開發過程,其本質就是構建一棵基于 View 的渲染樹,當幀信號到達時從根節點開始深度遍歷,依次調用 measure/layout/draw,直至完成整棵樹的渲染。對于 Compose 來說也存在這樣一棵渲染樹,我們將其稱為 Compositiion,樹上的節點是 LayoutNode,Composition 通過 LayoutNode 完成 measure/layout/draw 的過程最終將 UI 顯示到屏幕上。Composition 依靠 Composable 函數的執行來創建以及更新,即所謂的組合和重組

例如上面的 Composable 代碼,經過執行后會生成右側的 Composition。

一個函數經過執行是如何轉換成 LayoutNode 的呢?深入 Text 的源碼后發現其內部調用了 Layout, Layout 是一個可以自定義布局的 Composable,我們直接使用的各類 Composable 最終都是通過調用 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。

  • factory 就是創建 LayoutNode 的工廠
  • update 用來記錄會更新 Node 的狀態用于后續渲染

繼續進入 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 更新 Node 狀態
  • content() 繼續執行內部 Composable,創建子節點。

此外,代碼中還穿插著了一些 startXXX/endXXX ,這樣的成對調用就好似對一棵樹進行深度遍歷時的壓棧/出棧

startReusableNode
    NodeData // Node數據
    startReplaceableGroup
        GroupData //Group數據
        ... // 子Group
    endGroup
endNode

不只是 ReusableComposeNode 這樣的內置 Composable,我們自己寫的 Composable 函數體經過編譯后的代碼也會插入大量的 startXXX/endXXX,這些其實都是 Composer 對 SlotTable 訪問的過程,Composer 的職能就是通過對 SlotTable 的讀寫來創建和更新 Composition

下圖是 Composition,Composer 與 SlotTable 的關系類圖

2. 初識 SlotTable

前文我們將 Composable 執行后生成的渲染樹稱為 Compositioin。其實更準確來說,Composition 中存在兩棵樹,一棵是 LayoutNode 樹,這是真正執行渲染的樹,LayoutNode 可以像 View 一樣完成 measure/layout/draw 等具體渲染過程;而另一棵樹是 SlotTable,它記錄了 Composition 中的各種數據狀態。 傳統視圖的狀態記錄在 View 對象中,在 Compose 面向函數編程而不面向對象,所以這些狀態需要依靠 SlotTable 進行管理和維護。

Composable 函數執行過程中產生的所有數據都會存入 SlotTable, 包括 State、CompositionLocal,remember 的 key 與 value 等等 ,這些數據不隨函數的出棧而消失,可以跨越重組存在。Composable 函數在重組中如果產生了新數據則會更新 SlotTable。

SlotTable 的數據存儲在 Slot 中,一個或多個 Slot 又歸屬于一個 Group??梢詫?Group 理解為樹上的一個個節點。說 SlotTable 是一棵樹,其實它并非真正的樹形數據結構,它用線性數組來表達一棵樹的語義,從 SlotT
able 的定義中可以看到這一點:

//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 有兩個數組成員,groups 數組存儲 Group 信息,slots 存儲 Group 所轄的數據。用數組替代結構化存儲的好處是可以提升對“樹”的訪問速度。 Compose 中重組的頻率很高,重組過程中會不斷的對 SlotTable 進行讀寫,而訪問數組的時間復雜度只有 O(1),所以使用線性數組結構有助于提升重組的性能。

groups 是一個 IntArray,每 5 個 Int 為一組構成一個 Group 的信息

  • key : Group 在 SlotTable 中的標識,在 Parent Group 范圍內唯一
  • Group info: Int 的 Bit 位中存儲著一些 Group 信息,例如是否是一個 Node,是否包含 Data 等,這些信息可以通過位掩碼來獲取。
  • Parent anchor: Parent 在 groups 中的位置,即相對于數組指針的偏移
  • Size: Group: 包含的 Slot 的數量
  • Data anchor:關聯 Slot 在 slots 數組中的起始位置

slots 是真正存儲數據的地方,Composable 執行過程中可以產生任意類型的數據,所以數組類型是 Any?。每個 Gorup 關聯的 Slot 數量不定,Slot 在 slots 中按照所屬 Group 的順序依次存放。

groups 和 slots 不是鏈表,所以當容量不足時,它們會進行擴容。

3. 深入理解 Group

Group 的作用

SlotTable 的數據存儲在 Slot 中,為什么充當樹上節點的單位不是 Slot 而是 Group 呢?因為 Group 提供了以下幾個作用:

  • 構建樹形結構: Composable 首次執行過程中,在 startXXXGroup 中會創建 Group 節點存入 SlotTable,同時通過設置 Parent anchor 構建 Group 的父子關系,Group 的父子關系是構建渲染樹的基礎。

  • 識別結構變化: 編譯期插入 startXXXGroup 代碼時會基于代碼位置生成可識別的 $key(parent 范圍內唯一)。在首次組合時 $key 會隨著 Group 存入 SlotTable,在重組中,Composer 基于 $key 的比較可以識別出 Group 的增、刪或者位置移動。換言之,SlotTable 中記錄的 Group 攜帶了位置信息,故這種機制也被稱為 Positional Memoization。Positional Memoization 可以發現 SlotTable 結構上的變化,最終轉化為 LayoutNode 樹的更新。

  • 重組的最小單位: Compose 的重組是“智能”的,Composable 函數或者 Lambda 在重組中可以跳過不必要的執行。在 SlotTtable 上,這些函數或 lambda 會被包裝為一個個 RestartGroup ,因此 Group 是參與重組的最小單位。

Group 的類型

Composable 在編譯期會生成多種不同類型的 startXXXGroup,它們在 SlotTable 中插入 Group 的同時,會存入輔助信息以實現不同的功能:

startXXXGroup 說明
startNode/startReusableNode 插入一個包含 Node 的 Group。例如文章開頭 ReusableComposeNode 的例子中,顯示調用了 startReusableNode ,而后調用 createNode 在 Slot 中插入 LayoutNode。
startRestartGroup 插入一個可重復執行的 Group,它可能會隨著重組被再次執行,因此 RestartGroup 是重組的最小單元。
startReplaceableGroup 插入一個可以被替換的 Group,例如一個 if/else 代碼塊就是一個 ReplaceableGroup,它可以在重組中被插入后者從 SlotTable 中移除。
startMovableGroup 插入一個可以移動的 Group,在重組中可能在兄弟 Group 之間發生位置移動。
startReusableGroup 插入一個可復用的 Group,其內部數據可在 LayoutNode 之間復用,例如 LazyList 中同類型的 Item。

當然 startXXXGroup 不止用于插入新 Group,在重組中也會用來追蹤 SlotTable 的已有 Group,與當前執行
中的代碼情況進行比較。接下來我們看下幾種不同類型的 startXXXGroup 出現在什么樣的代碼中。

4. 編譯期生成的 startXXXGroup

前面介紹了 startXXXGroup 的幾種類型,我們平日在寫 Compose 代碼時,對他們毫無感知,那么他們分別是在何種情況下生成的呢?下面看幾種常見的 startXXXGroup 的生成時機:

startReplaceableGroup

前面提到過 Positional Memoization 的概念,即 Group 存入 SlotTable 時,會攜帶基于位置生成的 $key,這有助于識別 SlotTable 的結構變化。下面的代碼能更清楚地解釋這個特性

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

這段代碼,當 condition 從 true 變為 false,意味著渲染樹應該移除舊的 Text Node 1 ,并添加新的 Text Node 2。源碼中我們沒有為 Text 添加可辨識的 key,如果僅按照源碼執行,程序無法識別出 counditioin 變化前后 Node 的不同,這可能導致舊的節點狀態依然殘留,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 發生變化時,我們可以識別 Group 發生了變化,從而從結構上變更 SlotTable,而不只是更新原有 Node。

if/else 內部即使調用了多個 Composable(比如可能出現多個 Text) ,它們也只會包裝在一個 RestartGroup ,因為它們總是被一起插入/刪除,無需單獨生成 Group 。

startMovableGroup

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

上面代碼是一個顯示列表的例子。由于列表的每一行在 for 循環中生成,無法基于代碼位置實現 Positional Memoization,如果參數 list 發生了變化,比如插入了一個新的 Item,此時 Composer 無法識別出 Group 的位移,會對其進行刪除和重建,影響重組性能。

針對這類無法依靠編譯器生成 $key 的問題,Compose 給了解決方案,可以通過 key {...} 手動添加唯一索引 key,便于識別 Item 的新增,提升重組性能。經優化后的代碼如下:

//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。當輸入的 list 數據中出現了增/刪或者位移時,MoveableGroup 可以基于 DataKey 識別出是否是位移而非銷毀重建,提升重組的性能。

startRestartGroup

RestartGroup 是一個可重組單元,我們在日常代碼中定義的每個 Composable 函數都可以單獨參與重組,因此它們的函數體中都會插入 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)
    //...
}

這里主要是創建 RecomposeScopeImpl 并存入 SlotTable 。

  • RecomposeScopeImpl 中包裹了一個 Composable 函數,當它需要參與重組時,Compose 會從 SlotTable 中找到它并調用 RecomposeScopeImpl#invalide() 標記失效,當重組來臨時 Composable 函數被重新執行。
  • RecomposeScopeImpl 被緩存到 invalidateStack,并在 Composer#endRestartGroup() 中返回。
  • updateScope 為其設置需要參與重組的 Composable 函數,其實就是對當前函數的遞歸調用。注意 endRestartGroup 的返回值是可空的,如果 RestartGroupTest 中不依賴任何狀態則無需參與重組,此時將返回 null。

可見,無論 Compsoable 是否有必要參與重組,生成代碼都一樣。這降低了代碼生成邏輯的復雜度,將判斷留到運行時處理。

5. SlotTable 的 Diff 與遍歷

SlotTable 的 Diff

聲明式框架中,渲染樹的更新都是通過 Diff 實現的,比如 React 通過 VirtualDom 的 Diff 實現 Dom 樹的局部更新,提升 UI 刷新的性能。

SlotTable 就是 Compose 的 “Virtual Dom”,Composable 初次執行時在 SlotTable 中插入 Group 和對應的 Slot 數據。 當 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 方法有四個參數:

  • key: 編譯期基于代碼位置生成的 $key
  • objectKey: 使用 key{} 添加的輔助 key
  • isNode:當前 Group 是否是一個 Node,在 startXXXNode 中,此處會傳入 true
  • data:當前 Group 是否有一個數據,在 startProviders 中會傳入 providers

start 方法中有很多對 reader 和 writer 的調用,稍后會對他們作介紹,這里只需要知道他們可以追蹤 SlotTable 中當前應該訪問的位置,并完成讀/寫操作。上面的代碼已經經過提煉,邏輯比較清晰:

  • 基于 key 比較 Group 是否相同(SlotTable 中的記錄與代碼現狀),如果 Group 沒有變化,則調用 startReaderGroup 進一步判斷 Group 內的數據是否發生變化
  • 如果 Group 發生了變化,則意味著 start 中 Group 需要新增或者位移,通過 pending.getNext 查找 key 是否在 Composition 中存在,若存在則表示需要 Group 需要位移,通過 slot.moveGroup 進行位移
  • 如果 Group 需要新增,則根據 Group 類型,分別調用不同的 writer#startXXX 將 Group 插入 SlotTable

Group 內的數據比較是在 startReaderGroup 中進行的,實現比較簡單

private fun startReaderGroup(isNode: Boolean, data: Any?) {
    //...
    if (data != null && reader.groupAux !== data) {
        recordSlotTableOperation { _, slots, _ ->
            slots.updateAux(data)
        }
    }
    //...    
}
  • reader.groupAux 獲取當前 Slot 中的數據與 data 做比較
  • 如果不同,則調用 recordSlotTableOperation 對數據進行更新。

注意對 SlotTble 的更新并非立即生效,這在后文會作介紹。

SlotReader & SlotWriter

上面看到,start 過程中對 SlotTable 的讀寫都需要依靠 Composition 的 reader 和 writer 來完成。

writer 和 reader 都有對應的 startGroup/endGroup 方法。對于 writer 來說 startGroup 代表對 SlotTable 的數據變更,例如插入或刪除一個 Group ;對于 reader 來說 startGroup 代表著移動 currentGroup 指針到最新位置。currentGroupcurrentSlot 指向 SlotTable 當前訪問中的 Group 和 Slot 的位置。

看一下 SlotWriter#startGroup 中插入一個 Group 的實現:

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 用來在 groups 中分配插入 Group 用的空間,這里會涉及到 Gap Buffer 概念,我們在后文會詳細介紹。
  • initGroup:基于 startGroup 傳入的參數初始化 Group 信息。這些參數都是在編譯期隨著不同類型的 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 創建 writer/reader,使用結束需要調用各自的 close 關閉。reader 可以 open 多個同時使用,而 writer 同一時間只能 open 一個。為了避免發生并發問題, writer 與 reader 不能同時執行,所以對 SlotTable 的 write 操作需要延遲到重組后進行。因此我們在源碼中看到很多 recordXXX 方法,他們將寫操作提為一個 Change 記錄到 ChangeList,等待組合結束后再一并應用。

6. SlotTable 變更延遲生效

Composer 中使用 changes 記錄變動列表

//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 生命周期回調,可以在特定時間點完成特定業務,比如 LaunchedEffect 在首次進入 Composition 時創建 CoroutineScope, DisposableEffect 在從 Composition 中離開時調用 onDispose ,這些都是通過在這里注冊回調實現的。

記錄 Change

我們以 remember{} 為例看一下 Change 如何被記錄。
remember{} 的 key 和 value 都會作為 Composition 中的狀態記錄到 SlotTable 中。重組中,當 remember 的 key 發生變化時,value 會重新計算 value 并更新 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 中存儲的 key 與 key1 進行比較
  • Composer#cache 中,rememberedValue 會讀取 SlotTable 中緩存的當前 value。
  • 如果此時 key 的比較中發現了不同,則調用 block 計算并返回新的 value,同時調用 updateRememberedValue 將 value 更新到 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 加入到 changes 列表,這里 Change 的內容是通過 SlotWriter#set 將 value 更新到 SlotTable 的指定位置,groupSlotIndex 是計算出的 value 在 slots 中的偏移量。
  • previous 返回 remember 的舊 value ,可用來做一些后處理。從這里也可以看出, RememberObserver 與 RecomposeScopeImpl 等也都是 Composition 中的狀態。
    • RememberObserver 是一個生命周期回調,RememberManager#forgetting 對其進行注冊,當 previous 從 Composition 移除時,RememberObserver 會收到通知
    • RecomposeScopeImpl 是可重組的單元,pendingInvalidScopes = true 意味著此重組單元從 Composition 中離開。

除了 remember,其他涉及到 SlotTable 結構的變化,例如刪除、移動節點等也會借助 changes 延遲生效(插入操作對 reader 沒有影響不大故會立即應用)。例子中 remember 場景的 Change 不涉及 LayoutNode 的更新,所以 recordSlotTableOperation 中沒有使用到 Applier 參數。但是當種族造成 SlotTable 結構發生變化時,需要將變化應用到 LayoutNoel 樹,這時就要使用到 Applier 了。

應用 Change

前面提到,被記錄的 changes 等待組合完成后再執行。

當 Composable 首次執行時,在 Recomposer#composeIntial 中完成 Composable 的組合

//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() 應用 changes。同樣,在每次重組發生后也會調用 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 內部看到對 changes 的遍歷和執行。 此外還會通過 Applier 回調 applyChanges 的開始和結束。

7. UiApplier & LayoutNode

SlotTable 結構的變化是如何反映到 LayoutNode 樹上的呢?

前面我們將 Composable 執行后生成的渲染樹稱為 Composition。其實 Composition 是對這一棵渲染樹的宏觀認知,準確來說 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() 用來移動 current 的位置,完成樹上的導航。
  • insertXXX/remove/move 用來修改樹的結構。其中 insertTopDowninsertBottomUp 都用來插入新節點,只是插入的方式有所不同,一個是自下而上一個是自頂而下,針對不同的樹形結構選擇不同的插入順序有助于提高性能。例如 Android 端的 UiApplier 主要依靠 insertBottomUp 插入新節點,因為 Android 的渲染邏輯下,子節點的變動會影響父節點的重新 measure,自此向下的插入可以避免影響太多的父節點,提高性能,因為 attach 是最后才進行。

Composable 的執行過程只依賴 Applier 抽象接口,UiApplier 與 LayoutNode 只是 Android 平臺的對應實現,理論上我們通過自定義 Applier 與 Node 可以打造自己的渲染引擎。例如 Jake Wharton 有一個名為 Mosaic 的項目,就是通過自定義 Applier 和 Node 實現了自定義的渲染邏輯。

Root Node的創建

Android 平臺下,我們在 Activity#setContent 中調用 Composable:

//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 中創建 Composition 實例,同時傳入了綁定 Root Node 的 Applier。Root Node 被 AndroidComposeView 持有,來自 View 世界的 dispatchDraw 以及 KeyEvent,touchEvent 等就是從這里通過 Root Node 傳遞到了 Compose 世界。
  • WrappedComposition 是一個裝飾器,也是用來為 Composition 與 AndroidComposeView 建立連接,我們常用的很多來自 Android 的 CompositionLocal 就是這里構建的,比如 LocalContextLocalConfiguration 等等。

8. SlotTable 與 Composable 生命周期

Composable 的生命周期可以概括為以下三階段,現在認識了 SlotTable 之后,我們也可以從 SlotTable 的角度對其進行解釋:

  • Enter:startRestartGroup 中將 Composable 對應的 Group 存入 SlotTable
  • Recompose:SlotTable 中查找 Composable (by RecomposeScopeImpl) 重新執行,并更新 SlotTable
  • Leave:Composable 對應的 Group 從 SlotTable 中移除。

在 Composable 中使用副作用 API 可以充當 Composable 生命周期回調來使用

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 的本質就是使用 remember 向 SlotTable 存入一個 DisposableEffectImpl,這是一個 RememberObserver 的實現。 DisposableEffectImpl 隨著父 Group 進入和離開 SlotTable ,將接收到 onRememberedonForgotten 的回調。

還記得前面講過的 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 寫操作中發生的 changes 將在這里統一應用,當然也包括了 DisposableEffectImpl 插入/刪除時 record 的 changes,具體來說就是對 ManagerObserver 的注冊,會在后面的 dispatchRememberObservers 中進行回調。

重組是樂觀的

官網文檔中在介紹重組有這樣一段話:重組是“樂觀”的

When recomposition is canceled, Compose discards the UI tree from the recomposition. If you have any side-effects that depend on the UI being displayed, the side-effect will be applied even if composition is canceled. This can lead to inconsistent app state.

Ensure that all composable functions and lambdas are idempotent and side-effect free to handle optimistic recomposition.

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

很多人初看這段話會不明所以,但是在解讀了源碼之后相信能夠理解它的含義了。這里所謂 “樂觀” 是指 Compose 的重組總是假定不會被中斷,一旦發生了中斷,Composable 中執行的操作并不會真正反映到 SlotTable,因為通過源碼我們知道了 applyChanges 發生在 composiiton 成功結束之后。

如果組合被中斷,你在 Composable 函數中讀取的狀態很可能和最終 SlotTable 中的不一致。因此如果我們需要基于 Composition 的狀態進行一些副作用處理,必須要使用 DisposableEffect 這樣的副作用 API 包裹,因為通過源碼我們也知道了 DisposableEffect 的回調是 applyChanges 執行的,此時可以確保重組已經完成,獲取的狀態與 SlotTable 相一致。

9. SlotTable 與 GapBuffer

前面介紹過,startXXXGroup 中會與 SlotTable 中的 Group 進行 Diff,如果比較不相等,則意味著 SlotTable 的結構發生了變化,需要對 Group 進行插入/刪除/移動,這個過程是基于 Gap Buffer 實現的。

Gap Buffer 概念來自文本編輯器中的數據結構,可以將它理解為線性數組中可滑動、可伸縮的緩存區域,具體到 SlotTable 中,就是 groups 中的未使用的區域,這段區域可以在 groups 移動,提升 SlotTble 結構變化時的更新效率,以下舉例說明:

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

SlotTable 初始只有 Node3,Node4,而后根據狀態變化,需要插入 Node1,Node2,這個過程中如果沒有 Gap Buffer,SlotTable 的變化如下圖所示:

每次插入新 Node 都會導致 SlotTable 中已有 Node 的移動,效率低下。再看一下引入 Gap Buffer 之后的行為:

當插入新 Node 時,會將數組中的 Gap 移動到待插入位置,然后再開始插入新 Node。再插入 Node1,Node2 甚至它們的子 Node,都是在填充 Gap 的空閑區域,不會影響造成 Node 的移動。
看一下移動 Gap 的具體實現,相關代碼如下:

//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 的位置,即需要將 Gap 移動到此處
  • Group_Fields_Size 是 groups 中單位 Group 的長度,目前是常量 5。

幾個臨時變量的含義也非常清晰:

  • groupPhysicalAddress: 當前需要插入 group 的地址
  • groupPhysicalGapLen: 當前Gap 的長度
  • groupPhysicalGapStart:當前Gap 的起始地址

index < gapState 時,需要將 Gap 前移到 index 位置為新插入做準備。從后面緊跟的 copyInto 的參數可知,Gap 的前移實際是通過 group 后移實現的,即將 startIndex 處的 Node 復制到 Gap 的新位置之后 ,如下圖:

這樣我們不需要真的移動 Gap,只要將 Gap 的 start 的指針移動到 groupPyhsicalAddress 即可,新的 Node1 將在此處插入。當然,groups 移動之后,anchor 等關聯信息也要進行相應的更新。

最后再看一下刪除 Node 時的 Gap 移動情況,原理也是類似的:

將 Gap 移動到待刪除 Group 之前,然后開始刪除 Node,這樣,刪除過程其實就是移動 Gap 的 end 位置而已,效率很高而且保證了 Gap 的連續。

10. 總結

SlotTable 系統是 Compose 從組合到渲染到屏幕,整個過程中的最重要環節,結合下面的圖我們回顧一下整個流程:

  1. Composable 源碼在編譯期會被插入 startXXXGroup/endXXXGroup 模板代碼,用于對 SlotTable 的樹形遍歷。
  2. Composable 首次組合中,startXXXGroup 在 SlotTable 中插入 Group 并通過 $key 識別 Group 在代碼中的位置
  3. 重組中,startXXXGroup 會對 SlotTable 進行遍歷和 Diff,并通過 changes 延遲更新 SlotTable,同時應用到 LayoutNode 樹
  4. 渲染幀到達時,LayoutNode 針對變更部分進行 measure > layout > draw,完成 UI 的局部刷新。
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容