close

本文原作者: 朱濤,原文發布於: 朱濤的自習室‍

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

不過,如果您對 Compose 的底層原理不是特別熟悉的話,那麼,看完 Android 官方的文檔、視頻以後,您心中可能還是會充滿疑問,似懂非懂。畢竟,官方對「Compose 性能優化」主題的定義是「Intermediate 中階」。對於一門剛推出不到一年的新技術而言,「中階」已經是比較高的要求了。

當然,您也別擔心,我寫這篇文章的目的,就是為了讓更多的 Compose 開發者能夠看懂官方的 Compose 性能優化建議。

換句話說,我們只需要補「億點點」Compose 的「底層原理」即可。

Composable 的本質




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

// 代碼段1@Composablefun Greeting() { // 1 Column { // 2 Text(text = "Hello") Text(text = "Jetpack Compose!") }}

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

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

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

注釋 1:Greeting()它是一個 Kotlin 的函數,如果拋開它的 @Composable 註解不談的話,那麼,它的函數類型應該是 () -> Unit。但是,由於 @Composable 是一個非常特殊的註解,Compose 的編譯器插件會把它當作影響函數類型的因子之一。所以,Greeting() 它的函數類型應該是@Composable () -> Unit。(順便提一句,另外兩個常見的函數類型影響因子是: suspend、函數類型的接收者。)

注釋 2:Column {},請留意它的 {},我們之所以可以這樣寫代碼,這其實是 Kotlin 提供的高階函數簡寫。它完整的寫法應該是這樣的:

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

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

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

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

Composable 的特質



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

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

// 代碼段3@Composablefun 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: 1com.boycoder.testcompose D/MainActivity: 2com.boycoder.testcompose D/MainActivity: 3com.boycoder.testcompose D/MainActivity: 4

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

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

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

// 代碼段4@Composablefun Greeting() { log("start") Column { repeat(4) { log("repeat $it") Text(text = "Hello $it") } } log("end")}// 輸出結果: com.boycoder.testcompose D/MainActivity: startcom.boycoder.testcompose D/MainActivity: repeat 0com.boycoder.testcompose D/MainActivity: repeat 1com.boycoder.testcompose D/MainActivity: repeat 2com.boycoder.testcompose D/MainActivity: repeat 3com.boycoder.testcompose D/MainActivity: end

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

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

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

Compose編譯器插件



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

協程的 suspend 關鍵字,它可以改變函數的類型,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 Compiler 不僅僅只是改變「函數簽名」那麼簡單,如果您將 Composable 函數反編譯成 Java 代碼,您就會發現它的函數體也會發生改變。

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

Recompose 的原理




// 代碼段5class MainActivity : ComponentActivity() { // 省略 @Composable fun Greeting(msg: String) { Text(text = "Hello $msg!") }}

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

// 代碼段6public 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」,其實就是: "重新調用一次函數"而已。

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

// 代碼段7class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { MainScreen() } }}@Composablefun 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())}@Composablefun Greeting(msg: String) { log("Greeting start $msg") Text(text = "Hello $msg!") log("Greeting end $msg")}/* 輸出結果MainActivity: MainScreen startMainActivity: Greeting start InitMainActivity: Greeting end InitMainActivity: 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 具體的優化思路,我們留到以後再來分析。

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

// 代碼段8class MainActivity : ComponentActivity() { // 不變}@Composablefun 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 startMainActivity: Greeting start InitMainActivity: Greeting end InitMainActivity: MainScreen end等待 1秒MainActivity: Greeting start Modified // 重組MainActivity: Greeting end Modified // 重組*/

代碼的變化我用注釋標記出來了,主要的變化在:注釋 2,我們把原先 String 類型的參數改為了函數類型:() -> String。注釋 1、3 處改動,都是跟隨注釋 2 的。

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


Laziness

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

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

// 代碼段9fun main() { val value = 1 + 2 val lambda: () -> Int = { 1 + 2 } println(value) println(lambda) println(lambda())}

其實,如果您對 Kotlin 高階函數、Lambda 理解透徹的話,您馬上就能理解代碼段 8 當中的 Laziness 是什麼意思了。

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

3Function0<java.lang.Integer>3

這樣的輸出結果也很好理解。1 + 2 是一個表達式,當我們把它用 {}包裹起來以後,它就一定程度上實現了 Laziness,我們訪問 lambda 的時候並不會觸發實際的計算行為。只有調用 lambda() 的時候,才會觸發實際的計算行為。

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


重組「作用域」

其實,在前面的代碼段 6 處,我們就已經接觸過它了,也就是 ScopeUpdateScope。通過前面的分析,我們每個 Composable 函數,其實都會對應一個 ScopeUpdateScope,Compiler 底層就是通過注入監聽,來實現「重組」的。

實際上,Compose 底層還提供一個:狀態快照系統(SnapShot)。Compose 的快照系統底層的原理還是比較複雜的,以後有機會我們再深入探討。

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

// 代碼段10@Stableinterface 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 之間的差異:

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

代碼段 8 也是同理:

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

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

1. Defer reads as long as possible2. Use derivedStateOf to limit recompositions3. 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 步驟。


總體的過程如下圖所示:

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

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

首先,我要說明的是,Android 官方文檔當中的注釋其實是存在一個小瑕疵的。它對新手友好,但容易對我們深入底層的人產生困擾。上面代碼中描述的 Recomposition Scope 並不準確,它真正的 Recomposition Scope,應該是整個 SnackDetail(),而不是 Box()。對此,我已經在 Twitter 與相關的 Google 工程師反饋了,對方也回復了我,這是 "故意為之"的,因為這更容易理解。

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

// 代碼段11@Composablefun SnackDetail() { // Recomposition Scope // ... Box(Modifier.fillMaxSize()) { Start val scroll = rememberScrollState(0) // ... Title(snack, scroll.value) // 1,狀態讀取 // ... }// Recomposition Scope End}@Composableprivate 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@Composablefun SnackDetail() { // Recomposition Scope // ... Box(Modifier.fillMaxSize()) { Start val scroll = rememberScrollState(0) // ... Title(snack) { scroll.value } // 1,Laziness // ... } // Recomposition Scope End}@Composableprivate 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 之間的差異是巨大的:

前者會在頁面滑動的期間頻繁觸發: 「重組」+「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 的原因。

長按右側二維碼

查看更多開發者精彩分享


"開發者說·DTalk" 面向中國開發者們徵集 Google 移動應用 (apps & games)相關的產品/技術內容。歡迎大家前來分享您對移動應用的行業洞察或見解、移動開發過程中的心得或新發現、以及應用出海的實戰經驗總結和相關產品的使用反饋等。我們由衷地希望可以給這些出眾的中國開發者們提供更好展現自己、充分發揮自己特長的平台。我們將通過大家的技術內容着重選出優秀案例進行谷歌開發技術專家 (GDE)的推薦。



點擊屏末|閱讀原文|即刻報名參與"開發者說·DTalk"

arrow
arrow
    全站熱搜
    創作者介紹
    創作者 鑽石舞台 的頭像
    鑽石舞台

    鑽石舞台

    鑽石舞台 發表在 痞客邦 留言(0) 人氣()