深度解析 Jetpack Compose 佈局

南方吳彥祖_藍斯發表於2022-03-24

Jetpack Compose 是用於構建原生 Android 介面的新工具包。它可簡化並加快 Android 上的介面開發,使用更少的程式碼、強大的工具和直觀的 Kotlin API,快速讓應用生動而精彩。Compose 使用全新的元件——可組合項 (Composable) 來佈局介面,使用 修飾符 (Modifier) 來配置可組合項。

本文會為您講解由可組合項和修飾符提供支援的組合佈局模型,並深入探究其背後的工作原理以及它們的功能,讓您更好地瞭解所用佈局和修飾符的工作方式,和應如何以及在何時構建自定義佈局,從而實現滿足確切應用需求的設計。

如果您更喜歡通過視訊瞭解本文內容,請 點選這裡 觀看。

佈局模型

Compose 佈局系統的目標是提供易於建立的佈局,尤其是 自定義佈局。這要求佈局系統具備強大的功能,使開發者能建立應用所需的任何佈局,並且讓佈局具備優異的效能。接下來,我們來看看 Compose 的佈局模型 是如何實現這些目標的。

Jetpack Compose 可將狀態轉換為介面,這個過程分為三步: 組合、佈局、繪製。組合階段執行 可組合函式,這些函式可以生成介面,從而建立介面樹。例如,下圖中的 SearchResult 函式會生成對應的介面樹:

△ 可組合函式生成對應的介面樹

△ 可組合函式生成對應的介面樹

可組合項中可以包含邏輯和控制流,因此可以根據不同的狀態生成不同的介面樹。在佈局階段,Compose 會遍歷介面樹,測量介面的各個部分,並將每個部分放置在螢幕 2D 空間中。也就是說,每個節點決定了其各自的寬度、高度以及 x 和 y 座標。在繪製階段,Compose 將再次遍歷這棵介面樹,並渲染所有元素。

本文將深入探討佈局階段。佈局階段又細分為兩個階段: 測量和放置。這相當於 View 系統中的 onMeasure 和 onLayout。但在 Compose 中,這兩個階段會交叉進行,因此我們把它看成一個佈局階段。將介面樹中每個節點佈局的過程分為三步: 每個節點必須測量自身的所有子節點,再決定自身的尺寸,然後放置其子節點。如下例,單遍即可對整個介面樹完成佈局。

△ 佈局過程

△ 佈局過程

其過程簡述如下:

  1. 測量根佈局 Row;
  2. Row 測量它的第一個子節點 Image;
  3. 由於 Image 是一個不含子節點的葉子節點,它會測量自身尺寸並加以報告,還會返回有關如何放置其子節點的指令。Image 的葉子節點通常是空節點,但所有佈局都會在設定其尺寸的同時返回這些放置指令;
  4. Row 測量它的第二個子節點 Column;
  5. Column 測量其子節點,首先測量第一個子節點 Text;
  6. Text 測量並報告其尺寸以及放置指令;
  7. Column 測量第二個子節點 Text;
  8. Text 測量並報告其尺寸以及放置指令;
  9. Column 測量完其子節點,可以決定其自身的尺寸和放置邏輯;
  10. Row 根據其所有子節點的測量結果決定其自身尺寸和放置指令。

測量完所有元素的尺寸後,將再次遍歷介面樹,並且會在放置階段執行所有放置指令。

Layout 可組合項

我們已經瞭解這個過程涉及的步驟,接下來看一下它的實現方式。先看看組合階段,我們採用 Row、Column、Text 等更高階別的可組合項來表示介面樹,每個高階別的可組合項實際上都是由低階別的可組合項構建而成。以 Text 為例,可以發現它由若干更低階別的基礎構建塊組成,而這些可組合項都會包含一個或多個 Layout 可組合項。

△ 每個可組合項都包含一個或多個 Layout

△ 每個可組合項都包含一個或多個 Layout

Layout 可組合項是 Compose 介面的基礎構建塊,它會生成 LayoutNode。在 Compose 中,介面樹,或者說組合 (composition) 是一棵 LayoutNode 樹。以下是 Layout 可組合項的函式簽名:

