Jetpack Compose 階段

如同多數其他 UI 工具包,Compose 會透過數個不同的「階段」算繪畫面。我們檢視 Android View 系統時,有三個主要階段:測量、版面配置和繪圖。Compose 的情況非常類似,但在開始時還有另一個稱為「組合」的重要階段。

「組合」在我們的 Compose 文件中皆有描述,包括「Compose 的程式設計概念」和「狀態與 Jetpack Compose」等文章。

呈現畫面的三個階段

Compose 有三個主要階段:

  1. 組合:決定要顯示「哪些」UI。Compose 會執行可組合函式,並建立 UI 的描述。
  2. 版面配置安排 UI 的「位置」。這個階段包含兩個步驟:測量和放置。版面配置元素會根據 2D 座標,測量並放置本身以及位於版面配置樹狀結構中每個節點的所有子項元素。
  3. 繪圖設定「算繪」方式。UI 元素會繪製在 Canvas 中,這個畫布通常是指裝置螢幕。
這張圖片顯示 Compose 將資料轉換為 UI 的三個階段 (按照順序、資料、組合、版面配置、繪圖和 UI 排序)。
圖 1. Compose 將資料轉換為 UI 的三個階段。

這些階段通常會以相同順序執行,讓資料以一個方向從組合、版面配置流動到繪圖,藉此產生畫面 (也稱為單向資料流)。BoxWithConstraints 以及 LazyColumnLazyRow 是明顯的例外狀況,因為其子項的組合依附於父項的版面配置階段。

您可以放心假定系統在呈現每個畫面時幾乎都會執行這三個階段,但為了提升效能,Compose 會在所有階段中避免利用相同輸入內容計算相同結果的重複作業。如果 Compose 可以重複使用先前的結果,就會略過可組合函式的執行程序,而且在非必要的情況下,Compose UI 不會重新進行版面配置或者重新繪製整個樹狀結構。Compose 只會執行更新 UI 所需的最低工作量。Compose 會在不同階段內追蹤讀取的狀態,據以執行這項最佳化作業。

瞭解各階段

本節詳細說明如何針對可組合項執行三個 Compose 階段。

樂曲

在組合階段,Compose 執行階段會執行可組合函式,並輸出代表 UI 的樹狀結構結構。這個 UI 樹狀結構由版面配置節點組成,其中包含下一階段所需的所有資訊,如以下影片所示:

圖 2. 代表在組合階段中建立的 UI 的樹狀結構。

程式碼和 UI 樹狀結構的子區段如下所示:

程式碼片段包含五個可組合項和產生的 UI 樹狀結構,以及從父項節點分支的子項節點。
圖 3. UI 樹狀結構的子區段與對應程式碼。

在這些範例中,程式碼中的每個可組合函式都會對應至 UI 樹狀結構中的單一版面配置節點。在更複雜的範例中,可組合項可包含邏輯和控制流程,並根據不同狀態產生不同的樹狀結構。

版面配置

在版面配置階段,Compose 會使用在組合階段產生的 UI 樹狀結構做為輸入內容。版面配置節點集合包含決定每個節點大小和 2D 空間位置所需的所有資訊。

圖 4. 版面配置階段中,UI 樹狀結構中每個版面配置節點的測量和位置。

在版面配置階段,系統會透過下列三個步驟演算法掃遍樹狀結構:

  1. 測量子項:節點會測量其子項 (如果有的話)。
  2. 決定自己的大小:節點會根據這些測量數據,決定節點的大小。
  3. 放置子項:每個子節點都會根據節點自己的位置放置。

在這個階段結束時,每個版面配置節點都具備以下特性:

  • 已指派的「width」和「height」
  • 應繪製位置的 x, y 座標

回顧上一節的 UI 樹狀結構:

程式碼片段包含五個可組合項和產生的 UI 樹狀結構,以及從父項節點分支的子項節點

在這個樹狀結構中,演算法的運作方式如下:

  1. Row 會測量其子項 ImageColumn
  2. 已測量「Image」。由於沒有任何子項,所以會自行決定大小,並將大小回報給 Row
  3. 接著,系統會測量 Column。系統會先測量本身的子項 (兩個 Text 可組合項)。
  4. 系統會評估第一個 Text。沒有任何子項,因此會決定其大小,並將大小回報給 Column
    1. 系統會測量第二個 Text。沒有任何子項,因此會決定自身的大小,並回報給 Column
  5. Column 會使用子項測量結果決定其大小。該物件會使用子項寬度上限和子項高度總和。
  6. Column 會將子項的相對關係放置,使其垂直放在彼此下方。
  7. Row 會使用子項測量結果決定其大小。該物件會使用子項高度上限和子項寬度總和。然後放置子項。

