Jetpack Compose Layout 層級會有 Double taxation 嗎?那一兩件事情
聖誕節嘛,按照慣例,先來放首歌吧。
不知道為啥,我很想放這首歌,聽著聽著就上癮了,總而言之,該死的聖誕節。
前言
大約半年前我曾好奇提問過,使用 LinearLayout 的 weight 會有 performance 的問題,那使用 Jetpack Compose 的 weight 就不會有類似的問題嗎?因為 Compose 的 Layout 看需要甚至得疊上更多層呢?這樣狀況不會更嚴重嗎?
以這些問題做為起點,開始迷迷網網地進行調查。
Double taxation
解答第一個問題,那我們理所當然地要回頭看一下使用 LinearLayout 的 weight 會有 performance 的問題。這還蠻妙的,我們會知道這個問題,大多時候不是在 Layout 的時候跟你吐了甚麼例外事件,而是在我們實作 xml 的時候會跳一個 lint warning:
網路上查大多數的資料都告訴我們怎麼解決掉這個問題,而不是這個問題怎麼發生的,這不禁讓我好奇了起來。
layout weight 顯然跟 View 的 onMeasure 有關,那麼顯然的 LinearLayout 是個 Viewgroup ,那除了 measure 自己外,更多的是 measure 自己的 Child 。那我們可已透過自訂一個 View ,來看 LinearLayout measure 自己 Child 的次數。
class LogView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
Log.v("Jast at here server", "onMeasure : $tag")
}
}
XML:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:tag="root"
tools:context=".MainActivity">
<com.example.playground.LogView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:tag="rootChild1"/>
</LinearLayout>
那實際跑起來我們可以看到結果:
在一個 LinearLayout 下,若是沒有設定 layout weight 的話,需要被 measure 兩次,咦?為什麼要兩次呢?
雖說 LinearLayout 的特性是根據你設定的 orientation ,排列底下的 Children 的,但是因為 root layout 會需要先算過 Layout Hierarchy 的 measure 後再 measure 一次,所以會 measure 兩次。這一部分我目前還沒法解釋出為何,但這部分不影響 weight 的解讀。
在沒有 Layout weight 的情況下,就算疊到五層六層七層,對 LogView 來說,他只會被 measure 兩次。
那麼我們在 xml 加上了 layout_weight 呢?
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:tag="root"
tools:context=".MainActivity">
<com.example.playground.LogView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:tag="rootChild1"/>
</LinearLayout>
結果會是這樣:
可以發現被 measure 的次數 X2 了。
這原因主要是因為 Child 的 Layout weigh 還有寬或高等資料需要在第一次 measure 後才會得到(e.g. 最大 child 的寬或高),那以 LinearLayout 的邏輯來說,需要這些資訊才能去計算 layout weight 代表的寬高(例如所有 children 的 layout_weight 為 1 ,那找出最大的那一個 child 來讓所有 children 都一樣大小),所以會比原先的 measure 多一次。Layout 在無法一次測量獲得完整資訊,而需要進行一次以上的測量,這個行為現象被稱為 Double taxation。
那 View Tree 的走訪遞迴特性,每層 LinearLayout 都需要對自己的 Child 進行兩次 measure 。是故以這個 case 從最初的 2 次開始加上 weight 會被 x2 ,每增加一層,就會再多 x2,意思是,假如我再多一層 LinearLayout ,那麼 measure 的次數會變成 2³
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:tag="root"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<com.example.playground.LogView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:tag="rootChild1" />
</LinearLayout>
</LinearLayout>
結果會是這樣:
對 child view 來說,假如每一層都有 layout weight ,則它位於第 n 層,measure 次數則為 2^n,時間複雜度為 O(2^n),這通常是演算法暴力破解法的時間複雜度,是故可以當作 bad performance 。
當然若是加上其他的 child ,那次數就會更恐怖了,以這個例子來說:
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:tag="root"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_weight="1">
<com.example.playground.LogView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:tag="rootChild1" />
<com.example.playground.LogView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:tag="rootChild1" />
<com.example.playground.LogView
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_weight="1"
android:tag="rootChild1" />
</LinearLayout>
</LinearLayout>
measure 的次數會是 24 次,意思是 3 x 2³
那像是我們過往會有的需求,九宮格,類似 orientation vertical 包三個 LinearLayout horizontal 裏頭有三個 View 的那種,則會變成 3x3x2³ = 72 。九個 view 就需要 measure 72 次,這個 C/P 值暴低的阿。
Jetpack Compose 的層級
在寫 Android 的時候,我們總會說要盡量減低層級,LinearLayout 的情況固然是一個例子,但是請務必把它當成特例:在使用 LinearLayout 的時候才會有 LinearLayout 的 Double taxation 發生。在其他狀況下,我們會想要拉平 Layout 層級,通用的理由遠比想像中單純 — 就是不希望讓 View tree 的走訪變複雜,但是如果開發可取的話,疊上一兩層也不是問題。
那話說回來, Compose 的 Column 和 Row 也可以在它們所屬的 Scope 底下的 Child 塞 modifier.weight 呀?那就不會發生 LinearLayout 的 double taxation 嗎?
先說結論:不會
為什麼呢?
這可以先從 Compose 的繪製流程開始說起:
Composition : 原則上就是把所有 Composable 的 function 全部跑過一遍,得到具體 UI 應該要怎麼佈局、怎麼繪製的資訊。附帶一提,Recomposition 的時候這邊會決定該 view tree node 要不要跳過 layout 和 drawing 的步驟。
Layout :就是開始進行包括 measure 和 placement 的步驟,這個階段某個 tree view node 會知道自己和自己的 children view 應該怎麼擺放怎麼佈局
Drawing:就是大家都愛的繪製階段啦。
Modifier.weight 就包含在 composition 這個階段了,如果還記得上面所說 LinearLayout 需要 measure 兩次的理由的話,應該就可以理解這兩者之間的出入了。
以 LinearLayout 來說, measure 一次後才得到所需要對應的 weight 資訊,所以需要再 measure 一次,那 Compose 則是會得到所有所需要的 weight 資訊後才會開始 measure (Layout)
@Composable
fun Jiugongge() {
Column {
Row(Modifier.weight(1f)) {
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
}
Row(Modifier.weight(1f)) {
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
}
Row(Modifier.weight(1f)) {
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
Text(
modifier = Modifier.weight(1f),
text = "Just some demo."
)
}
}
}
以這個例子來說,跑過一次 Composition 後,實際跑 Layout 會變成這樣
- Column 要求 measure
- Column 要求自己的孩子,也就是第一個 Row 進行 measure
- Row 要求自己的第一個孩子,也就是第一個 Text ,進行 measure
- 第一個 Text 沒有小孩,是末端節點,於是他利用 composition 階段得到的資訊給了自己的 Size 和放置位置
- Row 要求自己的第二個孩子,也就是第二個 Text ,進行 measure
- 第二個 Text 沒有小孩,是末端節點,於是他利用 composition 階段得到的資訊給了自己的 Size 和放置位置
…
9. 第一個 Row 底下的小孩測量大小和放置位置都畫完了,他可以決定自己的大小和利用 Composition 階段得到的資訊決定放置位置了,完成後回到 Column
10. Column 要求自己的第二個孩子,也就是第二個 Row 進行 measure
...
22. Column 底下的小孩測量大小和放置位置都畫完了,他可以決定自己的大小和利用 Composition 階段得到的資訊決定放置位置了,完成後結束這個 node 的 layout。
結語
聖誕節…我又在聖誕節寫了一篇文章了,雖然這篇文章相對於之前的文章短上許多。
其實還蠻有意思的,半年前我提的疑問,在半年後我因緣際會回碰到 Double taxation 之後得到了對應的解答。我當時看的角度是,所有的 Layout 在跌太多層之後都有可能有 Double taxation 的問題,那麼 compose 疊很多層是不是也會有這種問題呢?後來細看才發現,double taxation 並不是因為疊很多曾造成的問題,疊很多層只是加重這個問題。LinearLayout 有會有這問題,GridLayout 也會有這個問題,但兩者發生的情況卻不太一樣,所以若想理解這方面的東西得把這東西發生的原因找出來才能對應的理解為什麼 Compose 不會發生類似的事情。因此我理解到,在這方面我會覺得知道為什麼會比知道怎麼辦來的更重要。
在這邊我們可以知道,Compose 和傳統 View 最大的不同是,傳統 View 會丟給 Child 去算,但資訊不夠的情況下,需要讓 Child 再算一次;而 Compose 則是會讓其 children 交代好再自己去算。
如果有任何問題,或是看到寫錯字,請不要吝嗇對我發問或對我糾正,您的回覆和迴響會是我寫文章最大的動力。
參考文章
https://developer.android.com/jetpack/compose/phases
https://developer.android.com/topic/performance/rendering/optimizing-view-hierarchies
https://developer.android.com/jetpack/compose/layouts/basics