你好,我是朱濤。這是「沉思錄」的第二篇文章。
今天我們簡單聊聊 Compose 的底層原理。
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiIwJWZ3xCdh1mcvZ2LcRDOxEzX3xCZlhXam9VbsUmepNXZy9CXldWYtlWPzNXZj9mcw1ycz9WL4xSPrdEZwZ1Rh5WNXp1bwNjW1ZUba9VZwlHdsAjMfd3bkFGazxCMx8VesATMfhHLlN3XnxCMz8FdsYkRGZkRG9lcvx2bjxSa2EWNhJTW1AlUxEFeVRUUfRHelRHL0EzXlpXazxyayFWbyVGdhd3LcV2Zh1Wa9M3clN2byBXLzN3btg3PwJWZ35SO0YzNzATY2cjMlJ2Y2IWNzYzX4MjN0MTM4IzLcBTMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.webp)
今年的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界面展示,如下圖所示。
例子雖然簡單,但是上面的代碼中,還是有兩個細節需要我們注意,我已經用注釋标記出來了:
注釋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就幾乎是:“所見即所得”。
也許,你會覺得,上面這個例子,也不算什麼,畢竟,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時代是不可想象的。
話說回來,正是因為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 的原理
// 代碼段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,
,這是Compose編譯器插件為Composable函數插入的一個輔助代碼。它的作用是在記憶體當中建立一個composer.startRestartGroup
,它往往代表了一個Composable函數開始執行了;同時,它還會建立一個對應的可重複的Group
,而這個ScopeUpdateScope
則會在注釋2處用到。ScopeUpdateScope
- 注釋2,
,它往往代表了一個Composable函數執行的結束。而這個Group,從一定程度上,也描述了UI的結構與層級。另外,它也會傳回一個composer.endRestartGroup()
,而它則是觸發「Recompose」的關鍵。具體的邏輯我們看注釋3。ScopeUpdateScope
- 注釋3,我們往
注冊了一個監聽,當我們的Greeting()函數需要重組的時候,就會觸發這個監聽,進而遞歸調用自身。這時候你會發現,前面提到的ScopeUpdateScope.updateScope()
也暗含了「重組」的意味。RestartGroup
由此可見,Compose當中看起來特别高大上的「Recomposition」,其實就是:“重新調用一次函數”而已。
那麼,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之間的差異:
對于代碼段7,當state的讀取發生在MainScreen()的ScopeUpdateScope,那麼,當state發生改變的時候,就會觸發MainScreen()的Scope進行「重組」。
代碼段8也是同理:
現在,回過頭來看這句話,相信你就能看懂了:狀态讀取發生在哪個Scope,狀态更新的時候,哪個Scope就發生重組。
好,做完前面這些鋪墊以後,我們就可以輕松看懂Android官方給出的其中三條性能優化建議了。
- Defer reads as long as possible.
- Use derivedStateOf to limit recompositions
- 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工程師回報了,對方也回複了我,這是“故意為之”的,因為這更容易了解。具體細節,你可以去這條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之間的差異是巨大的:
前者會在頁面滑動的期間頻繁觸發:「重組」+「Layout」+「Draw」,後者則完全繞過了「重組」,隻有「Layout」+「Draw」,由此可見,它的性能提升也是非常顯著的。
六、結尾
OK,到這裡,我們這篇文章就該結束了。我們來簡單總結一下:
- 第一,Composable函數的本質,其實就是函數。多個Composable函數互相嵌套以後,就自然形成了一個UI樹。Composable函數執行的過程,其實就是一個DFS周遊過程。
- 第二,
修飾的函數,最終會被Compose編譯器插件修改,不僅它的函數簽名會發生變化,它函數體的邏輯也會有天翻地覆的改變。函數簽名的變化,導緻普通函數無法直接調用Composable函數;函數體的變化,是為了更好的描述Compose的UI結構,以及實作「重組」。@Composable
- 第三,重組,本質上就是當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的原因。