Compose 的程式設計概念

Jetpack Compose 是適用於 Android 的新型宣告式 UI 工具包。Compose 提供「宣告式 API」,允許算繪應用程式 UI,而不需要強制變更前端檢視區塊,讓您輕鬆撰寫及維護應用程式 UI。這個術語需要一些說明,但這些概念對應用程式設計相當重要。

宣告式程式設計範例

過去,Android 檢視區塊階層能夠以 UI 小工具的樹狀結構表示。由於應用程式的狀態會依使用者互動等因素而異,因此 UI 階層必須更新以顯示目前的資料。最常見的 UI 更新方式是使用 findViewById() 等函式來巡察樹狀結構,以及呼叫 button.setText(String)container.addChild(View)img.setImageBitmap(Bitmap) 等方法來變更節點。這些方法會變更小工具的內部狀態。

手動操控檢視區塊會提高錯誤發生率。如果在多個位置算繪資料,很容易忘記更新其中一個顯示該資料的檢視區塊。如有兩項更新意外發生衝突,也很容易造成非法狀態。舉例來說,某項更新可能會嘗試為剛從 UI 中移除的節點設定一個值。一般來說,軟體維護複雜程度會隨著須更新的檢視區塊數量增加。

過去幾年來,整個產業紛紛開始改用宣告式 UI 模型,大幅簡化了建構和更新使用者介面的相關工程。這項技術會從概念上重新產生整個螢幕畫面,然後只套用必要的變更。這種做法可避免手動更新有狀態的檢視區塊階層時的繁複程序。Compose 是一種宣告式 UI 架構。

重新產生整個畫面的一大挑戰,在於這項作業可能會耗費大量的時間、運算能力和電池用量。為減少這類成本,Compose 會聰明地選擇在任一指定時間需要重新繪製的 UI 部分。這對 UI 元件的設計方式有一些潛在影響,如重組程序一節中所述。

簡易的可組合函式

您可以透過 Compose 建構使用者介面,具體做法是定義一組可組合函式,負責接收資料及輸出 UI 元素。一個簡易的範例為 Greeting 小工具,在接收 String 後會輸出 Text 小工具,以顯示問候訊息。

顯示「Hello World」文字的手機螢幕截圖,以及可產生該 UI 的簡單可組合函式程式碼

圖 1. 一個簡易的可組合函式,可使用所接收到的資料在螢幕畫面上算繪文字小工具。

這個函式的相關注意事項:

  • 這個函式會加上 @Composable 註解。所有可組合函式都必須包含這個註解,以便向 Compose 編譯器指明,這個函式的作用是將資料轉換為 UI。

  • 這個函式會接收資料。可組合函式可接受參數,以利應用程式邏輯描述 UI。在這個範例中,我們的小工具接受 String,進而能夠以姓名問候使用者。

  • 這個函式會在 UI 中顯示文字。具體做法是呼叫 Text() 可組合函式,透過該函式實際建立文字 UI 元素。可組合函式會藉由呼叫其他可組合函式來輸出 UI 階層結構。

  • 這個函式不會傳回任何內容。輸出 UI 的 Compose 函式不必傳回任何內容,原因是這類函式的作用是描述所需螢幕畫面狀態,而不是建構 UI 小工具。

  • 這個函式具有快速、冪等的特性,而且沒有任何「附帶影響」

    • 如果以相同引數多次呼叫這個函式,函式的運作方式將維持不變,也不會使用其他的值,例如全域變數或 random() 呼叫。
    • 這個函式可描述 UI,而沒有任何附帶影響,例如修改屬性或全域變數。

    一般來說,所有可組合函式都應使用這些屬性編寫,原因請見重組程序一節。

宣告式範例轉移

擁有許多命令式物件導向的 UI 工具包時,您可以執行個體化小工具的樹狀結構,藉此初始化 UI。具體做法通常是加載 XML 版面配置檔案。每個小工具會維持其內部狀態,並顯示可讓應用程式邏輯與小工具互動的 getter 和 setter 方法。

在 Compose 的宣告式方法中,小工具相對來說屬於無狀態,也不會顯示 setter 或 getter 函式。實際上,小工具不會顯示為物件。如要更新 UI,必須使用不同引數呼叫同一個可組合函式。這麼做可讓您輕鬆提供架構模式 (例如 ViewModel) 的狀態,詳情請參閱應用程式架構指南。接著,每次可觀測資料更新時,可組合元件就必須負責將目前的應用程式狀態轉換為 UI。

