在 Compose 中思考

Jetpack Compose 是適用於 Android 的新型宣告型 UI 工具包。Compose 提供宣告式 API,這個 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 的函式不需要傳回任何值,因為那些函式會描述所需的螢幕狀態,而不是建構 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 的值。使用 Text 函式再次呼叫 lambda 以顯示新值;此程序稱為重新組合。不依賴其他值的函式則不需要重新組合。

如先前所述,重新組合整個 UI 樹狀結構時可能產生高額費用,也就是使用了運算效能和電池壽命。Compose 利用這個智慧重新組合來解決這個問題。

重新組合是指當輸入變更時,再次呼叫可組合函式的過程。當函式的輸入內容有所變更時,就會發生這種情形。當 Compose 根據新的輸入內容進行重新組合時,只會呼叫可能變更的函式或 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。
  • 重新組合已最佳化,可能會取消。
  • 可組合函式可能以高頻率執行,通常是動畫各個影格的頻率。

以下各節將介紹如何建構可組合函式以支援重新組合。在任何情況下,最佳做法是盡量讓可組合函式快速、冪等且無副作用。

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

查看可組合函式的程式碼時,您可能會假設程式碼會以顯示的順序執行。但事實可能不然。可組合函式包含對其他可組合函式的呼叫,這些函式可能以任何順序執行。Compose 可以選擇辨識某些 UI 元素的優先順序高於其他 UI 元素,並優先繪製。

舉例來說,假設您有像這樣的程式碼可在分頁版面配置中繪製三個螢幕:

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

StartScreenMiddleScreenEndScreen 的呼叫能以任何順序進行。舉例來說,您無法讓 StartScreen() 設定部分全域變數(副作用),以及讓 MiddleScreen() 充分運用這項變更。每個函式都必須獨立包含。

可組合函式可以平行執行

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
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

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

在這個範例中,items 會隨著每次重做修改。例如動畫的所有頁框或清單更新時。無論是哪一種方式,UI 都會顯示錯誤數量。因此,Compose 不支援這類寫入作業;透過禁止寫入,我們可以允許架構變更執行緒,以執行可組合 lambda。

重新組合時盡可能略過

當 UI 中的部分無效時,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.h5)
        Divider()

        // 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。