Compose 為什麼可以跨平臺?

語言: CN / TW / HK

highlight: github theme: juejin


「回顧2022,展望2023,我正在參與2022年終總結徵文大賽活動

這是我在 2022 Kotlin 中文開發者大會 中帶來的一個分享,會後有網友反饋希望將 PPT 內容整理成文字方便閱讀,所以就有了本篇文章。大家如果要了解本次大會更多精彩內容,也可以去 JetBrains 官方視訊號檢視大會的直播回放。

前言

Compose 不止能用於 Android 應用開發,藉助其分層的架構設計以及 Kotlin 的跨平臺優勢,也是一個極具潛力的 Kotlin 跨平臺框架。本文讓我們從 Compose Runtime 的視角出發,看看 Compose 實現跨平臺開發的基本原理。

Compose Architecture Layers

Compose 作為一個框架,在架構上從下到上分成多層:

  • Compose Compiler:Kotlin 編譯器外掛,負責對 Composable 函式的靜態檢查以及程式碼生成等。
  • Compose Runtime:負責 Composable 函式的狀態管理,以及執行後的渲染樹生成和更新
  • Compose UI: 基於渲染樹進行 UI 的佈局、繪製等 UI 渲染工作
  • Compose Foundation: 提供用於佈局的基礎 Composable 元件,例如 ColumnRow 等。
  • Compose Material:提供上層的面向 Material 設計風格的 Composable 元件。 各層的職責明確,其中 Compose Compiler 和 Runtime 是支撐整個宣告式 UI 運轉的基石。

Compose Compiler

我們先看一下 Compose Compiler 的作用:

左邊的原始碼是一個非常簡單的 Composable 函式,定義了個一大帶有狀態的 Button,點選按鈕,Button 中顯示的 count 數增加。

原始碼經 Compose Compiler 編譯後變成右邊這樣,生成了很多程式碼。首先函式簽名上多了幾個引數,特別是多了 %composer 引數。然後函式體中插入了很多對 %composer 的呼叫,例如 startRestartGroup/endRestartGroup,startReplaceGroup/endReplaceGroup 等。這些生成程式碼用來完成 Compose Runtime 這一層的工作。接下來我們分析一下 Runtime 具體在做什麼

Group & SlotTable

Composable 函式雖然沒有返回值,但是執行過程中需要生成服務於 UI 渲染的產物,我們稱之為 Composition。引數 %composer 就是 Composition 的維護者,用來建立和更新 Composition。Composition 中包含兩棵樹,一棵狀態樹和一棵渲染樹。

關於兩棵樹:如果你瞭解 React,可以將這兩棵樹的關係類比成 React 中的 VIrtual DOM Tree 與 Real DOM Tree。Compose 中的這棵 “Virtual DOM” 用來記錄 UI 顯示所需要的狀態資訊, 所以我們稱之為狀態樹。

狀態樹上的節點單元是 Group,編譯器生成的 startXXXGroup 本質上就是在建立 Group 單元, startXXXGroup 與 endXXXGroup 之間產生的資料狀態都歸屬當前 Group;產生的 Group 就成為子 Group,因此隨著 Composable 的執行,基於 Group 的樹型結構就被構建出來了。

關於 Group:Group 都是一些功能單元,比如 RestartGroup 是一個可重組的最小單元,ReplaceableGroup 是可以被動態插入的最小單元等,以 Group 為單位組織狀態,可以更靈活的更新狀態樹。程式碼中什麼位置插入什麼樣的 startXXXGroup 完全由 Compose Compiler 智慧的幫我們生成,我們在寫程式碼時不必付出這方面的思考。

狀態樹實際是使用一個被稱作 Slot Table 的線性資料結構實現的,可以把他理解為一個數組,儲存著狀態樹深度遍歷的結果,陣列的各個區間儲存著對應 UI 節點上的狀態。

Comopsable 首次執行時,產生的 Group 以及所瞎的狀態會以此填充到 Slot Table 中,填充時會附帶一個編譯時給予程式碼位置生成的不重複的 key,所以 Slot Table 中的記錄也被稱作基於程式碼位置的儲存(Positional Memoization)。當重組發生時, Composable 會再次遍歷 SlotTable,並在 startXXXGroup 中根據 key 訪問當前程式碼所需的狀態,比如 count 就可以通過 remember 在重組中獲取最近的值。

Applier & Node Tree

Slot Table 中的狀態不能直接用來渲染,UI 的渲染依賴 Composition 中的另一棵樹 - 渲染樹。Slot Table 通過 Applier 轉換成渲染樹。渲染樹是真真正的樹形結構體 Node Tree。

