前言 - 從 Compose 生命周期說起
Compose 繪制生命周期為三個階段:
Composition/組合: Composable 源碼經過運行后生成 LayoutNode 的節點樹,這棵樹被稱為 Composition。
Layout/布局: 對節點樹深度遍歷測量子節點的尺寸,并將其在父容器內擺放到合適的位置。
Drawing/繪制: 基于布局后拿到的尺寸和位置信息,繪制上屏。
我們與 Android 經典視圖系統的生命周期 (Measure,Layout,Drawing) 做一個對比: 組合是 Compose 的特有階段,是其能夠通過函數調用實現聲明式 UI 的核心,想要深入理解 Compose 第一課就是理解這個過程。
繪制階段與傳統視圖大同小異,都是通過 Android Cavas API,底層調用 skia 實現。
本文討論的重點是布局階段。Compose 的 Layout 把 Measure 也囊括了進來,相對于 Android View 有相似性,但也有其獨有的特點和優勢,接下來我們進入正題。
Compose 布局過程三步走
Compose 布局包括三個階段,從當前 Node 出發,需要依次經歷:
Measure children: 深度遍歷子節點,并測量它們的尺寸
Decide own size: 根據收集到的子節點尺寸,決定當前節點自己的尺寸
Place children: 將子節點擺放到合理的相對位置
上面代碼描述了一個卡片的布局,下面以這個布局的節點樹為例,看一下布局流程。
Step1: 從 Row 開始發起測量,遵循三步走第一步,深度遍歷測量其子節點 Image 和 Column
Step2&3: Image 發起測量,因為沒有子節點需要測量了,所以只需要計算自己的尺寸,也因為沒有子節點需要擺放,空實現完成 place 即可
Step4: Column 發起測量,因其有子節點,繼續深度遍歷
Step5&6: 測量 Text,因為一個葉子節點,立即完成自己的 Size 和 Place 階段
Step7&8: 測量另一個 Text,同上
Step9: Column 拿到兩個子 Text 返回的 Size 后,計算出自己的 Size,不難猜到其計算邏輯應該是 width = maxOf(child1.w, child2.w),height = sumOf(child1.h, child2.h)。設置自己的 width 和 height 后,對兩個子 Text 進行 Place,垂直線性擺放。
看一下代碼是如何實現這三步。
所有的 Composable 最終都會調用一個公共 Layout Composable 方法,這里面創建 LayoutNode 存儲在 Composition 節點樹。
以 Column 的實現為例,可以看到調用 Layout 時,傳入了三個參數:
@Composable inline fun Column( modifier: Modifier = Modifier, verticalArrangement: Arrangement.Vertical = Arrangement.Top, horizontalAlignment: Alignment.Horizontal = Alignment.Start, content: @Composable ColumnScope.() -> Unit ) { val measurePolicy = columnMeasurePolicy(verticalArrangement, horizontalAlignment) Layout( content = { ColumnScopeInstance.content() }, measurePolicy = measurePolicy, modifier = modifier ) }
content: 在這里定義子 Composable,組合過后形成當前節點的子節點
measurePolicy: 這是定義了布局的三步走核心邏輯
modifier: 修飾符鏈,參與到布局或者繪制階段
measurePolicy 和 modifier 會存儲在當前 LayoutNode 上,等待 measure 的開始參與其中。下面重點分析 MeasurePolicy 了解三步走如何實現。
MeasurePolicy - 測量策略
fun interface MeasurePolicy { fun MeasureScope.measure( measurables: List, constraints: Constraints ): MeasureResult }
MeasurePolicy 通過 measure 方法完成測量。這里有兩個重要參數:
measurables: 等待測量的對象,其實就是當前節點的子節點
constraints: 測量約束。節點需要基于當前的 Constaints 進行測量,它規定了節點尺寸的上限和下限,如下:
class Constraints { val minWidth: Int val maxWidth: Int val minHeight: Int val maxHeight: Int ... }
Constraints - 測量約束
父節點通過 Constraints 約束子節點的測量。Constraints 非常重要,我們常說 Compose 不怕布局嵌套正是得益于它。反觀 Android 原生視圖,由于測量階段的約束不明確,子 View 需要再次請求父 View 給出清楚的 View.MeasureSpec,導致出現多次繪制。
舉幾個例子理解一下 Constraints 如何設置:
對于頁面的根節點, Activity 的 Window 的長寬就是其 Constraints 的最大長寬。如果是一個垂直可滾動容器的節點,那么它的 Constraints 的 height 應該是 Infinity,因為它可以跨多個屏幕存在。
此外, Modifier 的裝飾能力本質也是通過修改 Constraints 完成的。例如 fillMaxWidth 要求被修飾的節點填充整個父容器,所以 Modifier 會在布局階段將 minHeight/minWidth 對齊 max 組值。關于 Modifier 參與布局的流程,稍后介紹。
三步走實現 - Kotlin 語法優勢的體現
舉例看一下三步走代碼如何實現。
我們實現一個類似 Column 的布局效果,在 measurePolicy#measure 中實現三步走邏輯。
measurePolicy = { // this: MeasureScope // Step1:Measure each children val placeables = measurables.map { measurable -> measurable.measure(constraints) } // Step2: Deciee own size val height = placeables.sumOf { it.height } val width = placeables.maxOf { it.width } layout(width, height) { //this: Placeable.PlacementScope // Step3: Place children by changing the offset of y co-ord var yPosition = 0 placeables.forEach { placeable -> // Position item on the screen placeable.placeRelative(x = 0, y = yPosition) // Record the y co-ord placed up to yPosition += placeable.height } } }
每個 measuable 提供了參與測量的 measure 方法,此處會傳入 Constraints,返回的 placeable 中已經存儲了測量后的 widht 和 height,等待 place
基于各個 placeable 的 w 和 h 計算當前節點的 Size,并通過 layout 方法設置。layout 方法內會真正的創建 LayoutNode
layout 方法的末參是一個 lambda,這里是第三步擺放子節點的邏輯,通過設置 y 軸的偏移量實現縱向布局,非常簡單
特別值得一提的是,通過 meause 一個方法就完成三步走,布局邏輯相對傳統的 View 系統更加高效,回想傳統自定義 View 你需要分別實現 onMeasure,onLayout,onDraw 等,邏輯分散,可讀性差。
但是這種集中式的寫法有一個弊端,需要人為保證代碼順序。試想如果把 layout 寫在 measure 前面怎么辦?幸好 Kotlin 強大的編譯期檢查能力,很好地指導大家寫出正確代碼:
measure 方法的返回值是 MeasureResult 類型,layout 方法也返回此類型,所以保證了尾部一定是調用 layout 完成三步走
Measuable#measure 調用后返回 Placeable 類型,然后才能調用 Placeable#place,這保證了 place 和 measure 的先后關系
Measuable#measure 只能在 MeasureScope 中調用,Placeable#place 只能在 Placeable.PlacementScope 中調用,這確保了 place 需要在 layout 的 lambda 中調用
通過各種返回值類型、作用域類型的約束,大家可以寫出安全又一氣呵成的代碼,這種 API 設計理念值得推崇。
Modifier Node
接下來介紹一下 Modifier 如何參與布局的。
Modifier 在組合之后也會成為 Node 存儲在節點樹上,Modifier 的調用鏈生成一條單向繼承的子節點樹,而被修飾的 Composable 會成為這條樹枝的葉子結點。
比如上面例子中,Image 最終成為 clip->size 的子節點。實際上 Image 內部有一些內置的 Modifier,所以全部展開后 Image 所在的樹枝上有一連串 ModifierNode。
掛在節點樹上的 ModifierNode 可以參與到深度遍歷的繪制流程中,在 Image 之前對 Constraints 做出調整,完成對末端 Image 的裝飾。
以 Padding 修飾符為例,看一下源碼:
//組合中調用 paddiung 會 fun Modifier.padding( start: Dp = 0.dp, top: Dp = 0.dp, end: Dp = 0.dp, bottom: Dp = 0.dp ) = this then PaddingElement( start = start, top = top, end = end, bottom = bottom ) //Element 存儲到鏈上,創建 PaddingNode private class PaddingElement( ... ) : ModifierNodeElement() //PaddingNode 定義 measure 邏輯 private class PaddingNode( overide fun MeasureScope.measure( measurable: Measurable, // 注意不是list constraints: Constraints ): MeasureResult { ... } ):LayoutModifierNode,Modifier.Node()
組合階段,Modifier#then 創建 Element 加入 Modifier chain 中。Element 是無狀態的,重組中會重新生成,Element 會在組合中創建有狀態的 ModifierNode。ModifierNode 有狀態,重組中僅當狀態發生變化時被更新,否則不會重新生成。Modifier Node 是 Compose 1.5 引入的新優化,目的就是通過存儲 Modifier 狀態參與比較,提升重組性能。
ModifierNode 按照參與的階段不同,分為 LayoutModifierNode 和 DrawModifierNode。對于前者,布局邏輯就是現在 LayoutModifierNode#measure 中,和 MeasurePolicy#measure 的功能一樣,唯一的區別是接受單個 measurable 參數而不是 List。因為我們知道了 ModifierNode 是單向繼承,所以只會有一個后續子節點。如果把LayoutNode 的 measure 看做是自定義 ViewGroup 需要針對多個子 View 布局,那么 LayoutModifierNode 的 measure 更像是自定義 View,只對自身負責。
Modifier.layout {}
除了自定義一個 Modifier 來改變當前節點的布局,還有一個簡單的方法就是使用 Modifier.layout {} 方法。
fun Modifier.layout( measure: MeasureScope.(Measurable, Constraints) -> MeasureResult )我們可以在 Modifier 調用鏈的任意位置插入 measure 自定義代碼,對當前節點做裝飾。例如下面代碼中添加了一個自定義 50px 的 padding。
Box(Modifier .background(Color.Gray) .layout { measurable, constraints -> // an example modifier that adds 50 pixels of vertical padding val padding = 50 val placeable = measurable.measure(constraints.offset(vertical = -padding)) layout(placeable.width, placeable.height + padding) { placeable.placeRelative(0, padding) } }){ ... }
Modifier 布局流程
上面代碼繪制一個居中擺放 50*50 的矩形。我們通常不會同時設置這么多 size 相關的 modifier,這個例子只是為了展示 Modifier 的布局流程:
先看一下自頂向下的測量流程: 從 fillMaxSize 對應的 LayoutModifierNode 出發,假設當前的 Constraints 是 w:0-200,h:0-300。fillMaxSize 的功能是讓子節點填滿當前全部剩余空間,會為子節點創建以下 childConstraints:
val childConstraints = Constraints ( minWidth = outerConstraints.maxWidth, maxWidth = outerConstraints.maxWidth, minHeight = outerConstraints.maxHeight, maxHeight = outerConstraints.maxHeight, )來到 warpContentSize,它會讓子自己決定 size 不設限,min 值再次回歸 0,childConstraints 如下:
val childConstraints = Constraints ( minWidth = 0, maxWidth = outerConstraints.maxWidth, minHeight = 0, maxHeight = outerConstraints.maxHeight, )來到 size(50),這里自然要給一個具體的 size 約束,如下:
val childConstraints = Constraints ( minWidth = 50, maxWidth = 50, minHeight = 50, maxHeight = 50, )以此類推 Constraints 經過不斷調整傳入到葉子節點 Box 對應的 LayoutNode,完成三步走。 第一步測量
葉子節點測量完后,再自底向上進行第二三步,整個流程不做贅述了,只提一點: wrapContentSize 從語義上是應該跟隨子節點的大小,即 5050,為什么實際尺寸設置了 200300 呢?
因為其父節點 fillMaxSize 傳入的 Constraints 是 200300,rwapContentSize 必須填滿這個空間,而由于它有一個默認參數 align = Alignment.Center,所以才能出現 5050 矩形塊居中的效果。
Intrinsic Measurements - 固有特性測量
中文將其翻譯成 "固有特性",很多人不理解 "固有" 到底指什么?所以放在本文最后討論一下。
Compose 要求布局過程中每個節點只被測量一次,測量總耗時只與節點數正相關,與層級無關,所以 ComopseUI 不怕嵌套過深,而傳統 Android 視圖系統中,某個 View 存在多次測量的情況,隨著層級變多測量次數會指數級增長,所以傳圖視圖下我們需要通過優化 View 的層級提升性能。
Compose 為了保證 "每個節點只測量一次" 的原則,甚至增加了編譯期檢查:
val constraints1 = ... val constraints2 = ... val placeable1 = measurable.measure(constraints1 val placeable2 = measurable.measure(constraints2)
"每個節點只測量一次"在提升性能的同時也帶來了問題。來自官方文檔的例子:
@Composable fun TwoTexts(modifier: Modifier = Modifier, text1: String, text2: String) { Row(modifier = modifier) { Text( modifier = Modifier .weight(1f) .padding(start = 4.dp) .wrapContentWidth(Alignment.Start), text = text1 ) Divider( color = Color.Black, modifier = Modifier.fillMaxHeight().width(1.dp) ) Text( modifier = Modifier .weight(1f) .padding(end = 4.dp) .wrapContentWidth(Alignment.End), text = text2 ) } }上面代碼的本意是希望打造以下的布局效果:
但發現實際效果不符合預期: Divider 的高度沒有對齊左右的 Text,而是撐滿了容器高度:
Row 為測量 Divider 傳入Constraints 時,不知道對齊 Text 高度應該設置怎樣的 maxHeight。傳入的 maxHeight 值比較大導致 Divider 的 fillMaxSize 撐滿了整個容器。 傳統視圖體系中類似的情況,Row 在測量了 Text 的高度后,會再測量一次 Divider 并給出更合適的 View.MeasureSpec,但 Compose 中不可以,因為這樣違反了 "每個節點只測量一次"的原則。
為此, Compose 引入了 "固有特性測量" 的機制。在當前節點正式發起深度遍歷子測量節點之前的一次 "預處理",從子節點提前獲取必要信息,設置更合理的 Constraints,然后再發起正式測量。 MeasurePolicy 中提供了獲取 "固有特性" 尺寸的方法: IntrinsicMeasureScope.minIntrinsicXXX
fun interface MeasurePolicy { fun IntrinsicMeasureScope.minIntrinsicWidth( measurables: ListText 的固有特性的 minIntrinsicHeight 是文本內容單行展示的高度;Divider 的 minIntrinsicHeight 是 0,當我們改一下例子中的代碼,在 Row 的Modifier.height 增加 IntrinsicSize.Min。, height: Int ): Int fun IntrinsicMeasureScope.minIntrinsicHeight fun IntrinsicMeasureScope.maxIntrinsicWidth fun IntrinsicMeasureScope.maxIntrinsicHeight }
Row(modifier = modifier.height(IntrinsicSize.Min)) {...}Row 在發起子節點測量前,通過 MeasurePolicy 提供的固有特性相關方法,獲取所有子節點的minIntrinsicHeight,取最大的一個設為 Constraints.maxHeight 后發起正式測量。這樣,Divider 的 fillMaxSize 就會跟 Text 兩邊高度對齊了。
看到這里相信大家理解 "固有"的含義了,其本質代表 "不依賴 Constraints"就可以獲取的值,基于這些值更新 Constraints,后續測量只有一次也能正確約束。
-
Android
+關注
關注
12文章
3945瀏覽量
127924 -
函數
+關注
關注
3文章
4346瀏覽量
62968 -
代碼
+關注
關注
30文章
4825瀏覽量
69035
原文標題:【GDE 分享】一文看懂 Jetpack Compose 布局流程
文章出處:【微信號:Google_Developers,微信公眾號:谷歌開發者】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論