示意圖:Compose UI 中的資料從高階層物件流向其子項。

圖 2. 應用程式邏輯提供資料給頂層的可組合函式。該函式會運用這些資料並呼叫其他可組合元件來描述 UI,同時會將適當的資料傳遞給這些可組合元件和下層元素。

當使用者與 UI 互動時,UI 會發起事件,例如 onClick。這些事件應通知應用程式邏輯,進而變更應用程式的狀態。當狀態變更時,系統會使用新資料再次呼叫可組合函式。這樣會重新繪製 UI 元素,而這項程序稱作「重組」

示意圖:UI 元素在回應互動時觸發事件,並由應用程式邏輯處理。

圖 3. 使用者與 UI 元素互動,導致事件觸發。應用程式邏輯回應事件,然後視需要使用新參數自動呼叫可組合函式。

動態內容

由於可組合函式是以 Kotlin (而非 XML) 編寫,因此其動態程度就跟任何其他 Kotlin 程式碼一樣。舉例來說,假設您要建構一個 UI 來向清單中的使用者顯示問候訊息:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

這個函式會接收使用者姓名清單,並針對每位使用者產生問候訊息。可組合函式可能相當複雜。您可以使用 if 陳述式決定是否要顯示特定 UI 元素,您可以使用迴圈。您可以呼叫輔助函式。您享有基礎語言的完整彈性。這種效能和彈性是 Jetpack Compose 的主要優勢之一。

重組程序

在命令式 UI 模型中,如要變更小工具,請呼叫小工具的 setter 來變更其內部狀態。在 Compose 中,請使用新資料再次呼叫可組合函式。這樣一來,函式就會「重組」,表示函式輸出的小工具在必要時會使用新資料重新繪製。Compose 架構能夠聰明地只選出有變動的元件進行重組。

以這個會顯示按鈕的可組合函式為例:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

每次按鈕獲得點擊時,呼叫端都會更新 clicks 的值。Compose 會使用 Text 函式再次呼叫 lambda 來顯示新的值;這項程序稱作「重組」。其他未依附於該值的函式則不會進行重組。

如先前所述,重組整個 UI 樹狀結構需耗用運算能力和電池壽命,運算成本可能相當高昂。針對這個問題,Compose 的解決方法就是這種「智慧重組」

重組是在輸入內容變更時,再次呼叫可組合函式的程序。當函式的輸入內容有變動時,就會發生這種情況。當 Compose 根據新的輸入內容進行重組時,只會呼叫可能有變動的函式或 lambda,並略過其餘函式或 lambda。因為略過所有參數未變更的函式或 lambda,所以 Compose 能夠有效率地重組。

切勿依附於任何在執行可組合函式時產生的附帶影響,原因是函式的重組程序有可能遭到略過。如果您依附於附帶影響,使用者在應用程式中可能會遇到異常且不可預測的行為。附帶影響是指任何在應用程式其餘部分顯示的變更。舉例來說,下列操作皆屬於危險的附帶影響:

  • 寫入共用物件的屬性
  • 更新 ViewModel 中的可觀測元件
  • 更新共用偏好設定

可組合函式可能會以最高每個影格一次的頻率重新執行 (以算繪動畫的情況為例)。可組合函式應快速執行,以避免動畫期間的資源浪費。如需進行成本高昂的操作 (例如讀取共用偏好設定),請在背景協同程式中進行,並將結果值以參數形式傳遞給可組合函式。

舉例來說,下方程式碼會建立一個可組合元件,用於更新 SharedPreferences 中的值。可組合元件不應讀取或寫入共用偏好設定本身。因此,這個程式碼會改為將讀取和寫入操作移至背景協同程式中的 ViewModel。應用程式邏輯會傳遞目前的值,並使用回呼來觸發更新。

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

本文探討了使用 Compose 時的幾點注意事項:

  • 重組程序會盡可能略過可組合函式和 lambda。
  • 重組程序具有樂觀的完成率,也可能會遭到取消。
  • 可組合函式可能會以高頻率執行,最高可於動畫的每個影格皆執行一次。
  • 可組合函式可以平行執行。
  • 可組合函式能以任何順序執行。

以下各節將說明如何建構可組合函式來支援重組。在所有情況下,最佳做法都是確保可組合函式符合快速、冪等的條件,且沒有任何附帶影響。

重組程序會盡可能選擇略過

當部分 UI 無效時,Compose 會盡可能只重組需要更新的部分。這意味著 Compose 可能會不斷略過以重新執行單一按鈕的可組合元件,而不執行該元件在 UI 樹狀結構中的任何上層或下層可組合元件。

