Compose 中的狀態生命週期

在 Jetpack Compose 中,可組合函式通常會使用 remember 函式保留狀態。如「狀態與 Jetpack Compose」一文所述,系統會記下值,以便在重新組合時重複使用。

雖然 remember 可做為在重組期間保留值的工具,但狀態通常需要超出組合的生命週期。本頁說明 rememberretainrememberSaveablerememberSerializable API 之間的差異、何時該選擇哪個 API,以及在 Compose 中管理記憶和保留值的最佳做法。

選擇正確的生命週期

在 Compose 中,您可以使用多種函式在組合之間及其他位置保存狀態,包括 rememberretainrememberSaveablerememberSerializable。這些函式的生命週期和語意不同,適合儲存特定類型的狀態。下表概述兩者的差異:

remember

retain

rememberSaveablerememberSerializable

值在重組後是否仍有效?

值是否會在活動重建後保留?

一律會傳回相同的 (===) 例項

系統會傳回對等 (==) 物件,可能是還原序列化的副本

值在程序終止後仍會存在嗎?

支援的資料類型

全部

不得參照任何在活動遭到毀損時會洩漏的物件

必須可序列化
(可使用自訂 Saverkotlinx.serialization)

用途

  • 範圍限定於合成的物件
  • 可組合函式的設定物件
  • 可重新建立且不會影響 UI 保真度的狀態
  • 快取
  • 存留時間較長或「管理員」物件
  • 使用者輸入內容
  • 應用程式無法重新建立的狀態,包括文字欄位輸入內容、捲動狀態、切換按鈕等。

remember

remember 是在 Compose 中儲存狀態最常見的方式。首次呼叫 remember 時,系統會執行指定的計算並記住結果,也就是說,Compose 會儲存結果,供可組合函式日後重複使用。可組合函式重組時,會再次執行程式碼,但對 remember 的任何呼叫都會從先前的組合傳回值,而不是再次執行計算。

可組合函式的每個例項都有一組專屬的記憶值,稱為「位置記憶化」。系統會將記憶值記憶體化,以便在重新組合時使用,這些值會與組合階層中的位置繫結。如果可組合項用於不同位置,組合階層中的每個例項都會有自己的記憶值集。

如果不再使用記憶的值,系統會「忘記」該值並捨棄記錄。從組合階層中移除值時,系統會忘記這些值 (包括移除值並重新加入,以便移至其他位置,但未使用 key 可組合函式或 MovableContent 時),或使用不同的 key 參數呼叫值時,系統也會忘記這些值。

在可用選項中,remember 的生命週期最短,且會最早忘記值,是本頁面所述四個記憶化函式中最早忘記值的函式。因此最適合用於:

  • 建立內部狀態物件,例如捲動位置或動畫狀態
  • 避免在每次重組時重新建立昂貴的物件

不過,請避免下列情況:

  • 使用 remember 儲存任何使用者輸入內容,因為系統會在 Activity 設定變更和系統啟動的程序終止時,忘記記憶的物件。

rememberSaveablerememberSerializable

rememberSaveablerememberSerializable 是以 remember 為基礎建構而成,在指南中討論的記憶化函式中,這類函式的生命週期最長。除了在重新組成時,依位置記憶物件之外,它還能儲存值,以便在活動重新建立時還原這些值,包括因設定變更和程序終止而重新建立活動時 (當應用程式在背景執行時,系統通常會終止應用程式的程序,以便為前景應用程式釋出記憶體,或使用者在應用程式執行時撤銷權限)。

rememberSerializable 的運作方式與 rememberSaveable 相同,但會自動支援使用 kotlinx.serialization 程式庫序列化的複雜型別。如果類型標示 (或可標示) 為 @Serializable,請選擇 rememberSerializable;在所有其他情況下,請選擇 rememberSaveable

因此,rememberSaveablerememberSerializable 非常適合儲存與使用者輸入內容相關的狀態,包括文字欄位輸入內容、捲動位置、切換狀態等。您應儲存這些狀態,確保使用者不會遺失進度。一般來說,您應該使用 rememberSaveablerememberSerializable,將應用程式無法從其他永久性資料來源 (例如資料庫) 擷取的任何狀態記憶體化。

請注意,rememberSaveablerememberSerializable 會將記憶體中的值序列化為 Bundle,這會造成兩種影響:

  • 您記憶的值必須可由一或多個下列資料型別表示:原始型別 (包括 IntLongFloatDouble)、String,或上述任一型別的陣列。
  • 還原儲存的值時,系統會建立一個與 (==) 相等的新執行個體,但不會是組成先前使用的相同參照 (===)。

如要儲存更複雜的資料類型,但不想使用 kotlinx.serialization,可以實作自訂 Saver,將物件序列化及還原序列化為支援的資料類型。請注意,Compose 可立即瞭解 StateListMapSet 等常見資料類型,並自動將這些類型轉換為支援的類型。以下是 Size 類別的 Saver 範例。實作方式是使用 listSaver 將所有 Size 的屬性封裝到清單中。

