天天看點

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

你好,我是朱濤。這是「沉思錄」的第二篇文章。

今天我們簡單聊聊 Compose 的底層原理。

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

今年的Google I/O大會上,Android官方針對Jetpack Compose給出了一系列的性能優化建議,​​文檔​​​和​​視訊​​都已經放出來了。總的來說,官方的内容都非常棒,看完以後我也有些意猶未盡。推薦你去看看。

不過,在聊「性能優化」之前,我們首先要懂「億點點」Compose的底層原理。

一、Composable 的本質

我們都知道,Jetpack Compose最神奇的地方就是:可以用 Kotlin 寫UI界面(無需XML)。而且,借助Kotlin的高階函數特性,Compose UI界面的寫法也非常的直覺。

// 代碼段1

@Composable
fun Greeting() { // 1
    Column { // 2
        Text(text = "Hello")
        Text(text = "Jetpack Compose!")
    }
}      

上面這段代碼,即使你沒有任何Compose基礎,應該也可以輕松了解。Column相當于Android當中縱向的線性布局LinearLayout,在這個布局當中,我們放了兩個Text控件。

最終的UI界面展示,如下圖所示。

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

例子雖然簡單,但是上面的代碼中,還是有兩個細節需要我們注意,我已經用注釋标記出來了:

注釋1,​

​Greeting()​

​​它是一個Kotlin的函數,如果抛開它的@Composable注解不談的話,那麼,它的函數類型應該是​

​() -> Unit​

​​。但是,由于@Composable是一個非常特殊的注解,Compose的編譯器插件會把它當作影響​

​函數類型​

​​的因子之一。是以,​

​Greeting()​

​​它的函數類型應該是​

​@Composable () -> Unit​

​。(順便提一句,另外兩個常見的函數類型影響因子是:suspend、函數類型的接收者。)

注釋2:​

​Column {}​

​​,請留意它的​

​{}​

​​,我們之是以可以這樣寫代碼,這其實是Kotlin提供的高階函數​

​簡寫​

​。它完整的寫法應該是這樣的:

// 代碼段2

Column(content =  {
    log(2)
    Text(text = "Hello")
    log(3)
    Text(text = "Jetpack Compose!")
})      

由此可見,Compose的文法,其實就是通過Kotlin的高階函數實作的。Column()、Text()看起來像是在調用UI控件的構造函數,但它實際上隻是一個普通的頂層函數,是以說,這隻是一種DSL的“障眼法”而已。

備注:如果你想研究如何用Kotlin編寫DSL,可以去看看我公衆号的曆史文章。

那麼,到這裡,我們其實可以做出一個階段性的總結了:Composable的本質,是函數。這個結論看似簡單,但它卻可以為後面的原理研究打下基礎。

接下來,我們來聊聊Composable的特質。

二、Composable 的特質

前面我們已經說過了,Composable本質上就是函數。那麼,它的特質,其實跟普通的函數也是非常接近的。這個話看起來像是廢話,讓我來舉個例子吧。

基于前面的代碼,我們增加一些log:

// 代碼段3

@Composable
fun Greeting() {
    log(1)
    Column {
        log(2)
        Text(text = "Hello")
        log(3)
        Text(text = "Jetpack Compose!")
    }
    log(4)
}

private fun log(any: Any) {
    Log.d("MainActivity", any.toString())
}      

請問,上面代碼的輸出結果是怎樣的呢?如果你看過我的協程教程,那麼心裡肯定會有點“虛”,對吧?不過,上面這段代碼的輸出結果是非常符合直覺的。

// 輸出結果
// 注意:目前Compose版本為1.2.0-beta
// 在未來的版本當中,Compose底層是可能做出優化,并且改變這種行為模式的。

com.boycoder.testcompose D/MainActivity: 1
com.boycoder.testcompose D/MainActivity: 2
com.boycoder.testcompose D/MainActivity: 3
com.boycoder.testcompose D/MainActivity: 4      