@Composable
fun Layout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
    measurePolicy: MeasurePolicy
) {
    …
}

△ Layout 可組合項的函式簽名

其中,content 是可以容納任何子可組合項的槽位,出於佈局需要,content 中也會包含子 Layout。modifier 引數所指定的修飾符將應用於該佈局,這在下文中會詳細介紹。measurePolicy 引數是 MeasurePolicy 型別,它是一個函式式介面,指定了佈局測量和放置專案的方式。一般情況下,如需實現自定義佈局的行為,您要在程式碼中實現該函式式介面:

@Composable
fun MyCustomLayout(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
         modifier = modifier,
         content = content
    ) { measurables: List<Measurable>,
         constraints: Constraints ->
        // TODO 測量和放置專案
   }
}

△ 實現 MeasurePolicy 函式式介面

在 MyCustomLayout 可組合項中,我們呼叫 Layout 函式並以 Trailing Lambda 的形式提供 MeasurePolicy 作為引數,從而實現所需的 measure 函式。該函式接受一個 Constraints 物件來告知 Layout 它的尺寸限制。Constraints 是一個簡單類,用於限制 Layout 的最大和最小寬度與高度:

class Constraints {
    val minWidth: Int
    val maxWidth: Int
    val minHeight: Int
    val maxHeight: Int
}

△ Constraints

measure 函式還會接受 List<Measurable> 作為引數,這表示的是傳入的子元素。Measurable 型別會公開用於測量專案的函式。如前所述,佈局每個元素需要三步: 每個元素必須測量其所有子元素,並以此判斷自身尺寸,再放置其子元素。其程式碼實現如下:

@Composable
fun MyCustomLayout(
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier
) {
    Layout(
         modifier = modifier,
         content = content
    ) { measurables: List<Measurable>,
         constraints: Constraints ->
        // placeables 是經過測量的子元素,它擁有自身的尺寸值
        val placeables = measurables.map { measurable ->
            // 測量所有子元素,這裡不編寫任何自定義測量邏輯,只是簡單地
            // 呼叫 Measurable 的 measure 函式並傳入 constraints
            measurable.measure(constraints)
        }
        val width = // 根據 placeables 計算得出
        val height = // 根據 placeables 計算得出
        // 報告所需的尺寸
        layout (width, height) {
            placeables.foreach { placeable ->
                // 通過遍歷將每個專案放置到最終的預期位置
                placeable.place(
                    x = …
                    y = …
                )
            }
        }
   }
}

△ 佈局每個元素的程式碼示例

上述程式碼中使用了 Placeable 的 place 函式,它還有一個 placeRelative 函式可用於從右到左的語言設定中,當使用該函式時,它會自動對座標進行水平映象。

請注意,API 在設計上可阻止您嘗試放置未經測量的元素,place 函式只適用於 Placeable,也就是 measure 函式的返回值。在 View 系統中,呼叫 onMeasure 以及 onLayout 的時機由您決定,而且呼叫順序沒有強制要求,但這會產生一些微妙的 bug 以及行為上的差異。

自定義佈局示例

MyColumn 示例

△ Column

△ Column

Compose 提供一個 Column 元件用於縱向排布元素。為了理解這個元件背後的工作方式及其使用 Layout 可組合項的方式,我們來實現自己的一個 Column。暫且將其命名為 MyColumn,其實現程式碼如下:

@Composable
fun MyColumn(
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit
) {
    Layout(
         modifier = modifier,
         content = content
    ) { measurables, constraints ->
        // 測量每個專案並將其轉換為 Placeable
        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }
        // Column 的高度是所有專案所測得高度之和
        val height = placeables.sumOf { it.height }
        // Column 的寬度則為內部所含最寬專案的寬度
        val width = placeables.maxOf { it.width }
        // 報告所需的尺寸
        layout (width, height) {
            // 通過跟蹤 y 座標放置每個專案
            var y = 0
            placeables.forEach { placeable ->
                placeable.placeRelative(x = 0, y = y)
                // 按照所放置專案的高度增加 y 座標值
                y += placeable.height
            }
        }
    }
}

△ 自定義 Column

VerticalGrid 示例

△ VerticalGrid