Applier 是一個介面,從介面定義不難看出,它用於對一棵 Node 型別節點樹進行增刪改等維護工作。以一個 UI 的插入為例,我們在 Compoable 中的一段 if 語句就可以實現一個 UI 片段的插入。if 程式碼塊在編譯期會生成一個 ReplaceGroup,當重組中命中 if 條件執行到 startReplaceGroup 時,發現 Slot Table 中缺少 Group 對應 key 的資訊,因此可以識別出是一個插入操作,然後插入新的 Group 以及所轄的 Node 資訊,並通過 Applier 轉換成 Node Tree 中新插入的節點。

SlotTable 中插入新元素後,後續元素會通過 Gap Buffer 機制進行後移,而不是直接刪除。這樣可以保證後續元素在 Node Tree 中的對應節點的保留,實現 Node Tree 的增量更新,實現區域性重新整理,提升效能。

Compose Phases

我們結合前面的介紹,整體看一下 Compose 從原始碼到上屏的全過程:

  • Composable 原始碼經 Compiler 處理後插入了用於更新 Composition 的程式碼。這部分工作由 Compose Compiler 完成。

  • 當 Compose 框架接收到系統側傳送的幀訊號後,從頂層開始執行 Composable 函式,執行過程中依次更新 Composition 中的狀態樹和渲染樹,這個過程即所謂的“組合”。這部分工作由 Compose Runtime 完成。

  • Compose 在 Android 平臺的容器是 AndroidComposeView,當接收到系統傳送的 disptachDraw 時,便開始驅動 Composition 的渲染樹以及進行 Measure,Lyaout,Drawing 完成 UI 的渲染。這部分工作由 Compose UI 負責完成。

Comopse 渲染一幀的三個階段 : Composition -> Layout -> Drawing。 傳統檢視開發中,渲染樹(View Tree)的維護需要我們在程式碼邏輯中完成;Compose 渲染樹的維護則交給了框架,所以多了 Composition 這一階段。這也是 Compose 相對於自定義 View 程式碼更簡單的根本原因。

把這整個過程從中間一分為二來看,Compose Compiler 與 Compose Runtime 負責驅動一棵節點樹的更新,這部分與平臺無關,節點樹也可以是任意型別的節點樹甚至是一顆渲染無關的樹。不同平臺的渲染機制不同,所以 Compose UI 與平臺相關。 我們只要在 Compoe UI 這一層,針對不同平臺實現自己的 Node Tree 和對應的 Applier,就可以在 Compose Runtime 的驅動下實現 UI 的宣告式開發。

Compose for Android View

基於這一結論,我們做一個實驗:使用 Compose Runtime 驅動 Android 原生 View 的渲染。

我們首先定義一個基於 View 型別節點的 Applier :ViewApplier

```kotlin class ViewApplier(val view: FrameLayout) : AbstractApplier(view) { override fun onClear() { (view as? ViewGroup)?.removeAllViews() }

override fun insertBottomUp(index: Int, instance: View) {
    (current as? ViewGroup)?.addView(instance, index)
}

override fun insertTopDown(index: Int, instance: View) {
}

override fun move(from: Int, to: Int, count: Int) {
    // NOT Supported
    TODO()
}

override fun remove(index: Int, count: Int) {
    (view as? ViewGroup)?.removeViews(index, count)
}

} ```

然後,我們建立兩個 Android View 對應的 Composable,TextView 和 LinearLayout:

```kotlin @Composable fun TextView( text: String, onClick: () -> Unit = {} ) { val context = localContext.current ComposeNode( factory = { TextView(context) }, update = { set(text) { this.text = text } set(onClick) { setOnClickListener { onClick() } } }, ) }

@Composable fun LinearLayout(children: @Composable () -> Unit) { val context = localContext.current ComposeNode( factory = { LinearLayout(context).apply { orientation = LinearLayout.VERTICAL layoutParams = ViewGroup.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, ) } }, update = {}, content = children, ) }

``` ComposeNode 是 Compose Runtime 提供的 API,用來像 Slot Table 新增一個 Node 資訊。Slot Tabl 通過 Applier 建立基於 View 的節點樹時,會通過 Node 的 factory 建立對應的 View 節點。

有了上述實驗,我們就可以使用 Compose 構建 Android View 了,同時可以通過 Compose 的 SnapshotState 驅動 View 的更新:

```kotlin @Composable fun AndroidViewApp() {

var count by remember { mutableStateOf(1) }

LinearLayout {
    TextView(
        text = "This is the Android TextView!!",
    )
    repeat(count) {
        TextView(
            text = "Android View!!TextView:$it $count",
            onClick = {
                count++
            }
        )
    }

}

} ```

