Compose 中的状态生命周期

在 Jetpack Compose 中,可组合函数通常使用 remember 函数来保存状态。如状态和 Jetpack Compose 中所述,记住的值可在重组时重复使用。

虽然 remember 可作为在重组期间保持值的工具,但状态通常需要在组合的生命周期之外存在。本页介绍了 rememberretainrememberSaveablerememberSerializable API 之间的区别、何时选择哪个 API,以及在 Compose 中管理记忆值和保留值的最佳实践。

选择正确的使用寿命

在 Compose 中,您可以使用多个函数来跨组合及其他场景持久保存状态:rememberretainrememberSaveablerememberSerializable。这些函数的生命周期和语义各不相同,并且各自适合存储特定类型的状态。下表概述了这些区别:

remember

retain

rememberSaveablerememberSerializable

值在重组后是否仍然存在?

值在 activity 重新创建后是否会继续留存?

始终返回相同的 (===) 实例

系统将返回一个等效 (==) 对象,可能是反序列化副本

值在进程终止后是否继续存在?

支持的数据类型

全部

不得引用在销毁 activity 时会泄露的任何对象

必须可序列化
(通过自定义 Saver 或通过 kotlinx.serialization

用例

  • 以组合为作用域的对象
  • 可组合项的配置对象
  • 可以重新创建且不会丢失界面保真度的状态
  • 缓存
  • 长期存在的对象或“管理器”对象
  • 用户输入
  • 应用无法重新创建的状态,包括文本字段输入、滚动状态、切换开关等。

remember

remember 是在 Compose 中存储状态的最常见方式。当首次调用 remember 时,系统会执行给定的计算并记住结果,这意味着 Compose 会存储该结果,以便可组合函数日后重复使用。当可组合函数重组时,它会再次执行其代码,但对 remember 的任何调用都会从之前的组合中返回其值,而不是再次执行计算。

可组合函数的每个实例都有一组自己的记忆值,称为位置记忆化。当记忆的值被记忆化以供在重组中使用时,它们会与在组合层次结构中的位置相关联。如果某个可组合函数在不同位置使用,则组合层次结构中的每个实例都有一组自己的记忆值。

当不再使用记忆的值时,系统会忘记该值并舍弃其记录。当记住的值从组合层次结构中移除时(包括在不使用 key 可组合项或 MovableContent 的情况下移除值并重新添加以移至其他位置时),或者使用不同的 key 参数调用时,系统会忘记这些值。

在可用的选项中,remember 的生命周期最短,并且会最早忘记本页中介绍的四种记忆化函数的值。因此,它最适合用于:

  • 创建内部状态对象,例如滚动位置或动画状态
  • 避免在每次重组时重新创建开销较大的对象

不过,您应避免:

  • 使用 remember 存储任何用户输入,因为在 activity 配置更改和系统发起的进程终止后,系统会忘记已记忆的对象。

rememberSaveablerememberSerializable

rememberSaveablerememberSerializable 基于 remember 构建。它们是本指南中讨论的记忆化函数中生命周期最长的。除了在重组后按位置记忆对象之外,它还可以保存值,以便在重新创建 activity 时(包括因配置更改和进程终止而重新创建 activity 时)恢复这些值(当系统在您的应用位于后台时终止其进程时,通常是为了释放内存以供前台应用使用,或者用户在您的应用运行时撤消其权限时)。

rememberSerializable 的工作方式与 rememberSaveable 相同,但会自动支持使用 kotlinx.serialization 库可序列化的复杂类型的持久性。如果您的类型标记为 @Serializable(或可以标记为 @Serializable),请选择 rememberSerializable;在所有其他情况下,请选择 rememberSaveable

因此,rememberSaveablerememberSerializable 非常适合存储与用户输入相关联的状态,包括文本字段条目、滚动位置、切换状态等。您应保存此状态,以确保用户永远不会丢失其位置。一般来说,您应使用 rememberSaveablerememberSerializable 来记忆应用无法从其他持久性数据源(例如数据库)检索的任何状态。

请注意,rememberSaveablerememberSerializable 通过将记忆化值序列化为 Bundle 来保存这些值。这会带来以下两个影响:

  • 您记忆的值必须能以以下一种或多种数据类型表示:基元(包括 IntLongFloatDouble)、String 或上述任一类型的数组。
  • 恢复已保存的值时,该值将是一个与之前组合使用的新实例(==),但不是相同的引用(===)。

如需存储更复杂的数据类型而不使用 kotlinx.serialization,您可以实现自定义 Saver,以将对象序列化和反序列化为支持的数据类型。请注意,Compose 可开箱即用地理解 StateListMapSet 等常见数据类型,并自动将这些类型转换为受支持的类型。以下是 Size 类的 Saver 示例。它通过使用 listSaverSize 的所有属性打包到列表中来实现。

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 的生命周期,retain 能够持久保存无法序列化的值,例如 lambda 表达式、flow 和大型对象(如位图)。例如,您可以使用 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 在其最常用的功能(即在配置更改期间持久保留对象实例)方面提供类似的功能。选择使用 retain 还是 ViewModel 取决于您要持久保存的值的类型、其作用域应如何设置,以及您是否需要其他功能。

ViewModel 是通常封装应用界面层和数据层之间通信的对象。它们可让您将逻辑移出可组合函数,从而提高可测试性。ViewModelViewModelStore 中作为单例进行管理,并且具有不同于保留值的生命周期。ViewModel 会一直保持有效状态,直到其 ViewModelStore 被销毁,而保留的值会在内容从组合中永久移除时失效(例如,对于配置更改,这意味着如果重新创建了界面层次结构,并且在重新创建组合后未消耗保留的值,则该保留的值会失效)。

ViewModel 还包括与 Dagger 和 Hilt 进行依赖项注入的开箱即用集成、与 SavedState 的集成,以及用于启动后台任务的内置协程支持。因此,ViewModel 非常适合启动后台任务和网络请求、与项目中的其他数据源互动,以及选择性地捕获和持久保存关键的界面状态,这些状态应在 ViewModel 中跨配置变更保留,并在进程终止后继续存在。

retain 最适合作用域限定为特定可组合实例的对象,并且不需要在同级可组合项之间重复使用或共享。ViewModel 非常适合用于存储界面状态和执行后台任务,而 retain 则非常适合用于存储界面管道的对象,例如缓存、展示跟踪和分析、对 AndroidView 的依赖项,以及与 Android 操作系统交互或管理第三方库(例如付款处理方或广告)的其他对象。

对于设计现代 Android 应用架构建议之外的自定义应用架构模式的高级用户:retain 还可用于构建内部“ViewModel 类似”API。虽然 retain 不提供对协程和已保存状态的开箱即用支持,但它可以作为此类 ViewModel 类似物的生命周期构建块,在这些类似物之上构建这些功能。有关如何设计此类组件的具体细节不在本指南的讨论范围内。

retain

ViewModel

确定范围

没有共享值;每个值都保留在合成层次结构的特定点,并与该点相关联。在不同位置保留相同类型的实例始终会作用于新实例。

ViewModelViewModelStore 中的单例

销毁

永久离开组合层次结构时

ViewModelStore 被清除或销毁时

其他功能

可以在对象位于组合层次结构中或不在组合层次结构中时接收回调

内置 coroutineScope,支持 SavedStateHandle,可使用 Hilt 注入

所有者

RetainedValuesStore

ViewModelStore

用例

  • 在各个可组合函数实例的本地持久保存界面特定值
  • 展示跟踪,可能通过 RetainedEffect 实现
  • 用于定义自定义“ViewModel 类”架构组件的构建块
  • 将界面层和数据层之间的互动提取到单独的类中,以便进行代码整理和测试
  • Flow 转换为 State 对象,并调用不应因配置更改而中断的挂起函数
  • 在大型界面区域(例如整个屏幕)中共享状态
  • 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 界面由可组合函数组成,但在创建和组织组合时,需要用到许多对象。最常见的示例是定义了自己的状态的复杂可组合对象,例如接受 LazyListStateLazyList

定义以 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) }
    )
}