△ VerticalGrid

我們再來看另一個示例: 構建常規網格。其部分程式碼實現如下:

@Composable
fun VerticalGrid(
    modifier: Modifier = Modifier,
    columns: Int = 2,
    content: @Composable () -> Unit
) {
    Layout(
        content = content,
         modifier = modifier
    ) { measurables, constraints ->
        val itemWidth = constraints.maxWidth / columns
        // 通過 copy 函式保留傳遞下來的高度約束,但設定確定的寬度約束
        val itemConstraints = constraints.copy (
            minWidth = itemWidth,
            maxWidth = itemWidth,
        )        
        // 使用這些約束測量每個專案並將其轉換為 Placeable
        val placeables = measurables.map { it.measure(itemConstraints) }
        …
    }
}

△ 自定義 VerticalGrid

在該示例中,我們通過 copy 函式建立了新的約束。這種為子節點建立新約束的概念就是實現自定義測量邏輯的方式。建立不同約束來測量子節點的能力是此模型的關鍵,父節點與子節點之間並沒有協商機制,父節點會以 Constraints 的形式傳遞其允許子節點的尺寸範圍,只要子節點從該範圍中選擇了其尺寸,父節點必須接受並處理子節點。

這種設計的優點在於我們可以單遍測量整棵介面樹,並且禁止執行多個測量迴圈。這是 View 系統中存在的問題,巢狀結構執行多遍測量過程可能會讓葉子檢視上的測量次數翻倍,Compose 的設計能夠防止發生這種情況。實際上,如果您對某個專案進行兩次測量,Compose 會丟擲異常:

△ 重複測量某個專案時 Compose 會丟擲異常

△ 重複測量某個專案時 Compose 會丟擲異常

佈局動畫示例

由於具備更強的效能保證,Compose 提供了新的可能性,例如為佈局新增動畫。Layout composable 不僅可以建立通用佈局,還能建立出符合應用設計需求的專用佈局。以 Jetsnack 應用中的自定義底部導航為例,在該設計中,如果某專案被選中,則顯示標籤;如果未被選中,則只顯示圖示。而且,設計還需要讓專案的尺寸和位置根據當前選擇狀態執行動畫。

△ Jetsnack 應用中的自定義底部導航

△ Jetsnack 應用中的自定義底部導航

我們可以使用自定義佈局來實現該設計,從而對佈局變化的動畫處理進行精確控制:

@Composable
fun BottomNavItem(
    icon: @Composable BoxScope.() -> Unit,
    text: @Composable BoxScope.() -> Unit,
    @FloatRange(from = 0.0, to = 1.0) animationProgress: Float
) {
    Layout(
        content = {
            // 將 icon 和 text 包裹在 Box 中
            // 這種做法能讓我們為每個專案設定 layoutId
            Box(
                modifier = Modifier.layoutId(“icon”)
                content = icon
            )
            Box(
                modifier = Modifier.layoutId(“text”)
                content = text
            )
        }
    ) { measurables, constraints ->
        // 通過 layoutId 識別對應的 Measurable,比依賴專案的順序更可靠
        val iconPlaceable = measurables.first {it.layoutId == “icon” }.measure(constraints)
        val textPlaceable = measurables.first {it.layoutId == “text” }.measure(constraints)
 
        // 將放置邏輯提取到另一個函式中以提高程式碼可讀性
        placeTextAndIcon(
            textPlaceable,
            iconPlaceable,
            constraints.maxWidth,
            constraints.maxHeight,
            animationProgress
        )
    }
}
 
fun MeasureScope.placeTextAndIcon(
    textPlaceable: Placeable,
    iconPlaceable: Placeable,
    width: Int,
    height: Int,
    @FloatRange(from = 0.0, to = 1.0) animationProgress: Float
): MeasureResult {
 
    // 根據動畫進度值放置文字和圖示
    val iconY = (height - iconPlaceable.height) / 2
    val textY = (height - textPlaceable.height) / 2
 
    val textWidth = textPlaceable.width * animationProgress
    val iconX = (width - textWidth - iconPlaceable.width) / 2
    val textX = iconX + iconPlaceable.width
 
    return layout(width, height) {
        iconPlaceable.placeRelative(iconX.toInt(), iconY)
        if (animationProgress != 0f) {
            textPlaceable.placeRelative(textX.toInt(), textY)
        }
    }
}