請注意,每個節點只造訪一次。Compose 執行階段只需要通過一次 UI 樹狀結構一次,即可測量和放置所有節點,進而提升效能。當樹狀結構中的節點數量增加時,週遊所需的時間以線性方式增加。相反地,如果每個節點多次造訪,遍歷時間也會以指數方式增加。

繪圖

在繪圖階段,系統會從上到下再次掃遍樹狀結構,而每個節點會依序在螢幕上繪圖。

圖 5. 繪圖階段會在螢幕上繪製像素。

在先前的範例中,樹狀結構內容是以下列方式繪製:

  1. Row 會繪製可能的所有內容,例如背景顏色。
  2. Image 會自行繪製。
  3. Column 會自行繪製。
  4. 第一個和第二個 Text 會分別繪製。

圖 6. UI 樹狀結構和其繪製表示法。

狀態讀取

當您讀取上述其中一個階段期間的快照狀態值時,Compose 會自動追蹤該值被讀取時自身正在執行的動作。這項追蹤功能可讓 Compose 在狀態值變更時重新執行讀取器,也是 Compose 狀態觀測能力的基礎。

狀態通常是使用 mutableStateOf() 建立,然後透過以下兩種方式存取:直接存取 value 屬性,或者使用 Kotlin 屬性委派項目。詳情請參閱可組合項的狀態相關說明。就本指南的目的而言,「狀態讀取」是指上述任一種同等存取方法。

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)

// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

在實際運作時,屬性委派會使用「getter」和「setter」函式來存取及更新狀態的 value。您只有在將屬性當做一個值來參照 (而非屬性建立) 時,才能叫用 getter 和 setter 函式,因此上述的兩種方式是等同的方法。

每個程式碼區塊在讀取狀態變更為「重新啟動範圍」時,可以重新執行。Compose 會持續追蹤不同階段中的狀態值變更和重新啟動範圍。

階段性狀態讀取

如前所述,Compose 有三個主要階段,且會在每個階段內追蹤所讀取的狀態。這樣一來,Compose 就能根據每個受影響的 UI 元素,僅通知需要執行工作的特定階段。

現在讓我們深入瞭解各個階段,說明在各階段中讀取狀態值時會發生的情況。

階段 1:組合

@Composable 函式或 lambda 區塊中讀取狀態會影響組合,也可能會影響後續階段。當狀態值變更時,重組工具會安排重新執行所有讀取該狀態值的可組合函式。請注意,如果輸入內容沒有變更,執行階段可能會決定略過部分或所有可組合函式。詳情請參閱如果輸入內容未變更,則可略過一文。

Compose UI 會依據組合結果執行版面配置和繪圖階段。如果內容維持不變,尺寸和版面配置也不會更改,就會略過這些階段。

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

階段 2:版面配置

版面配置階段包含兩個步驟:「測量」和「放置」。測量步驟會執行傳送至 Layout 可組合項、LayoutModifier 介面的 MeasureScope.measure 方法等的測量 lambda。放置步驟則會執行 layout 函式的位置區塊、Modifier.offset { … } 的 lambda 區塊等等。

每個步驟的狀態讀取作業都會影響版面配置,也可能會影響繪圖階段。當狀態值變更時,Compose UI 會安排執行版面配置階段。如果尺寸或位置發生變更,也會執行繪圖階段。

更準確地說,測量步驟和放置步驟設有不同的重新啟動範圍,也就是說,放置步驟中的狀態讀取作業不會重新叫用之前的測量步驟。不過,這兩個步驟常會交織進行,因此放置步驟中的狀態讀取作業可能會影響其他屬於測量步驟的重新啟動範圍。

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

階段 3:繪圖

繪圖程式碼執行期間的狀態讀取作業會影響繪圖階段。常見的範例包括 Canvas()Modifier.drawBehindModifier.drawWithContent。當狀態值變更時,Compose UI 只會執行繪圖階段。

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

最佳化狀態讀取作業

當 Compose 執行限於局部的狀態讀取追蹤作業時,我們可以讀取適當階段中的每個狀態,藉此盡可能降低執行工作量。

一起來看看這個範例。在本例中,Image() 會使用偏移修飾符來偏移最終的版面配置位置,以便在使用者捲動畫面時產生視差效果。

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState) {
        // ...
    }
}

這段程式碼可以運作,但會導致效能不佳。按照其編寫方式,程式碼會讀取 firstVisibleItemScrollOffset 狀態的值,並將該值傳送給 Modifier.offset(offset: Dp) 函式。當使用者捲動畫面時,firstVisibleItemScrollOffset 值會隨之改變。如我們所知,Compose 會追蹤所有讀取的狀態,以便重新啟動 (重新叫用) 讀取程式碼,在本例中為 Box 的內容。