你看,Composable不僅從源碼的角度上看是個普通的函數,它在運作時的行為模式,跟普通的函數也是類似的。我們寫出來的Composable函數,它們互相嵌套,最終會形成一個樹狀結構,準确來說是一個N叉樹。而Composable函數的執行順序,其實就是對一個N叉樹的DFS周遊。

這樣一來,我們寫出來的Compose UI就幾乎是:“所見即所得”。

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

也許,你會覺得,上面這個例子,也不算什麼,畢竟,XML也可以做到類似的事情。那麼,讓我們來看另外一個例子吧。

// 代碼段4

@Composable
fun Greeting() {
    log("start")
    Column {
        repeat(4) {
            log("repeat $it")
            Text(text = "Hello $it")
        }
    }
    log("end")
}

// 輸出結果:
com.boycoder.testcompose D/MainActivity: start
com.boycoder.testcompose D/MainActivity: repeat 0
com.boycoder.testcompose D/MainActivity: repeat 1
com.boycoder.testcompose D/MainActivity: repeat 2
com.boycoder.testcompose D/MainActivity: repeat 3      

我們使用repeat{}重複調用了4次Text(),我們就成功在螢幕上建立了4個Text控件,最關鍵的是,它們還可以在Column{}當中正常縱向排列。這樣的代碼模式,在從前的XML時代是不可想象的。

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

話說回來,正是因為Composable的本質就是函數,它才會具備普通函數的一些特質,進而,也讓我們可以像寫普通代碼一樣,用邏輯語句來描述UI布局。

好了,現在我們已經知道了Composable的本質是函數,可是,我們手機螢幕上的那些UI控件是怎麼出現的呢?接下來,我們需要再學「一點點」Compose編譯器插件的知識。PS:這回,我保證真的是「一點點」。

三、Compose 編譯器插件

雖然Compose Compiler Plugin看起來像是一個非常高大上的東西,但從宏觀概念上來看的話,它所做的事情還是很簡單的。

如果你看過我的部落格《圖解協程原理》的話,你一定會知道,協程的suspend關鍵字,它可以改變函數的類型,Compose的注解​

​@Composable​

​也是類似的。總的來說,它們之間的對應關系是這樣的:

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

具體來說,我們在Kotlin當中寫的Composable函數、挂起函數,在經過編譯器轉換以後,都會被額外注入參數。對于挂起函數來說,它的參數清單會多出一個​

​Continuation​

​​類型的參數;對于Composable函數,它的參數清單會多出一個​

​Composer​

​類型的參數。

為什麼普通函數無法調用「挂起函數」和「Composable函數」,底層的原因就是:普通函數根本無法傳入​

​Continuation​

​、​

​Composer​

​作為調用的參數。

注意:需要特殊說明的是,在許多場景下,Composable函數經過Compose Compiler Plugin轉換後,其實還可能增加其他的參數。更加複雜的情況,我們留到後續的文章裡再分析。

另外,由于Compose并不是屬于Kotlin的範疇,為了實作Composable函數的轉換,Compose團隊是通過「Kotlin編譯器插件」的形式來實作的。我們寫出的Kotlin代碼首先會被轉換成IR,而Compose Compiler Plugin則是在這個階段直接改變了它的結構,進而改變了最終輸出的Java位元組碼以及Dex。這個過程,也就是我在文章開頭放那張動圖所描述的行為。

動圖我就不重複貼了,下面是一張靜态的流程圖。

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

不過,Compose Compiler 不僅僅隻是改變「函數簽名」那麼簡單,如果你将Composable函數反編譯成Java代碼,你就會發現它的函數體也會發生改變。

讓我們來看一個具體的例子,去發掘Compose的「重組」(Recompose)的實作原理。

四、Recompose 的原理

// 代碼段5

class MainActivity : ComponentActivity() {
    // 省略

    @Composable
    fun Greeting(msg: String) {
        Text(text = "Hello $msg!")
    }
}      

上面的代碼很簡單,Greeting()的邏輯十分簡單,不過當它被反編譯成Java後,它實際的邏輯會變複雜許多。