△ 自定義底部導航

使用自定義佈局的時機

希望以上示例能幫助您瞭解自定義佈局的工作方式以及這些佈局的應用理念。標準佈局強大而靈活,但它們也需要適應很多用例。有時,若您知道具體的實現需求,使用自定義佈局可能更加合適。

當您遇到以下場景時,我們推薦使用自定義佈局:

  • 難以通過標準佈局實現的設計。雖然可以使用足夠多的 Row 和 Column 構建大部分介面,但這種實現方式有時難以維護和升級;
  • 需要非常精確地控制測量和放置邏輯;
  • 需要實現佈局動畫。我們正在開發可對放置進行動畫處理的新 API,未來可能不必自行編寫佈局就能實現;
  • 需要完全控制效能。下文會詳細介紹這一點。

修飾符

至此,我們瞭解了 Layout 可組合項以及構建自定義佈局的方式。如果您使用 Compose 構建過介面,就會知道 修飾符 在佈局、配置尺寸和位置方面發揮著重要作用。通過前文的示例可以看到,Layout 可組合項接受修飾符鏈作為引數。修飾符會裝飾它們所附加的元素,可以在佈局自身的測量和放置操作之前參與測量和放置。接下來我們來看看它的工作原理。

修飾符分很多不同的型別,可以影響不同的行為,例如繪製修飾符 (DrawModifier)、指標輸入修飾符 (PointerInputModifier) 以及焦點修飾符 (FocusModifier)。本文我們將重點介紹佈局修飾符 (LayoutModifier),該修飾符提供了一個 measure 方法,該方法的作用與 Layout 可組合項基本相同,不同之處在於,它只作用於單個 Measurable 而不是 List<Measurable>,這是因為修飾符的應用物件是單個專案。在 measure 方法中,修飾符可以修改約束或者實現自定義放置邏輯,就像佈局一樣。這表示您並不總是需要編寫自定義佈局,如果只想對單個專案執行操作,則可以改用修飾符。

以 padding 修飾符為例,該工廠函式以修飾符鏈為基礎,建立能夠捕獲所需 padding 值的 PaddingModifier 物件。

fun Modifier.padding(all: Dp) =
    this.then(PaddingModifier(
            start = all,
            top = all,
            end = all,
            bottom = all
        )
    )
 
private class PaddingModifier(
    val start: Dp = 0.dp,
    val top: Dp = 0.dp,
    val end: Dp = 0.dp,
    val bottom: Dp = 0.dp
) : LayoutModifier {
 
override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val horizontal = start.roundToPx() + end.roundToPx()
        val vertical = top.roundToPx() + bottom.roundToPx()
 
        // 按 padding 尺寸收縮外部約束來修改測量
        val placeable = measurable.measure(constraints.offset(-horizontal, -vertical))
 
        val width = constraints.constrainWidth(placeable.width + horizontal)
        val height = constraints.constrainHeight(placeable.height + vertical)
        return layout(width, height) {
                // 按所需的 padding 執行偏移以放置內容
                placeable.placeRelative(start.roundToPx(), top.roundToPx())
        }
    }
}

△ padding 修飾符的實現

除了通過上例中的方式覆寫 measure 方法實現測量,您也可以使用 Modifier.layout,在無需建立自定義佈局的情況下直接通過修飾符鏈向任意可組合項新增自定義測量和放置邏輯,如下所示:

Box(Modifier
            .background(Color.Gray)
            .layout { measurable, constraints ->
                // 通過修飾符在豎直方向新增 50 畫素 padding 的示例
                val padding = 50
                val placeable = measurable.measure(constraints.offset(vertical = -padding))
                layout(placeable.width, placeable.height + padding) {
                    placeable.placeRelative(0, padding)
                }
            }
        ) {
            Box(Modifier.fillMaxSize().background(Color.DarkGray))
        }

△ 使用 Modifier.layout 實現佈局