執行效果如下:

compose_for_view.gif

同樣,我們也可以基於 Compose Runtime 為任意平臺打造基於 Compose 的宣告式 UI 框架。

Compose for Desktop & Web

JetBrains 在 Compose 多平臺應用方面進行了很多嘗試,並做出了很多成果。JetBrains 基於谷歌 Jetpack Compose 的 fork 相繼釋出了 Compose for Desktop 以及 Compose for Web。

Compose Desktop 與 Android 同樣基於 LayoutNode 的渲染樹,通過 Skia 引擎完成跨平臺渲染。所以它們在渲染效果以及開發體驗上都保持高度一致。Compose Desktop 依靠 Kotlin/JVM 編譯成位元組碼產物,並使用 Jpackage 和 Jlink 打包成不同桌面系統的( Linux/Mac/Windows)的安裝包,可以在脫離 JVM 的環境下直接執行。

Compose Web 使用了基於 W3C 標準的 DomNode 作為渲染樹節點,在 Compose Runtime 驅動下生成 DOM Tree 。Compose Web 通過 Kotlin/JS 編譯成 JavaScript 最終在瀏覽器中執行和渲染。Compose Web 中預製了更貼近 HTML 風格的 Composable API,所以 UI 程式碼上與 Android/Desktop 無法直接複用。

通過 compose-jb 官方的例子,感受一下 Desktop & Web 的不同

https://github.com/JetBrains/compose-jb/tree/master/examples/todoapp

上面使用 Compose 在各個平臺實現的頁面效果,Desktop 和 Android 的渲染效果完全一致,Web 與前兩者在現實效果上不同,他們的程式碼分別如下所示:

Compose Desktop 與 Jetpack Compose 在程式碼上沒有區別,而 Compose Web 使用 Div,Ul 這樣與 HTML 標籤同名的 Composable,而且使用 style { ...} 這樣面向 CSS 的 DSL 替代 Modifier,開發體驗更符合前端的習慣。雖然 UI 部分的程式碼在不同平臺有差異,但是在邏輯部分,可以實現完全複用,各平臺的 Comopse UI 都使用 component.models.subscribeAsState() 監聽狀態變化。

Compose for Multiplatform

JetBrains 將 Android,Desktop,Web 三個平臺的 Compose 整合成統一 Group Id 的 Kotlin Multiplatform 庫,便誕生了 Comopse Multiplatform。

Compose Mutiplatform 作為一個 KM 庫,讓一個 KMP (Kotlin Multiplatform Project) 中可共享的程式碼從 Data 層上升到 UI 層以及 UI 相關的 Logic 層。

使用 IntelliJ IDEA 可以建立一個 Compose Multiplatform 工程模版,在結構上與一個普通的 KMP 無異。

  • android/desktop/web 資料夾是各個平臺的工程檔案,基於 gradle 編譯成目標平臺的產物。

  • common 資料夾是 KMP 的核心。commonMain 中是完全共享的 Kt 程式碼,通過 expect/actual 關鍵字實現平臺差異化開發。

我們先在 gradle 中依賴 Comopse Multiplatform 庫,之後就可以在 commonMain 中開發共享基於 Compose 的 UI 程式碼了。Comopse Multiplatform 的各個元件將 Jetpack Compose 對應元件的 Group Id 中的 androidx 字首替換為 org.jertbrains 字首:

text androidx.compose.runtime -> org.jetbrains.compose.runtime androidx.compose.material -> org.jetbrains.compose.material androidx.compose.foundation -> org.jetbrains.compose.foundation

最後

最後,我們來思考一下 Compose for MultiplatformCompose Multiplatform 這兩個詞的區別?在我看來,Compose Multiplatform 會讓家將焦點放在 Multiplatform 上面,自然會拿來與 Flutter 等同類框架作對比。但是通過本文的介紹,大家已經知道了 Compose 並非一個專門為跨平臺打造的框架,現階段它並不追求渲染效果和開發體驗完全一致,它的出現更像是 Kotlin 帶來的增值服務。

而 Compose for Multiplatfom 的焦點則更應該放在 Compose 上,它表示 Compose 可以服務於更多平臺,依託強大的 Compiler 和 Runtime 層,我們可以為更多平臺打造宣告式框架。擴大 Kotlin 的應用場景和 Kotlin 開發者的能力邊界。希望今後再提到 Compose 跨平臺式,大家可以多從 Compose for Multiplatform 的角度去看待他的意義和價值。