Compose 中的狀態生命週期

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

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

選擇正確的生命週期

在 Compose 中,您可以使用多個函式,在組合結構和其他位置保留狀態:remember、retain、rememberSaveable 和 rememberSerializable。這些函式的生命週期和語意各不相同,分別適合儲存特定類型的狀態。下表概述兩者的差異:

remember

retain

rememberSaveable、rememberSerializable

值在重組後是否仍有效?

✅

✅

✅

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

❌

✅

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

✅

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

值在程序終止後是否仍存在?

❌

❌

✅

支援的資料類型

全部

不得參照任何在 Activity 刪除時會外洩的物件

必須可序列化
(使用自訂 Saver 或 kotlinx.serialization)

用途

  • 範圍限定為組合結構的物件
  • 可組合項的設定物件
  • 可重新建立,且不會降低 UI 擬真度的狀態
  • 快取
  • 長期存在或「管理員」物件
  • 使用者輸入內容
  • 應用程式無法重新建立的狀態,包括文字欄位輸入、捲動狀態、切換鈕等。

remember

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

可組合函式的每個執行個體都有一組記住的值,稱為「位置記憶」。當系統記住的值經過記憶,以便在重新組成時使用,這些值會與其在組成階層中的位置綁定。如果在不同位置使用可組合項,組合階層中的每個例項都會有一組專屬的記憶值。

當系統不再使用記住的值時,就會忘記該值,並捨棄其記錄。從組合階層中移除記住的值時,系統會忘記這些值 (包括在不使用 key 可組合項或 MovableContent 的情況下,移除並重新加入值以移至其他位置),或使用不同的 key 參數呼叫。

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

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

不過,請避免下列做法:

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

rememberSaveable和rememberSerializable

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

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

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

請注意,rememberSaveable 和 rememberSerializable 會將記憶值序列化為 Bundle,藉此儲存這些值。這會造成兩種影響:

  • 您記憶的值必須能以一或多種下列資料類型表示:原始類型 (包括 Int、Long、Float、Double)、String,或任何這些類型的陣列。
  • 還原儲存的值時,該值會是與 (==) 相等的新例項,但與組合先前使用的參照 (===) 不同。

如要在不使用 kotlinx.serialization 的情況下儲存更複雜的資料類型,您可以實作自訂 Saver,將物件序列化及反序列化為支援的資料類型。請注意,Compose 可直接理解 State、List、Map、Set 等常見資料類型,並自動將這些類型轉換為支援的類型。以下是 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 介於 remember 和 rememberSaveable/rememberSerializable 之間,取決於其值記憶的時間長度。之所以命名不同,是因為保留值的生命週期也與記住的值不同。

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

為了換取比 rememberSaveable 更短的生命週期,retain 能夠保留無法序列化的值,例如 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() }
    // ...
}

retain對ViewModel

基本上,retain 和 ViewModel 的功能類似,最常用於在設定變更時保留物件例項。選擇 retain 或 ViewModel 取決於您要保留的值類型、範圍設定方式,以及是否需要額外功能。

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

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

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

進階使用者如要設計自訂應用程式架構模式,而非採用新式 Android 應用程式架構建議,也可以使用 retain 建構內部「ViewModel 類似」API。雖然不支援協同程式和儲存狀態,但 retain 可做為這類 ViewModel 類似項目的生命週期建構區塊,並在這些功能之上建構。本指南不包含這類元件的設計細節。

retain

ViewModel

範圍界定

沒有共用值;每個值都會保留在組合結構階層中的特定點,並與該點相關聯。在不同位置保留相同類型,一律會對新執行個體執行。

ViewModel 是 ViewModelStore 中的單例

破壞

永久離開組合階層時

清除或刪除 ViewModelStore 時

其他功能

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

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

擁有者

RetainedValuesStore

ViewModelStore

用途

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

合併 retain 和 rememberSaveable 或 rememberSerializable

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

您可以同時使用 retain 和 rememberSaveable 或 rememberSerializable。正確結合這兩個生命週期會大幅增加複雜度。建議您在更進階和自訂的架構模式中採用此模式,且僅在符合下列所有條件時才採用:

  • 您定義的物件是由必須保留或儲存的值混合而成 (例如追蹤使用者輸入內容的物件,以及無法寫入磁碟的記憶體快取)
  • 狀態範圍限定為可組合項,不適用於 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 應用程式可支援多種板型規格,包括手機、摺疊式裝置、平板電腦和電腦。應用程式通常需要使用自動調整版面配置,在這些板型規格之間轉換。舉例來說,在平板電腦上執行的應用程式可能會顯示雙欄清單詳細資料檢視畫面,但在較小的手機螢幕上顯示時,可能會在清單和詳細資料頁面之間導覽。

由於系統會根據位置記住並保留值,因此只有在組合結構階層中的相同位置出現時,才會重複使用這些值。版面配置會配合不同板型規格調整,因此可能會改變組合階層的結構,導致系統忘記值。

如果是 ListDetailPaneScaffold 和 NavDisplay (來自 Jetpack Navigation 3) 等現成元件,這不會造成問題,狀態會在版面配置變更時保留。如要讓自訂元件適應板型規格,請執行下列任一操作,確保狀態不受版面配置變更影響:

  • 請確保有狀態可組合項一律在組合階層中的相同位置呼叫。如要實作自動調整版面配置,請變更版面配置邏輯,而非重新定位組合階層中的物件。
  • 使用 MovableContent 可順利重新定位有狀態的可組合項。MovableContent 的執行個體可將記住和保留的值從舊位置移至新位置。

記住工廠函式

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

定義以 Compose 為主的物件時,建議您建立 remember 函式,定義預期的記憶行為,包括生命週期和重要輸入內容。這樣一來,狀態的使用者就能放心地在組合階層中建立例項,這些例項會按照預期保留及失效。定義可組合工廠函式時,請遵循下列規範:

  • 在函式名稱前加上 remember。如果函式實作取決於物件為 retained,且 API 絕不會演變為依賴 remember 的不同變化版本,則可選擇改用 retain 前置字元。
  • 如果選擇狀態持續性,且可以編寫正確的 Saver 實作,請使用 rememberSaveable 或 rememberSerializable。
  • 避免根據可能與用途無關的 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) }
    )
}