data class Size(val x: Int, val y: Int) {
    object Saver : androidx.compose.runtime.saveable.Saver<Size, Any> by listSaver(
        save = { listOf(it.x, it.y) },
        restore = { Size(it[0], it[1]) }
    )
}

@Composable
fun rememberSize(x: Int, y: Int) {
    rememberSaveable(x, y, saver = Size.Saver) {
        Size(x, y)
    }
}

retain

就記憶體儲存值的時間長度而言,retain API 介於 rememberrememberSaveable/rememberSerializable 之間。由於保留的值與記憶的值生命週期不同,因此名稱也不同。

如果值遭到保留,系統會將其位置記憶體化,並儲存在次要資料結構中,該結構具有與應用程式生命週期相關聯的獨立生命週期。保留的值可在設定變更期間繼續存在,不必序列化,但無法在程序終止期間保留。如果系統在重新建立組合階層後未使用某個值,保留的值就會停用 (相當於 retain 忘記該值)。

為了換取較短的生命週期 (短於 rememberSaveable),保留功能可以保存無法序列化的值,例如 lambda 運算式、流程和大型物件 (如點陣圖)。舉例來說,您可以使用 retain 管理媒體播放器 (例如 ExoPlayer),避免在設定變更期間中斷媒體播放。

@Composable
fun MediaPlayer() {
    // Use the application context to avoid a memory leak
    val applicationContext = LocalContext.current.applicationContext
    val exoPlayer = retain { ExoPlayer.Builder(applicationContext).apply { /* ... */ }.build() }
    // ...
}

retainViewModel

就最常用的功能而言,retainViewModel 的核心功能相似,都能在設定變更時保留物件例項。選擇使用 retainViewModel 取決於您要保存的值類型、範圍,以及是否需要額外功能。

ViewModel 是物件,通常會封裝應用程式 UI 和資料層之間的通訊。這類函式可讓您將邏輯移出可組合函式,進而提升可測試性。ViewModel 會在 ViewModelStore 中以單例模式管理,且生命週期與保留值不同。ViewModel 會保持有效,直到 ViewModelStore 遭到刪除為止,但當內容從組合中永久移除時,保留的值就會停用 (舉例來說,如果是設定變更,這表示如果系統重新建立 UI 階層,且保留的值在重新建立組合後未耗用,保留的值就會停用)。

ViewModel 也包含 Dagger 和 Hilt 的依附元件插入功能整合、與 SavedState 的整合,以及啟動背景工作的內建協同程式支援。因此,ViewModel 是啟動背景工作和網路要求、與專案中的其他資料來源互動,以及選擇性擷取並保存重要 UI 狀態的理想位置,這些狀態應在 ViewModel 的設定變更中保留,並在程序終止後繼續存在。

retain 最適合用於特定可組合項例項範圍內的物件,且不需要在同層級可組合項之間重複使用或共用。ViewModel 適合用來儲存 UI 狀態及執行背景工作,而 retain 則適合用來儲存 UI 管道的物件,例如快取、曝光追蹤和分析、AndroidView 的依附元件,以及與 Android OS 互動或管理第三方程式庫 (例如付款處理器或廣告) 的其他物件。

進階使用者可根據 Modern Android 應用程式架構建議,設計自訂應用程式架構模式:retain 也可用於建構類似「ViewModel」的內部 API。雖然系統不會直接支援協同程式和已儲存狀態,但 retain 可做為這類 ViewModel 類似項目的生命週期建構區塊,並在頂端建構這些功能。本指南不包含如何設計這類元件的詳細說明。

retain

ViewModel

範圍

沒有共用值,每個值都會保留在組合階層中的特定點,並與該點建立關聯。在不同位置保留相同型別時,一律會對新例項執行動作。

ViewModelViewModelStore 中的單例項

破壞

永久離開組合階層時

ViewModelStore遭到清除或刪除時

其他功能

物件是否位於組合階層中,都可以接收回呼

內建 coroutineScope,支援 SavedStateHandle,可使用 Hilt 插入

擁有者

RetainedValuesStore

ViewModelStore

用途

  • 保留個別可組合函式執行個體本機的 UI 特定值
  • 曝光追蹤 (可能透過 RetainedEffect)
  • 用於定義自訂「ViewModel 類似」架構的構成元素
  • 將 UI 層和資料層之間的互動擷取到獨立類別中,方便整理程式碼和進行測試
  • Flow 轉換為 State 物件,並呼叫不應因設定變更而中斷的暫停函式
  • 透過大型 UI 區域 (例如整個畫面) 分享狀態
  • View 的互通性

結合 retainrememberSaveablerememberSerializable

有時,物件需要同時具有 retainedrememberSaveablerememberSerializable 的混合生命週期。這可能表示您的物件應為 ViewModel,可支援已儲存狀態,如 ViewModel 的已儲存狀態模組指南所述。