雖然 Layout 接受單個 Modifier 引數,該引數會建立一個按順序應用的修飾符鏈。我們通過示例來了解它與佈局模型的互動方式。我們將分析下圖修飾符的效果及其工作原理:

△ 修飾符鏈的效果示例

△ 修飾符鏈的效果示例

首先,我們為 Box 設定尺寸並將其繪製出來,但這個 Box 放置在了父佈局的左上角,我們可以使用 wrapContentSize 修飾符將 Box 居中放置。wrapContentSize 允許內容測量其所需尺寸,然後使用 align 引數放置內容,align 引數的預設值為 Center,因此可以省略這個引數。但我們發現,Box 還是在左上角。這是因為大多數佈局都會根據其內容自適應調整尺寸,我們需要讓測量尺寸佔據整個空間,以便讓 Box 在空間內居中。因此,我們在 wrapContentSize 前面新增 fillMaxSize 佈局修飾符來實現這個效果。

△ 修飾符鏈的應用過程

△ 修飾符鏈的應用過程

我們來看一下這些修飾符是如何實現此效果的。您可以藉助下圖動畫來輔助理解該過程:

△ 修飾符鏈的工作原理

△ 修飾符鏈的工作原理

假設這個 Box 要放入最大尺寸為 200*300 畫素的容器內,容器會將相應的約束傳入修飾符鏈的第一個修飾符中。fillMaxSize 實際上會建立一組新約束,並設定最大和最小寬度與高度,使之等於傳入的最大寬度與高度以便填充到最大值,在本例中是 200*300 畫素。這些約束沿著修飾符鏈傳遞以測量下一個元素,wrapContentSize 修飾符會接受這些引數,它會建立新的約束來放寬對傳入約束的限制,從而讓內容測量其所需尺寸,也就是寬 0-200,高 0-300。這看起來只像是對 fillMax 步驟的反操作,但請注意,我們是使用這個修飾符實現專案居中的效果,而不是重設專案的尺寸。這些約束沿著修飾符鏈傳遞到 size 修飾符,該修飾符建立具體尺寸的約束來測量專案,指定尺寸應該正好是 50*50。最後,這些約束傳遞到 Box 的佈局,它執行測量並將解析得到的尺寸 (50*50) 返回到修飾符鏈,size 修飾符因此也將其尺寸解析為 50*50,並據此建立放置指令。然後 wrapContent 解析其大小並建立放置指令以居中放置內容。因為 wrapContent 修飾符知道其尺寸為 200*300,而下一個元素的尺寸為 50*50,所以使用居中對齊建立放置指令,以便將內容居中放置。最後,fillMaxSize 解析其尺寸並執行放置操作。

修飾符鏈的執行方式與佈局樹的工作方式非常相像,差異在於每個修飾符只有一個子節點,也就是鏈中的下一個元素。約束會向下傳遞,以便後續元素用其測量自身尺寸,然後返回解析得到的尺寸,並建立放置指令。該示例也說明了 修飾符順序的重要性。通過使用修飾符對功能進行組合,您可以很輕鬆地將不同的測量和佈局策略組合在一起。

高階功能

接下來將介紹佈局模型的一些高階功能,雖然您不一定總是需要這些功能,但它們能夠幫助您構建更高階的功能。

固有特性測量 (Intrinsic Measurement)

前文提到過,Compose 使用單遍佈局系統。這個說法並不完全正確,佈局並不總是能通過單遍操作就得以完成,有時我們也需要了解有關子節點尺寸的資訊才能最終確定約束。

以彈出式選單為例。假設有一個包含五個選單項的 Column,如下圖所示,它的顯示基本上是正常的,但是可以看到,每個選單項的尺寸卻不相同。

△ 選單項的尺寸不相同

△ 選單項的尺寸不相同

我們很容易想到,讓每個選單項都佔用允許的最大尺寸即可:

△ 每個選單項都佔有允許的最大尺寸

△ 每個選單項都佔有允許的最大尺寸

但這麼做也沒能完全解決問題,因為選單視窗會擴大到其最大尺寸。有效的解決方法是使用最大固有寬度來確定尺寸:

△ 使用最大固有寬度來確定尺寸

△ 使用最大固有寬度來確定尺寸