每個可組合函式和 lambda 都可能會自行重組。以下範例說明重組程序在算繪清單時如何略過部分元素:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        HorizontalDivider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

上述各個範圍可能是重組期間唯一可執行的項目。當 header 變更時,Compose 可能會略過 Column lambda,而不會執行其任何父項。執行 Column 時,如果 names 沒有變更,Compose 可能會選擇略過 LazyColumn 的項目。

同樣地,執行所有可組合函式或 lambda 都不應該有任何附帶影響。當您需要執行附帶影響時,請透過回呼觸發。

重組程序具有樂觀的完成率

只要 Compose 認為可組合元件的參數可能有變動,即會啟動重組程序。重組程序具有「樂觀」的完成率,這表示 Compose 預期可在參數再次變更前完成重組。如果參數在重組完成前就「有變動」,Compose 可能會取消這個重組程序,並使用新的參數重新啟動重組程序。

重組程序取消後,Compose 會捨棄重組程序中的 UI 樹狀結構。如果您有任何附帶影響依附於目前顯示的 UI,即使組合取消,系統仍會套用相關附帶影響。這可能會導致應用程式狀態不一致。

請確認所有可組合函式和 lambda 皆符合冪等、無附帶影響的條件,以處理具有樂觀完成率的重組程序。

可組合函式可能會頻繁執行

在某些情況下,可組合函式可能會於 UI 動畫的每個影格都執行一次。如果函式執行成本高昂的操作 (例如讀取裝置儲存空間內容),可能會造成 UI 資源浪費。

舉例來說,如果小工具嘗試讀取裝置設定,每秒可能會讀取這些設定數百次,進而對應用程式效能產生嚴重影響。

如果您的可組合函式需要資料,應定義用於接收資料的參數。接著,您可以將成本高昂的工作移至組合外的其他執行緒,再使用 mutableStateOfLiveData 將資料傳遞給 Compose。

可組合函式可平行執行

Compose 可以同時執行可組合函式,將重組程序最佳化。這樣一來,Compose 就能運用多個核心,並以較低優先順序執行不在螢幕畫面中的可組合函式。

這項最佳化設定意味著可組合函式可能會在背景執行緒集區中執行。如果可組合函式在 ViewModel 中呼叫某個函式,Compose 可能會同時從多個執行緒呼叫該函式。

為了確保應用程式正確運作,所有可組合函式都不得有附帶影響。如需觸發附帶影響,請改用回呼,例如一律在 UI 執行緒中執行的 onClick

可組合函式獲得叫用時,叫用可能是發生在與呼叫端不同的執行緒中。因此,應避免使用對可組合 lambda 所含變數做出修改的程式碼,原因在於這類程式碼無法確保執行緒安全,而且相關修改是可組合 lambda 不容許的附帶影響。

下方範例中的可組合元件會顯示清單及其計數:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

這個程式碼沒有任何附帶影響,並會將輸入清單轉換為 UI,這是顯示小型清單的絕佳程式碼。不過,如果函式會寫入本機變數,這個程式碼就無法確保執行緒安全,或是不正確:

@Composable
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Card {
                    Text("Item: $item")
                    items++ // Avoid! Side-effect of the column recomposing.
                }
            }
        }
        Text("Count: $items")
    }
}

在這個範例中,items 會隨著每次重組進行修改。修改頻率可能是以動畫的每個影格或清單更新為準。無論如何,UI 都會顯示錯誤的計數。因此,Compose 不支援這類寫入;藉由禁止這類寫入,我們得以讓架構變更執行緒來執行可組合 lambda。

可組合函式能以任何順序執行

光看可組合函式的程式碼,您可能會認為程式碼是以顯示順序執行,但這不保證一定是真的。如果可組合函式包含對其他可組合函式的呼叫,那些函式可能會以任何順序執行。Compose 可選擇認定部分 UI 元素的優先順序高於其他元素,並優先繪製這些元素。

舉例來說,假設您有類似下方的程式碼,用於在分頁版面配置中繪製三個螢幕畫面:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

StartScreenMiddleScreenEndScreen 的呼叫能以任何順序執行。這表示您將受到一些限制,例如無法讓 StartScreen() 設定某些全域變數 (附帶影響),也無法讓 MiddleScreen() 運用該變更。反之,每個函式都必須保持獨立。

瞭解詳情

如要進一步瞭解如何在 Compose 和可組合函式中思考,請參閱下列其他資源。

影片