// 代碼段6

public static final void Greeting(final String msg, Composer $composer,
 final int $changed) { // 多出來的changed我們以後分析吧

  //                        1,開始
  //                          ↓
  $composer = $composer.startRestartGroup(-1948405856);

  int $dirty = $changed;
  if (($changed & 14) == 0) {
     $dirty = $changed | ($composer.changed(msg) ? 4 : 2);
  }

  if (($dirty & 11) == 2 && $composer.getSkipping()) {
     $composer.skipToGroupEnd();
  } else {
     TextKt.Text-fLXpl1I(msg, $composer, 0, 0, 65534);
  }

  //                                  2,結束
  //                                     ↓
  ScopeUpdateScope var10000 = $composer.endRestartGroup();

  if (var10000 != null) {
     var10000.updateScope((Function2)(new Function2() {
        public final void invoke(@Nullable Composer $composer, int $force) {
           //              3,遞歸調用自己
           //                ↓
           MainActivityKt.Greeting(msg, $composer, $changed | 1);
        }
     }));
  }
}      

毫無疑問,Greeting()反編譯後,之是以會變得這麼複雜,背後的原因全都是因為Compose Compiler Plugin。上面這段代碼裡值得深挖的細節太多了,為了不偏離主題,我們暫時隻關注其中的3個注釋,我們一個個看。

  • 注釋1,​

    ​composer.startRestartGroup​

    ​​,這是Compose編譯器插件為Composable函數插入的一個輔助代碼。它的作用是在記憶體當中建立一個​

    ​可重複的Group​

    ​​,它往往代表了一個Composable函數開始執行了;同時,它還會建立一個對應的​

    ​ScopeUpdateScope​

    ​​,而這個​

    ​ScopeUpdateScope​

    ​則會在注釋2處用到。
  • 注釋2,​

    ​composer.endRestartGroup()​

    ​​,它往往代表了一個Composable函數執行的結束。而這個Group,從一定程度上,也描述了UI的結構與層級。另外,它也會傳回一個​

    ​ScopeUpdateScope​

    ​,而它則是觸發「Recompose」的關鍵。具體的邏輯我們看注釋3。
  • 注釋3,我們往​

    ​ScopeUpdateScope.updateScope()​

    ​​注冊了一個監聽,當我們的Greeting()函數需要重組的時候,就會觸發這個監聽,進而遞歸調用自身。這時候你會發現,前面提到的​

    ​RestartGroup​

    ​也暗含了「重組」的意味。

由此可見,Compose當中看起來特别高大上的「Recomposition」,其實就是:“重新調用一次函數”而已。

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

那麼,Greeting()到底是在什麼樣的情況下才會觸發「重組」呢?我們來看一個更加完整的例子。

// 代碼段7

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MainScreen()
        }
    }
}

@Composable
fun MainScreen() {
    log("MainScreen start")
    val state = remember { mutableStateOf("Init") }
    // 1
    LaunchedEffect(key1 = Unit) {
        delay(1000L)
        state.value = "Modified"
    }
    Greeting(state.value)
    log("MainScreen end")
}

private fun log(any: Any) {
    Log.d("MainActivity", any.toString())
}

@Composable
fun Greeting(msg: String) {
    log("Greeting start $msg")
    Text(text = "Hello $msg!")
    log("Greeting end $msg")
}

/* 輸出結果
MainActivity: MainScreen start
MainActivity: Greeting start Init
MainActivity: Greeting end Init
MainActivity: MainScreen end
等待 1秒
MainActivity: MainScreen start        // 重組
MainActivity: Greeting start Modified // 重組
MainActivity: Greeting end Modified   // 重組
MainActivity: MainScreen end          // 重組
*/      

上面的代碼邏輯仍然十分的簡單,setContent {}調用了MainScreen();MainScreen()調用了Greeting()。唯一需要注意的,就是注釋1處的​

​LaunchedEffect{}​

​,它的作用是啟動一個協程,延遲1秒,并對state進行指派。