這裡確定了 Column 會盡力為每個子節點提供所需的空間,對 Text 而言,其寬度是單行渲染全部文字所需的寬度。在確定固有尺寸後,將使用這些值設定 Column 的尺寸,然後,子節點就可以填充 Column 的寬度了。

如果使用最小值而非最大值,又會發生什麼呢?

△ 使用最小固有寬度來確定尺寸

△ 使用最小固有寬度來確定尺寸

它將確定 Column 會使用子節點的最小尺寸,而 Text 的最小固有寬度是每行一個詞時的寬度。因此,我們最後得到一個按詞換行的選單。

如需詳細瞭解固有特性測量,請參閱 Jetpack Compose 中的佈局 Codelab 中的 "固有特性" 部分。

ParentData

到目前為止,我們看到的修飾符都是通用修飾符,也就是說,它們可以應用於任何可組合項。有時,您的佈局提供的一些行為可能需要從子節點獲得一些資訊,這便要用到 ParentDataModifier

我們回到前面那個在父節點中居中放置藍色 Box 的示例。這一次,我們將這個 Box 放在另一個 Box 中。Box 中的內容在一個稱為 BoxScope 的接收器作用域內排布。BoxScope 定義了只在 Box 內可用的修飾符,它提供了一個名為 Align 的修飾符。這個修飾符剛好能夠提供我們要應用到藍色 Box 的功能。因此,如果我們知道藍色 Box 位於另一個 Box 內,就可以改用 Align 修飾符來定位它。

△ 在 BoxScope 中可以改用 Align 修飾符來定位內容

△ 在 BoxScope 中可以改用 Align 修飾符來定位內容

Align 是一個 ParentDataModifier 而不是我們之前看到的那種佈局修飾符,因為它只是向其父節點傳遞一些資訊,所以如果不在 Box 中,該修飾符便不可用。它包含的資訊將提供給父 Box,以供其設定子佈局。

您也可以為自己的自定義佈局編寫 ParentDataModifier,從而允許子節點向父節點告知一些資訊,以供父節點在佈局時使用。

對齊線 (Alignment Lines)

我們可以使用對齊線根據佈局頂部、底部或中心以外的標準來設定對齊。最常用的 對齊線 是文字基線。假設需要實現這樣一個設計:

△ 需要實現設計圖中的圖示和文字對齊

△ 需要實現設計圖中的圖示和文字對齊

我們很自然就能想到這樣來實現它:

Row {
    Icon(modifier = Modifier
        .size(10. dp)
        .align(Alignment.CenterVertically)
    )
    Text(modifier = Modifier
        .padding(start = 8.dp)
        .align(Alignment.CenterVertically)
    )
}

△ 有問題的對齊實現

仔細觀察,會發現圖示並沒有像設計稿那樣對齊在文字的基線上。

△ 圖示和文字居中對齊,圖示底部沒有落在文字基線上

△ 圖示和文字居中對齊,圖示底部沒有落在文字基線上

我們可以通過以下程式碼進行修復:

Row {
    Icon(modifier = Modifier
        .size(10. dp)
        .alignBy { it.measuredHeight }
    )
    Text(modifier = Modifier
        .padding(start = 8.dp)
        .alignByBaseline()
    )
}

△ 正確的對齊實現

首先,對 Text 使用 alignByBaseline 修飾符。而圖示既沒有基線,也沒有其他對齊線,我們可以使用 alignBy 修飾符讓圖示對齊到我們需要的任何位置。在本例中,我們知道圖示的底部是對齊的目標位置,因此將圖示的底部進行對齊。最終便實現了期望的效果:

△ 圖示底部與文字基線完美對齊

△ 圖示底部與文字基線完美對齊

由於對齊功能會穿過父節點,因此,處理巢狀對齊時,只需設定父節點的對齊線,它會從子節點獲取相應的值。如下例所示:

△ 未設定對齊的巢狀佈局

△ 未設定對齊的巢狀佈局

△ 通過父節點設定對齊線

△ 通過父節點設定對齊線

您甚至可以在自定義佈局中建立自己的自定義對齊,從而允許其他可組合項對齊到它。

