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

詳解Jetpack Compose 1.1版本的新功能
如何使用 Compose 進(jìn)行構(gòu)建
Jetpack Compose基礎(chǔ)知識(shí)科普
Android Studio Dolphin穩(wěn)定版正式發(fā)布
Compose Material 3 穩(wěn)定版現(xiàn)已發(fā)布 | 2022 Android 開發(fā)者峰會(huì)
Jetpack Compose 更新一覽 | 2022 Android 開發(fā)者峰會(huì)
Google計(jì)劃用Jetpack Compose來重建Android系統(tǒng)中的設(shè)置應(yīng)用
Compose for Wear OS 1.1 推出穩(wěn)定版: 了解新功能!
Kotlin聲明式UI框架Compose Multiplatform支持iOS

Jetpack Compose和設(shè)備類型的三大重要更新
docker-compose配置文件內(nèi)容詳解以及常用命令介紹

評(píng)論