從代碼的日志輸出,我們可以看到,前面4個日志輸出,是Compose初次執行觸發的;後面4個日志輸出,則是由state改變導緻的「重組」。看起來,Compose通過某種機制,捕捉到了state狀态的變化,然後通知了MainScreen()進行了重組。

如果你足夠細心的話,你會發現,state實際上隻在Greeting()用到了,而state的改變,卻導緻MainScreen()、Greeting()都發生了「重組」,MainScreen()的「重組」看起來是多餘。這裡其實就藏着Compose性能優化的一個關鍵點。

注意:類似上面的情況,Compose Compiler 其實做了足夠多的優化,MainScreen()的「重組」看似是多餘的,但它實際上對性能的影響并不大,我們舉這個例子隻是為了講明白「重組」的原理,引出優化的思路。Compose Compiler 具體的優化思路,我們留到以後再來分析。

讓我們改動一下上面的代碼:

// 代碼段8

class MainActivity : ComponentActivity() {
    // 不變
}

@Composable
fun MainScreen() {
    log("MainScreen start")
    val state = remember { mutableStateOf("Init") }
    LaunchedEffect(key1 = Unit) {
        delay(1000L)
        state.value = "Modified"
    }
    Greeting { state.value } // 1,變化在這裡
    log("MainScreen end")
}

private fun log(any: Any) {
    Log.d("MainActivity", any.toString())
}

@Composable   // 2,變化在這裡 ↓
fun Greeting(msgProvider: () -> String) {
    log("Greeting start ${msgProvider()}") // 3,變化
    Text(text = "Hello ${msgProvider()}!") // 3,變化
    log("Greeting end ${msgProvider()}")   // 3,變化
}

/*
MainActivity: MainScreen start
MainActivity: Greeting start Init
MainActivity: Greeting end Init
MainActivity: MainScreen end
等待 1秒
MainActivity: Greeting start Modified  // 重組
MainActivity: Greeting end Modified    // 重組
*/      

代碼的變化我用注釋标記出來了,主要的變化在:注釋2,我們把原先String類型的參數改為了函數類型:​

​() -> String​

​。注釋1、3處改動,都是跟随注釋2的。

請留意代碼的日志輸出,這次,「重組」的範圍發生了變化,MainScreen()沒有發生重組!這是為什麼呢?這裡涉及到兩個知識點:一個是Kotlin函數式程式設計當中的「Laziness」;另一個是Compose重組的「作用域」。我們一個個來看。

4.1 Laziness

Laziness 在函數式程式設計當中是個相當大的話題,要把這個概念将透的話,得寫好幾篇文章才行,這裡我簡單解釋下,以後有機會我們再深入讨論。

了解 Laziness 最直覺的辦法,就是寫一段這樣對比的代碼:

// 代碼段9