BoxWithConstraints

BoxWithConstraints 是一個功能強大且很實用的佈局。在組合中,我們可以根據條件使用邏輯和控制流來選擇要顯示的內容,但是,有時候可能希望根據可用空間的大小來決定佈局內容。

從前文中我們知道,尺寸資訊直到佈局階段才可用,也就是說,這些資訊一般無法在組合階段用來決定要顯示的內容。此時 BoxWithConstraints 便派上用場了,它與 Box 類似,但它將內容的組合推遲到佈局階段,此時佈局資訊已經可用了。BoxWithConstraints 中的內容在接收器作用域內排布,佈局階段確定的約束將通過該作用域公開為畫素值或 DP 值。

@Composable
fun BoxWithConstraints(
        ...
        content: @Composable BoxWithConstraintsScope.() -> Unit
)
 
// BoxWithConstraintsScope 公開佈局階段確定的約束
interface BoxWithConstraintsScope : BoxScope {
    val constraints: Constraints
    val minWidth: Dp
    val maxWidth: Dp
    val minHeight: Dp
    val maxHeight: Dp
}

△ BoxWithConstraints 和 BoxWithConstraintsScope

它內部的內容可以使用這些約束來選擇要組合的內容。例如,根據最大寬度選擇不同的呈現方式:

@Composable
fun MyApp(...) {
    BoxWithConstraints() { // this: BoxWithConstraintsScope
        when {
            maxWidth < 400.dp -> CompactLayout()
            maxWidth < 800.dp -> MediumLayout()
            else -> LargeLayout()
        }
    }
}

△ 在 BoxWithConstraintsScope 中根據最大寬度選擇不同的佈局

效能

我們介紹了單遍佈局模型如何防止在測量或放置方面花費過多時間,也演示了佈局階段兩個不同的子階段: 測量和放置。現在,我們將介紹效能相關的內容。

儘量避免重組

單遍佈局模型的設計效果是,任何只影響專案的放置而不影響測量的修改都可以單獨執行。以 Jetsnack 為例:

△ Jetsnack 應用中產品詳情頁的協調滾動效果

△ Jetsnack 應用中產品詳情頁的協調滾動效果

這個產品詳情頁包含協調滾動效果,頁面上的一些元素根據滾動操作進行移動或縮放。請注意標題區域,這個區域會隨著頁面內容而滾動,最後固定在螢幕的頂部。

@Composable
fun SnackDetail(...) {
    Box {
        val scroll = rememberScrollState(0)
        Body(scroll)
        Title(scroll = scroll.value)
        ...
    }
}
 
@Composable
fun Body(scroll: ScrollState) {
    Column(modifier = Modifier.verticalScroll(scroll)) {
        …
    }
}

△ 詳情頁的大致實現

為了實現此效果,我們將不同元素作為獨立的可組合項疊放在一個 Box 中,提取滾動狀態並將其傳入 Body 元件。Body 會使用滾動狀態進行設定以使內容能夠垂直滾動。在 Title 等其他元件中可以觀察滾動位置,而我們的觀察方式會對效能產生影響。例如,使用最直接的實現,簡單地使用滾動值對內容進行偏移:

@Composable
fun Title(scroll: Int) {
    Column(
        modifier = Modifier.offset(scroll)
    ) {
        …
    }
}

△ 簡單地使用滾動值偏移 Title 的內容

這種方法的問題是,滾動是一個可觀察的狀態值,讀取該值所處的作用域規定了狀態發生變化時 Compose 需要重新執行的操作。在此示例中,我們要讀取組合中的滾動偏移值,然後使用它來建立偏移修飾符。只要滾動偏移值發生變化,Title 元件都需要重新組合,也就需要建立並執行新的偏移修飾符。由於滾動狀態是從組合中讀取的,任何更改都會導致重組,在重組時,還需要進行佈局和繪製這兩個後續階段。

不過,我們不是要更改顯示的內容,而是更改內容的位置。我們還可以進一步提高效率,通過修改一下實現,不再接受原始滾動位置,而是傳遞一個可以提供滾動位置的函式:

