Compose 中的状态生命周期

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

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

选择正确的生命周期

在 Compose 中,您可以使用多个函数在组合及其他位置保留状态:remember、retain、rememberSaveable 和 rememberSerializable。这些函数的生命周期和语义各不相同,分别适合存储特定类型的状态。下表概述了这些差异:

remember

retain

rememberSaveable、rememberSerializable

值在重组后继续有效?

✅

✅

✅

值在 activity 重新创建后是否会保留?

❌

✅

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

✅

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

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

❌

❌

✅

受支持的数据类型

全部

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

必须可序列化
(使用自定义 Saver 或 kotlinx.serialization)

使用场景

  • 限定于组合范围内的对象
  • 可组合项的配置对象
  • 可在不损失界面保真度的情况下重新创建的状态
  • 缓存
  • 长生命周期对象或“管理器”对象
  • 用户输入
  • 应用无法重新创建的状态,包括文本字段输入、滚动状态、切换开关等。

remember

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

可组合函数的每个实例都有自己的一组记住的值,称为位置记忆。当系统对记住的值进行记忆化处理以便在重组中使用时,这些值会与它们在组合层次结构中的位置相关联。如果在不同位置使用可组合项,则组合层次结构中的每个实例都有自己的一组记住的值。

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

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

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

不过,您应避免:

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

rememberSaveable和rememberSerializable

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

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 是通常封装应用界面与数据层之间通信的对象。借助这些类,您可以将逻辑从可组合函数中移出,从而提高可测试性。ViewModel 在 ViewModelStore 中作为单例进行管理,其生命周期与保留值不同。虽然 ViewModel 会一直保持活跃状态,直到其 ViewModelStore 被销毁,但当内容从组合中永久移除时,保留的值会被停用(例如,对于配置更改,这意味着如果重新创建了界面层次结构,并且在重新创建组合后未消耗保留的值,则保留的值会被停用)。

ViewModel 还包含开箱即用的集成,可用于通过 Dagger 和 Hilt 进行依赖项注入、与 SavedState 集成,以及用于启动后台任务的内置协程支持。这使得 ViewModel 成为启动后台任务和网络请求、与项目中的其他数据源交互以及选择性地捕获和持久保存关键任务界面状态的理想场所,这些状态既应在 ViewModel 中的配置更改期间保留,又应在进程终止后继续存在。

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

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

retain

ViewModel

范围界定

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

ViewModel 是 ViewModelStore 中的单例

销毁

永久离开组合层次结构时

ViewModelStore 被清除或销毁时

其他功能

无论对象是否在组合层次结构中,都可以接收回调

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

所有者

RetainedValuesStore

ViewModelStore

用例

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