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. 放置子項:每個子項節點都會相對於節點本身的位置放置。

這個階段結束時,每個版面配置節點都會包含:

  • 已指派的寬度高度
  • 應繪製的 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 元素,且這些元素會依據測量結果和彼此的關係妥善放置。使用正確的版面配置原始物件或建立自訂版面配置時,最低層的共用父項可做為可靠資料來源,以協調多個元素之間的關係。導入動態狀態會打破這項原則。