@Composable
fun Title(scrollProvider: () -> Int) {
    Column(
        modifier = Modifier.offset {
            val scroll = scrollProvider()
            val offset = (maxOffset - scroll).coerceAtLeast(minOffset)
            IntOffset(x = 0, y = offset)
        }
    ) {
        …
    }
}

△ 使用提供滾動位置的函式代替原始滾動位置

這時,我們可以在不同的時間只呼叫此 Lambda 函式並讀取滾動狀態。這裡使用了 offset 修飾符,它接受能提供偏移值的 Lambda 函式作為引數。這意味著在滾動發生變化時,不需要重新建立修飾符,只在放置階段才會讀取滾動狀態的值。所以,當滾動狀態變化時我們只需要執行放置和繪製操作,不需要重組或測量,因此能夠提高效能。

再回到底部導航的示例,它存在同樣的問題,我們可以用相同方法加以修正:

@Composable
fun BottomNavItem(
    icon: @Composable BoxScope.() -> Unit,
    text: @Composable BoxScope.() -> Unit,
    animationProgress: () -> Float
) {
    …
 
    val progress = animationProgress()
 
    val textWidth = textPlaceable.width * progress
    val iconX = (width - textWidth - iconPlaceable.width) / 2
    val textX = iconX + iconPlaceable.width
 
    return layout(width, height) {
        iconPlaceable.placeRelative(iconX.toInt(), iconY)
        if (animationProgress != 0f) {
            textPlaceable.placeRelative(textX.toInt(), textY)
        }
    }
}

△ 修正後的底部導航

我們使用了能提供當前動畫進度的函式作為引數,因此不需要重組,只執行佈局即可。

您需要掌握一個原則: 只要可組合項或修飾符的引數可能頻繁發生更改,都應當保持謹慎,因為這種情況可能導致過度組合。只有在更改顯示內容時,才需要重組,更改顯示位置或顯示方式則不需要這麼做。

BoxWithConstraints 可以根據佈局執行組合,是因為它會在佈局階段啟動子組合。出於效能考慮,我們希望儘量避免在佈局期間執行組合。因此,相較於 BoxWithConstraints,我們傾向於使用會根據尺寸更改的佈局。當資訊型別隨尺寸更改時才使用 BoxWithConstraints。

提高佈局效能

有時候,佈局不需要測量其所有子節點便可獲知自身大小。舉個例子,有如下構成的卡片:

△ 佈局卡片示例

△ 佈局卡片示例

圖示和標題構成標題欄,剩下的是正文。已知圖示大小為固定值,標題高度與圖示高度相同。測量卡片時,就只需要測量正文,它的約束就是佈局高度減去 48 DP,卡片的高度則為正文的高度加上 48 DP。

△ 測量過程只測量正文尺寸

△ 測量過程只測量正文尺寸

系統識別出只測量了正文,因此它是決定佈局尺寸的唯一重要子節點,圖示和文字仍然需要測量,但可以在放置過程中執行。

△ 放置過程測量圖示和文字

△ 放置過程測量圖示和文字

假設標題是 "Layout",當標題發生變化時,系統不必重新執行佈局的測量操作,因此不會重新測量正文,從而省去不必要的工作。

△ 標題發生變化時不必重新測量

△ 標題發生變化時不必重新測量

總結

在本文中,我們介紹了自定義佈局的實現過程,還使用修飾符構建和合並佈局行為,進一步降低了滿足確切功能需求的難度。此外,還介紹了佈局系統的一些高階功能,例如跨巢狀層次結構的自定義對齊,為自有佈局建立自定義 ParentDataModifier,支援自動從右向左設定,以及將組合操作推遲到佈局資訊已知時,等等。我們還了解如何執行單遍佈局模型,如何跳過重新測量以使其只執行重新放置操作的方法,熟練使用這些方法,您將能編寫出通過手勢進行動畫處理的高效能佈局邏輯。

對佈局系統的理解能夠幫助您構建滿足確切設計需求的佈局,從而建立使用者喜愛的優秀應用。如需瞭解更多,請查閱以下列出的資源:

歡迎您 點選這裡 向我們提交反饋,或分享您喜歡的內容、發現的問題。您的反饋對我們非常重要,感謝您的支援!

相關文章