這是在組合階段內讀取的狀態範例。這不一定是壞事,事實上這是重組的基礎,讓您透過資料變更來發出新的 UI。

本例所提供的並非最理想的做法,因為每個捲動事件都會導致系統重新評估整個可組合項的內容,然後再進行評估、版面配置,最後再繪圖。我們會在每次畫面捲動時觸發組合階段,即使顯示的內容沒有變更,而僅改變內容的顯示位置也一樣。不過,我們可以對狀態讀取作業進行最佳化,從而只重新觸發版面配置階段。

系統提供另一個版本的偏移修飾符:Modifier.offset(offset: Density.() -> IntOffset)

這個版本採用 lambda 參數,以此產生的偏移值會由 lambda 區塊回傳。讓我們更新程式碼來使用這個版本:

Box {
    val listState = rememberLazyListState()

    Image(
        // ...
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState) {
        // ...
    }
}

那麼為什麼這種做法效能較佳?系統會在版面配置階段 (特別是版面配置階段的放置步驟期間) 叫用我們提供給修飾符的 lambda 區塊,這表示系統不再在組合期間讀取 firstVisibleItemScrollOffset 狀態。由於 Compose 會追蹤狀態讀取時間,因此如果 firstVisibleItemScrollOffset 值發生變更,Compose 就只需要重新啟動版面配置和繪圖階段。

本範例仰賴不同的偏移修飾符來最佳化最終程式碼,但這個想法適用於一般情況:嘗試將狀態讀取作業盡可能侷限在最低階段,讓 Compose 能執行最少的工作量。

當然,在組合階段讀取狀態經常是絕對必要的。即便如此,我們仍在某些情況下可以篩選狀態變更,藉此將重組次數降到最低。如要進一步瞭解相關做法,請參閱 derivedStateOf:將一或多個狀態物件轉換成其他狀態一文。

重組迴圈 (循環階段依附元件)

我們先前曾提到,Compose 的階段一律會按照相同的順序叫用,而且無法在同一個畫面中返回前一階段。但這並非禁止應用程式在「不同」畫面中進入組合迴圈。請參考以下例子:

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

我們在此 (拙劣地) 實作了一個垂直欄,在頂端放置圖片,然後在圖片下方放置文字。我們使用 Modifier.onSizeChanged() 來取得圖片的解析尺寸,然後使用 Modifier.padding() 將文字向下移動。從 PxDp 的不自然轉換已指出程式碼有問題。

這個範例的問題是,無法在單一畫面內執行到「最終」版面配置。這段程式碼需要呈現多個畫面,而這些畫面會執行不必要的工作,導致 UI 在使用者的畫面上到處跳動。

讓我們逐步觀察每個畫面,看看發生了什麼情況:

在第一個畫面的組合階段,imageHeightPx 的值為 0,而提供給文字的函式則為 Modifier.padding(top = 0)。接著進入版面配置階段,並呼叫 onSizeChanged 修飾符的回呼。這時 imageHeightPx 會更新為圖片的實際高度。Compose 會安排下一個畫面的重組作業。在繪圖階段,由於邊框間距尚未反映值的變更,因此文字會以邊框間距為 0 的形式呈現。

接著,Compose 會依據 imageHeightPx 值的變更時間,安排啟動第二個畫面。系統會在 Box 內容區塊中讀取狀態,並在組合階段中叫用該狀態。這次會提供文字的邊框間距,以及與圖片高度相符的邊框間距。在版面配置階段,程式碼會再次設定 imageHeightPx 的值,但由於這個值維持不變,因此不會安排重組。

最後,我們取得文字所需的邊框間距,但使用另一個畫面將邊框間距值傳回至不同階段並非最佳做法,而且會產生含有重疊內容的畫面。

這個範例可能看起來很牽強,但請留意這個一般模式:

  • Modifier.onSizeChanged()onGloballyPositioned() 或其他一些版面配置作業
  • 更新某個狀態
  • 使用該狀態做為版面配置修飾符的輸入內容 (padding()height() 或類似項目)
  • 可能會重複執行

如要修正上述範例,只要使用正確的版面配置原始物件即可。使用簡單的 Column() 即可實作上述範例,但如果您有更複雜的範例需要自訂項目,就必須編寫自訂版面配置。詳情請參閱自訂版面配置指南。

這裡的一般原則是針對應相互測量和放置的多個 UI 元素提供單一可靠資料來源。使用正確的版面配置原始物件或建立自訂版面配置時,最低的共用父項可做為可靠資料來源,以協調多個元素之間的關係。導入動態狀態會打破這項原則。