fun main() {
    val value = 1 + 2
    val lambda: () -> Int = { 1 + 2      

其實,如果你對Kotlin高階函數、Lambda了解透徹的話,你馬上就能了解代碼段8當中的Laziness是什麼意思了。如果你對這Kotlin的這些基本概念還不熟悉,可以去看看我公衆号的曆史文章。

上面這段代碼的輸出結果如下:

3
Function0<java.lang.Integer>
3      

這樣的輸出結果也很好了解。​

​1 + 2​

​​是一個表達式,當我們把它用​

​{}​

​包裹起來以後,它就一定程度上實作了Laziness,我們通路lambda的時候并不會觸發實際的計算行為。隻有調用​

​lambda()​

​的時候,才會觸發實際的計算行為。

Laziness講清楚了,我們來看看Compose的重組「作用域」。

4.2 重組「作用域」

其實,在前面的代碼段6處,我們就已經接觸過它了,也就是​

​ScopeUpdateScope​

​。通過前面的分析,我們每個Composable函數,其實都會對應一個ScopeUpdateScope,Compiler底層就是通過注入監聽,來實作「重組」的。

實際上,Compose底層還提供一個:​

​狀态快照系統​

​​(SnapShot)。Compose的快照系統底層的原理還是比較複雜的,以後有機會我們再深入探讨,更多資訊你可以看看這個​​連結​​。

總的來說,SnapShot 可以監聽Compose當中State的讀、寫行為。

// 代碼段10

@Stable
interface MutableState<T> : State<T> {
    override var value: T
}

internal open class SnapshotMutableStateImpl<T>(
    value: T,
    override val policy: SnapshotMutationPolicy<T>
) : StateObject, SnapshotMutableState<T> {

    override var value: T
        get() = next.readable(this).value
        set(value) = next.withCurrent {
            if (!policy.equivalent(it.value, value)) {
                next.overwritable(this, it) { this.value = value }
            }
        }
}      

本質上,它其實就是通過自定義Getter、Setter來實作的。當我們定義的state變量,它的值從“Init”變為“Modified”的時候,Compose可以通過自定義的Setter捕獲到這一行為,進而調用ScopeUpdateScope當中的監聽,觸發「重組」。

那麼,代碼段7、代碼段8,它們之間的差異到底在哪裡呢?關鍵其實就在于ScopeUpdateScope的不同。

這其中的關聯,其實用一句話就可以總結:狀态讀取發生在哪個Scope,狀态更新的時候,哪個Scope就發生重組。

如果你看不懂這句話也沒關系,我畫了一個圖,描述了代碼段7、代碼段8之間的差異:

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

對于代碼段7,當state的讀取發生在MainScreen()的ScopeUpdateScope,那麼,當state發生改變的時候,就會觸發MainScreen()的Scope進行「重組」。

代碼段8也是同理:

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

現在,回過頭來看這句話,相信你就能看懂了:狀态讀取發生在哪個Scope,狀态更新的時候,哪個Scope就發生重組。

好,做完前面這些鋪墊以後,我們就可以輕松看懂Android官方給出的其中三條性能優化建議了。

  1. Defer reads as long as possible.
  2. Use derivedStateOf to limit recompositions
  3. Avoid backwards writes

以上這3條建議,本質上都是為了盡可能避免「重組」,或者縮小「重組範圍」。由于篇幅限制,我們就挑第一條來詳細解釋吧~

五、盡可能延遲State的讀行為

其實,對于我們代碼段7、代碼段8這樣的改變,Compose的性能提升不明顯,因為Compiler底層做了足夠多的優化,多一個層級的函數調用,并不會有明顯差異。Android官方更加建議我們将某些狀态的讀寫延遲到Layout、Draw階段。

這就跟Compose整個執行、渲染流程相關了。總的來說,對于一個Compose頁面來說,它會經曆以下4個步驟:

  • 第一步,Composition,這其實就代表了我們的Composable函數執行的過程。
  • 第二步,Layout,這跟我們View體系的Layout類似,但總體的分發流程是存在一些差異的。
  • 第三步,Draw,也就是繪制,Compose的UI元素最終會繪制在Android的Canvas上。由此可見,Jetpack Compose雖然是全新的UI架構,但它的底層并沒有脫離Android的範疇。
  • 第四步,Recomposition,重組,并且重複1、2、3步驟。

總體的過程如下圖所示:

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

Android官方推薦我們盡可能推遲狀态讀取的原因,其實還是希望我們可以在某些場景下直接跳過Recomposition的階段、甚至Layout的階段,隻影響到Draw。

而實作這一目标的手段,其實就是我們前面提到的「Laziness」思想。讓我們以官方提供的代碼為例:

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

首先,我要說明的是,Android官方文檔當中的注釋其實是存在一個小瑕疵的。它對新手友好,但容易對我們深入底層的人産生困擾。上面代碼中描述的Recomposition Scope并不準确,它真正的Recomposition Scope,應該是整個​

​SnackDetail()​

​,而不是​

​Box()​

​。對此,我已經在Twitter與相關的Google工程師回報了,對方也回複了我,這是“故意為之”的,因為這更容易了解。具體細節,你可以去這條​​Twitter​​看看。

好,我們回歸正題,具體分析一下這個案例:

// 代碼段11

@Composable
fun SnackDetail() {
    // Recomposition Scope
    // ...

    Box(Modifier.fillMaxSize()) {  Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack, scroll.value) // 1,狀态讀取
        // ...
    }
// Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scroll: Int) {
    // ...
    val offset = with(LocalDensity.current) { scroll.toDp() }

    Column(
        modifier = Modifier
            .offset(y = offset) // 2,狀态使用
    ) {
        // ...      

上面的代碼有兩個注釋,注釋1,代表了狀态的讀取;注釋2,代表了狀态的使用。這種“狀态讀取與使用位置不一緻”的現象,其實就為Compose提供了性能優化的空間。

那麼,具體我們該如何優化呢?其實很簡單,借助我們之前Laziness的思想,讓:“狀态讀取與使用位置一緻”。

// 代碼段12

@Composable
fun SnackDetail() {
    // Recomposition Scope 
    // ...

    Box(Modifier.fillMaxSize()) { Start
        val scroll = rememberScrollState(0)
        // ...
        Title(snack) { scroll.value } // 1,Laziness
        // ...
    }
    // Recomposition Scope End
}

@Composable
private fun Title(snack: Snack, scrollProvider: () -> Int) {
    // ...
    val offset = with(LocalDensity.current) { scrollProvider().toDp() }
    Column(
        modifier = Modifier
            .offset(y = offset) // 2,狀态讀取+使用
    ) {
    // ...      

請留意注釋1這裡的變化,由于我們将​

​scroll.value​

​​變成了Lambda,是以,它并不會在composition期間産生狀态讀取行為,這樣,當​

​scroll.value​

​發生變化的時候,就不會觸發「重組」,這就是「Laziness」的意義。

代碼段11、代碼段12之間的差異是巨大的:

沉思錄 | 揭秘 Compose 原理:圖解 Composable 的本質

前者會在頁面滑動的期間頻繁觸發:「重組」+「Layout」+「Draw」,後者則完全繞過了「重組」,隻有「Layout」+「Draw」,由此可見,它的性能提升也是非常顯著的。

六、結尾

OK,到這裡,我們這篇文章就該結束了。我們來簡單總結一下:

  • 第一,Composable函數的本質,其實就是函數。多個Composable函數互相嵌套以後,就自然形成了一個UI樹。Composable函數執行的過程,其實就是一個DFS周遊過程。
  • 第二,​

    ​@Composable​

    ​修飾的函數,最終會被Compose編譯器插件修改,不僅它的函數簽名會發生變化,它函數體的邏輯也會有天翻地覆的改變。函數簽名的變化,導緻普通函數無法直接調用Composable函數;函數體的變化,是為了更好的描述Compose的UI結構,以及實作「重組」。
  • 第三,重組,本質上就是當Compose狀态改變的時候,Runtime對Composable函數的重複調用。這涉及到Compose的快照系統,還有ScopeUpdateScope。
  • 第四,由于ScopeUpdateScope取決于我們對State的讀取位置,是以,這就決定了我們可以使用Kotlin函數式程式設計當中的Laziness思想,對Compose進行「性能優化」。也就是讓:狀态讀取與使用位置一緻,盡可能縮小「重組作用域」,盡可能避免「重組」發生。
  • 第五,今年的Google I/O大會上,Android官方團隊提出了:​​5條性能優化的最佳實踐​​,其中3條建議的本質,都是在踐行:狀态讀取與使用位置一緻的原則。
  • 第六,我們詳細分析了其中的一條建議「盡可能延遲State的讀行為」。由于Compose的執行流程分為:「Composition」、「Layout」、「Draw」,通過Laziness,我們可以讓Compose跳過「重組」的階段,大大提升Compose的性能。

七、結束語

其實,Compose的原理還是相當複雜的。它除了UI層跟Android有較強的關聯以外,其他的部分Compiler、Runtime、Snapshot都是可以獨立于Android以外而存在的。這也是為什麼JetBrains可以基于Jetpack Compose建構出​​Compose-jb​​的原因。