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、LazyColumn 和 LazyRow 是明顯的例外狀況,因為其子項的組合依附於父項的版面配置階段。

從概念上來說,每個影格都會經歷這些階段;不過,為了提升效能,Compose 會避免在所有階段中,利用相同輸入內容計算出相同結果的重複作業。如果 Compose 可以重複利用先前計算的結果,就會略過可組合函式的執行程序,而且在非必要的情況下,Compose UI 不會重新進行版面配置或者重新繪製整個樹狀結構。Compose 只會執行更新 UI 所需的最低工作量。Compose 會在不同階段內追蹤讀取的狀態,據以執行這項最佳化作業。

瞭解各階段

本節將更詳細地說明可組合函式如何執行三個 Compose 階段。

樂曲

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

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

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

程式碼片段包含五個可組合函式,以及產生的 UI 樹狀結構,其中子節點會從父節點分支。
圖 3. 使用者介面樹狀結構的子區段,以及對應的程式碼。

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

版面配置

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

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

在版面配置階段,系統會使用下列三步驟演算法遍歷樹狀結構:

  1. 測量子項:節點會測量子項 (如有)。
  2. 決定自身大小:節點會根據這些測量結果決定自身大小。
  3. 放置子項:每個子節點都會放置在相對於節點本身位置的位置。

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

  • 已指派的寬度和高度
  • 應繪製的 x、y 座標

回想一下上一節的 UI 樹狀結構:

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

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

  1. Row 會測量子項 Image 和 Column。
  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 樹狀結構及其繪製的表示法。

狀態讀取

當您在先前列出的其中一個階段讀取 snapshot state 的 value 時,Compose 會自動追蹤讀取 value 時執行的動作。這項追蹤功能可讓 Compose 在狀態的 value 變更時重新執行讀取器,也是 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 會持續追蹤不同階段中的狀態 value 變更和重新啟動範圍。

階段性狀態讀取

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

以下各節將說明各個階段,以及在各階段中讀取狀態值時會發生的情況。

階段 1:組合

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

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 區塊,以及類似函式。

每個步驟的狀態讀取作業都會影響版面配置,也可能會影響繪圖階段。當狀態的 value 變更時,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.drawBehind 和 Modifier.drawWithContent。當狀態的 value 變更時,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 狀態的 value,並將該值傳送給 Modifier.offset(offset: Dp) 函式。當使用者捲動畫面時,firstVisibleItemScrollOffset 的 value 會隨之改變。如您所知,Compose 會追蹤所有讀取的狀態,以便重新啟動 (重新叫用) 讀取程式碼,在本例中為 Box 的內容。

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

重點:這個範例並非最佳做法,因為每個捲動事件都會導致系統重新評估整個可組合項的內容,然後再進行評估、版面配置,最後再繪圖。即使顯示的內容沒有變更,只有位置變更,您仍會在每次捲動時觸發組合階段。您可以對狀態讀取作業進行最佳化,從而只重新觸發版面配置階段。

使用 lambda 偏移

系統提供另一個版本的偏移修飾符: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 的 value 發生變更時,Compose 就只需要重新啟動版面配置和繪圖階段。

當然,在組合階段讀取狀態經常是絕對必要的。即使如此,您還是可以透過篩選狀態變更,盡可能減少重組次數。如要進一步瞭解相關做法,請參閱「derivedStateOf:將一或多個狀態物件轉換成其他狀態」一文。

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

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

Box {
    var imageHeightPx by remember { mutableIntStateOf(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() 將文字向下移動。從 Px 到 Dp 的不自然轉換已指出程式碼有問題。

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

第一個影格的構圖

在第一個畫面的組合階段,imageHeightPx 最初為 0。因此,程式碼會提供含有 Modifier.padding(top = 0) 的文字。後續的版面配置階段會叫用 onSizeChanged 修飾符的回呼,將 imageHeightPx 更新為圖片的實際高度。Compose 會安排下一個畫面的重新組合作業。不過,在目前的繪圖階段,文字會以 0 的邊框間距呈現,因為更新後的 imageHeightPx 值尚未反映。

第二個影格構圖

Compose 會依據 imageHeightPx 值的變更時間,啟動第二個畫面。在這個影格的組合階段中,系統會在 Box 內容區塊中讀取狀態。現在提供給文字的邊框間距與圖片高度完全相符。在版面配置階段,imageHeightPx 會再次設定,但由於值維持一致,因此不會安排進一步重新組合。

圖表:重組迴圈。版面配置階段的大小變更會觸發重組,進而導致版面配置再次發生。

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

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

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

本文所述的一般原則是,將單一可靠資料來源用於多個 UI 元素,且這些元素會依據測量結果和彼此的關係妥善放置。使用正確的版面配置原始物件或建立自訂版面配置時,最低層的共用父項可做為可靠資料來源,以協調多個元素之間的關係。導入動態狀態會打破這項原則。

如要進一步瞭解重新組合迴圈,以及如何避免在各階段寫入狀態,請參閱「Compose 中的反向寫入」。