探索 Jetpack Compose 核心:深入 SlotTable 系統

語言: CN / TW / HK

highlight: androidstudio theme: juejin


引言

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 來實現不同的佈局和顯示效果。

kotlin //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 :

kotlin //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 ,這樣的成對呼叫就好似對一棵樹進行深度遍歷時的壓棧/出棧

text startReusableNode NodeData // Node資料 startReplaceableGroup GroupData //Group資料 ... // 子Group endGroup endNode 不只是 ResuableComposeNode 這樣的內建 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 的定義中可以看到這一點:

```kotlin //SlotTable.kt internal class SlotTable : CompositionData, Iterable {

/**
 * 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 ahchor 構建 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 /startResueableNode|插入一個包含 Node 的 Group。例如文章開頭 ReusableComposeNode 的例子中,顯示呼叫了 startResueableNode ,而後呼叫 createNode 在 Slot 中插入 LayoutNode。 | |startRestartGroup|插入一個可重複執行的 Group,它可能會隨著重組被再次執行,因此 RestartGroup 是重組的最小單元。| |startReplacableGroup|插入一個可以被替換的 Group,例如一個 if/else 程式碼塊就是一個 ReplaceableGroup,它可以在重組中被插入後者從 SlotTable 中移除。| |startMovableGroup|插入一個可以移動的 Group,在重組中可能在兄弟 Group 之間發生位置移動。 | |startReusableGroup|插入一個可複用的 Group,其內部資料可在 LayoutNode 之間複用,例如 LazyList 中同類型的 Item。|

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

4. 編譯期生成的 startXXXGroup

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

startReplacableGroup

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

kotlin @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 如何解決這個問題呢,看一下上述程式碼編譯後的樣子(虛擬碼):

kotlin @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

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

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

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

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

    }
}

} ``` 上面程式碼經過編譯後會插入 startMoveableGroup:

kotlin @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,編譯前後的程式碼如下:

```kotlin // 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 做了些什麼

```kotlin //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 中包裹了一個 Compsoable 函式,當它需要參與重組時,Compose 會從 SlotTable 中找到它並呼叫 RecomposeScopeImpl#invalide() 標記失效,當重組來臨時 Composable 函式被重新執行。
  • RecomposeScopeImpl 被快取到 invalidateStack,並在 Composer#endRestartGroup() 中返回。updateScope 為其設定需要參與重組的 Compsoable 函式,其實就是對當前函式的遞迴呼叫。注意 endRestartGroup 的返回值是可空的,如果 RestartGroupTest 中不依賴任何狀態則無需參與重組,此時將返回 null。

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

5. SlotTable 的 Diff 與遍歷

SlotTable 的 Diff

宣告式框架中,渲染樹的更新都是通過 Diff 實現的,比如 React 通過 VirtualDom 的 Diff 實現 Dom 樹的區域性更新,提升 UI 重新整理的效能。

SlotTable 就是 Compose 的 “VirtualDom”,Composable 初次執行時在 SlotTable 中插入 Group 和對應的 Slot 資料。 當 Composable 參與重組時,基於程式碼現狀與 SlotTable 中的狀態進行 Diff,發現 Composition 中需要更新的狀態,並最終應用到 LayoutNode 樹。

這個 Diff 的過程也是在 startXXXGroup 過程中完成的,具體實現都集中在 Composer#start()

```kotlin //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:當前 Gorup 是否是一個 Node,在 startXXXNode 中,此處會傳入 true
  • data:當前 Group 是否有一個數據,在 startProviers 中會摻入 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 中進行的,實現比較簡單

kotlin 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 的實現:

```kotlin 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 的實現:

kotlin 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 記錄變動列表 ```kotlin //Composer.kt internal class ComposerImpl { //... private val changes: MutableList, //...

private fun record(change: Change) {
    changes.add(change)
}

} ` `Change` 是一個函式,執行具體的變動邏輯,函式簽名即引數如下:kotlin //Composer.kt internal typealias Change = ( applier: Applier<*>, slots: SlotWriter, rememberManager: RememberManager ) -> Unit ```

  • applier: 傳入 Applier 用於將變化應用到 LayoutNode 樹,在後文詳細介紹 Applier
  • slots:傳入 SlotWriter 用於更新 SlotTable
  • remembrerManger:傳入 RemeberManger 用來註冊 Composition 生命週期回撥,可以在特定時間點完成特定業務,比如 LaunchedEffect 在首次進入 Composition 時建立 CoroutineScope, DisposableEffect 在從 Composition 中離開時呼叫 onDispose ,這些都是通過在這裡註冊回撥實現的。

記錄 Change

我們以 remember{} 為例看一下 Change 如何被記錄。 remember{} 的 key 和 value 都會作為 Compositioin 中的狀態記錄到 SlotTable 中。重組中,當 remember 的 key 發生變化時,value 會重新計算 value 並更新 SlotTable。

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

//Composer.kt @ComposeCompilerApi inline fun 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,看一下具體實現:

```kotlin //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 等也都是 Compoisition 中的狀態。
  • RememberObserver 是一個生命週期回撥,RememberMananger#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 的組合

```kotlin //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。

```kotlin 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 是對這一棵渲染樹的巨集觀認知,準確來說 Compositoin 內部通過 Applier 維護著 LayoutNode 樹並執行具體渲染。SlotTable 結構的變化會隨著 Change 列表的應用反映到 LayoutNode 樹上。

像 View 一樣,LayoutNode 通過 measure/layout/draw 等一系列方法完成具體渲染。此外它還提供了 insertAt/removeAt 等方法實現子樹結構的變化。這些方法會在 UiApplier 中呼叫:

```kotlin //UiApplier.kt internal class UiApplier( root: LayoutNode ) : AbstractApplier(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:

```kotlin //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 以及KeyEventtouchEvent等就是從這裡通過 Root Node 傳遞到了 Compose 世界。 -WrappedComposition是一個裝飾器,也是用來為 Composition 與 AndroidComposeView 建立連線,我們常用的很多來自 Android 的 CompositionLocal 就是這裡構建的,比如LocalContextLocalConfiguration` 等等。

8. SlotTable 與 Composable 生命週期

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

  • Enter:startRestartGroup 中將 Composable 對應的 Gorup 存入 SlotTable
  • Recompose:SlotTble 中查詢 Composable (by RecomposeScopeImple) 重新執行,並更新 SlotTable
  • Leave:Composable 對應的 Group 從 SlotTable 中移除。

在 Composable 中使用副作用 API 可以充當 Composble 生命週期回撥來使用

kotlin DisposableEffect(Unit) { //callback when entered the Composition & recomposed onDispose { //callback for leaved the Composition } } 我們以 DisposableEffect 為例,看一下生命週期回撥是如何基於 SlotTable 系統完成的。 看一下 DispoableEffect 的實現,程式碼如下:

```kotlin @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,這是一個 RemeberObserver 的實現。 DisposableEffectImpl 隨著父 Gorup 進入和離開 SlotTable ,將接收到onRememberedonForgotten` 的回撥。

還記得前面講過的 applyChanges 嗎,它發生在重組完成之後

```kotlin 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 結構變化時的更新效率,以下舉例說明:

kotlin @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 的具體實現,相關程式碼如下:

```kotlin //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 中單位 Gorup 的長度,目前是常量 5。

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

  • groupPhysicalAddress: 當前需要插入 gorup 的地址
  • 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 延遲更新 SlotTble,同時應用到 LayoutNode 樹
  4. 渲染幀到達時,LayoutNode 針對變更部分進行 measure > layout > draw,完成 UI 的區域性重新整理。