可以同時使用 retainrememberSaveablerememberSerializable。正確合併這兩個生命週期會大幅增加複雜度。建議您採用這個模式做為更進階和自訂架構模式的一部分,且僅在符合下列所有條件時使用:

  • 您要定義的物件是由必須保留或儲存的值組成 (例如追蹤使用者輸入內容的物件,以及無法寫入磁碟的記憶體內快取)
  • 您的狀態範圍限定於可組合項,不適合單例項範圍或 ViewModel 的生命週期

如果符合上述所有情況,建議將類別分成三部分:已儲存的資料、保留的資料,以及沒有自身狀態的「中介」物件,並委派給保留和儲存的物件,據此更新狀態。這個模式的形狀如下:

@Composable
fun rememberAndRetain(): CombinedRememberRetained {
    val saveData = rememberSerializable(serializer = serializer<ExtractedSaveData>()) {
        ExtractedSaveData()
    }
    val retainData = retain { ExtractedRetainData() }
    return remember(saveData, retainData) {
        CombinedRememberRetained(saveData, retainData)
    }
}

@Serializable
data class ExtractedSaveData(
    // All values that should persist process death should be managed by this class.
    var savedData: AnotherSerializableType = defaultValue()
)

class ExtractedRetainData {
    // All values that should be retained should appear in this class.
    // It's possible to manage a CoroutineScope using RetainObserver.
    // See the full sample for details.
    var retainedData = Any()
}

class CombinedRememberRetained(
    private val saveData: ExtractedSaveData,
    private val retainData: ExtractedRetainData,
) {
    fun doAction() {
        // Manipulate the retained and saved state as needed.
    }
}

將狀態依生命週期劃分後,職責和儲存空間的劃分就會非常明確。有意讓保留資料無法操控儲存資料,因為這樣可避免在 savedInstanceState 組合包已擷取且無法更新時,嘗試更新儲存資料。您也可以測試建構函式,而不需呼叫 Compose 或模擬 Activity 重建,藉此測試重建情境。

如需這個模式的完整實作範例,請參閱完整範例 (RetainAndSaveSample.kt)。

位置記憶和自動調整式版面配置

Android 應用程式可支援多種板型規格,包括手機、摺疊式裝置、平板電腦和桌上型電腦。應用程式經常需要使用自適應版面配置,在這些外型規格之間轉換。舉例來說,在平板電腦上執行的應用程式可能會顯示雙欄清單詳細資料檢視畫面,但如果顯示在較小的手機螢幕上,則可能會在清單和詳細資料頁面之間導覽。

由於系統會記憶並保留位置值,因此只有在組成階層的相同位置顯示這些值時,才會重複使用。版面配置會配合不同外型規格調整,因此可能會改變組合階層的結構,導致值遭到遺忘。

如果是 ListDetailPaneScaffoldNavDisplay 等現成可用的元件 (來自 Jetpack Navigation 3),這不會是問題,而且狀態會在版面配置變更期間持續存在。如果是會配合外型規格調整的自訂元件,請採取下列其中一種做法,確保狀態不受版面配置變更影響:

  • 請確保有狀態的可組合函式一律在組合階層中的相同位置呼叫。實作自動調整式版面配置時,請變更版面配置邏輯,而不是在組合階層中重新放置物件。
  • 使用 MovableContent 妥善重新放置具狀態的可組合函式。MovableContent 的執行個體可以將記憶和保留的值從舊位置移至新位置。

請記住原廠函式

雖然 Compose UI 是由可組合函式組成,但許多物件都會參與組合的建立和組織。最常見的例子是定義自身狀態的複雜可組合物件,例如 LazyList,這類物件會接受 LazyListState

定義以 Compose 為主的物件時,建議您建立 remember 函式,定義預期的記憶行為,包括生命週期和鍵盤輸入內容。這樣一來,狀態的消費者就能在組合階層中放心地建立例項,這些例項會存留並如預期般失效。定義可組合工廠函式時,請遵守下列規範:

  • 在函式名稱前加上 remember。視需要,如果函式實作方式取決於物件是否為 retained,且 API 絕不會演進為依據 remember 的不同變體,請改用 retain 前置字元。
  • 如果選擇狀態持續性,且可以編寫正確的 Saver 實作項目,請使用 rememberSaveablerememberSerializable
  • 請避免根據可能與用途無關的 CompositionLocal 初始化值或產生副作用。請注意,建立狀態的位置可能與使用狀態的位置不同。

@Composable
fun rememberImageState(
    imageUri: String,
    initialZoom: Float = 1f,
    initialPanX: Int = 0,
    initialPanY: Int = 0
): ImageState {
    return rememberSaveable(imageUri, saver = ImageState.Saver) {
        ImageState(
            imageUri, initialZoom, initialPanX, initialPanY
        )
    }
}

data class ImageState(
    val imageUri: String,
    val zoom: Float,
    val panX: Int,
    val panY: Int
) {
    object Saver : androidx.compose.runtime.saveable.Saver<ImageState, Any> by listSaver(
        save = { listOf(it.imageUri, it.zoom, it.panX, it.panY) },
        restore = { ImageState(it[0] as String, it[1] as Float, it[2] as Int, it[3] as Int) }
    )
}