Ciclos de vida del estado en Compose

En Jetpack Compose, las funciones de componibilidad suelen contener el estado con la función remember. Los valores recordados se pueden reutilizar en todas las recomposiciones, como se explica en El estado y Jetpack Compose.

Si bien remember sirve como una herramienta para conservar valores en todas las recomposiciones, el estado suele necesitar durar más que el ciclo de vida de una composición. En esta página, se explica la diferencia entre las APIs de remember, retain, rememberSaveable, y rememberSerializable, cuándo elegir cada una y cuáles son las prácticas recomendadas para administrar los valores recordados y retenidos en Compose.

Elige la vida útil correcta

En Compose, hay varias funciones que puedes usar para conservar el estado en todas las composiciones y más allá: remember, retain, rememberSaveable y rememberSerializable. Estas funciones difieren en su vida útil y semántica, y cada una es adecuada para almacenar tipos específicos de estado. Las diferencias se describen en la siguiente tabla:

remember

retain

rememberSaveable, rememberSerializable

¿Los valores sobreviven a las recomposiciones?

¿Los valores sobreviven a las recreaciones de actividades?

Siempre se mostrará la misma instancia (===).

Se mostrará un objeto equivalente (==), posiblemente una copia deserializada.

¿Los valores sobreviven al cierre de procesos?

Tipos de datos admitidos

Todos

No debe hacer referencia a ningún objeto que se filtre si se destruye la actividad.

Debe ser serializable
(ya sea con un Saver personalizado o con kotlinx.serialization)

Casos de uso

  • Objetos que están dentro del alcance de la composición
  • Objetos de configuración para elementos componibles
  • Estado que se puede volver a crear sin perder la fidelidad de la IU
  • Caché
  • Objetos de larga duración o "administrador"
  • Entrada del usuario
  • Estado que la app no puede volver a crear, incluida la entrada de campo de texto, el estado de desplazamiento, los botones de activación, etcétera

remember

remember es la forma más común de almacenar el estado en Compose. Cuando se llama a remember por primera vez, se ejecuta el cálculo determinado y se recuerda, lo que significa que Compose lo almacena para que el elemento componible lo reutilice en el futuro. Cuando un elemento componible se vuelve a componer, ejecuta su código nuevamente, pero cualquier llamada a remember muestra sus valores de la composición anterior en lugar de volver a ejecutar el cálculo.

Cada instancia de una función de componibilidad tiene su propio conjunto de valores recordados, lo que se conoce como memoización posicional. Cuando los valores recordados se memoizan para usarse en todas las recomposiciones, se vinculan a su posición en la jerarquía de composición. Si se usa un elemento componible en diferentes ubicaciones, cada instancia en la jerarquía de composición tiene su propio conjunto de valores recordados.

Cuando ya no se usa un valor recordado, se olvida y se descarta su registro. Los valores recordados se olvidan cuando se quitan de la jerarquía de composición (incluso cuando se quita un valor y se vuelve a agregar para pasar a una ubicación diferente sin usar el elemento componible key o MovableContent) o cuando se llama con diferentes parámetros key.

De las opciones disponibles, remember tiene la vida útil más corta y olvida los valores antes que las cuatro funciones de memoización que se describen en esta página. Esto lo hace más adecuado para lo siguiente:

  • Crear objetos de estado internos, como la posición de desplazamiento o el estado de animación
  • Evitar la recreación costosa de objetos en cada recomposición

Sin embargo, debes evitar lo siguiente:

  • Almacenar cualquier entrada del usuario con remember, ya que los objetos recordados se olvidan en los cambios de configuración de la actividad y el cierre de procesos iniciados por el sistema

rememberSaveable y rememberSerializable

rememberSaveable y rememberSerializable se basan en remember. Tienen la vida útil más larga de las funciones de memoización que se analizan en esta guía. Además de memoizar objetos de forma posicional en todas las recomposiciones, también pueden guardar valores para que se puedan restablecer en las recreaciones de actividades, incluidos los cambios de configuración y el cierre de procesos (cuando el sistema finaliza el proceso de tu app mientras está en segundo plano, por lo general, para liberar memoria para las apps en primer plano o si el usuario revoca los permisos de tu app mientras se está ejecutando).

rememberSerializable funciona de la misma manera que rememberSaveable, pero admite automáticamente la conservación de tipos complejos que son serializables con la biblioteca kotlinx.serialization. Elige rememberSerializable si tu tipo está (o puede estar) marcado con @Serializable y rememberSaveable en todos los demás casos.

Esto hace que rememberSaveable y rememberSerializable sean candidatos perfectos para almacenar el estado asociado con la entrada del usuario, incluida la entrada de campo de texto, la posición de desplazamiento, los estados de activación, etcétera. Debes guardar este estado para asegurarte de que el usuario nunca pierda su posición. En general, debes usar rememberSaveable o rememberSerializable para memoizar cualquier estado que tu app no pueda recuperar de otra fuente de datos persistente, como una base de datos.

Ten en cuenta que rememberSaveable y rememberSerializable guardan sus valores memoizados serializándolos en un Bundle. Esto tiene dos consecuencias:

  • Los valores que memoizas deben poder representarse con uno o más de los siguientes tipos de datos: primitivos (incluidos Int, Long, Float, Double), String o arrays de cualquiera de estos tipos.
  • Cuando se restablece un valor guardado, será una instancia nueva que sea igual a (==), pero no la misma referencia (===) que usó la composición antes.

Para almacenar tipos de datos más complicados sin usar kotlinx.serialization, puedes implementar un Saver personalizado para serializar y deserializar tu objeto en tipos de datos admitidos. Ten en cuenta que Compose comprende tipos de datos comunes como State, List, Map, Set, etcétera, de inmediato y los convierte automáticamente en tipos admitidos en tu nombre. El siguiente es un ejemplo de un Saver para una clase Size. Se implementa empaquetando todas las propiedades de Size en una lista con listSaver.

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

La API de retain existe entre remember y rememberSaveable/rememberSerializable en términos de cuánto tiempo memoiza sus valores. Tiene un nombre diferente porque los valores retenidos también experimentan un ciclo de vida diferente al de sus contrapartes recordadas.

Cuando se retiene un valor, se memoiza de forma posicional y se guarda en una estructura de datos secundaria que tiene una vida útil independiente vinculada a la vida útil de la app's Un valor retenido puede sobrevivir a los cambios de configuración sin serializarse, pero no puede sobrevivir al cierre de procesos. Si no se usa un valor después de que se vuelve a crear la jerarquía de composición, el valor retenido se retira (que es el equivalente de retain a olvidarse).

A cambio de este ciclo de vida más corto que rememberSaveable, retain puede conservar valores que no se pueden serializar, como expresiones lambda, flujos y objetos grandes como mapas de bits. Por ejemplo, puedes usar retain para administrar un reproductor multimedia (como ExoPlayer) para evitar interrupciones en la reproducción de contenido multimedia durante un cambio de configuración.

@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 en comparación con ViewModel

En esencia, retain y ViewModel ofrecen una funcionalidad similar en su capacidad de uso frecuente para conservar instancias de objetos en los cambios de configuración. La elección de usar retain o ViewModel depende del tipo de valor que conservas, cómo debe estar dentro del alcance y si necesitas funcionalidad adicional.

Los ViewModel son objetos que suelen encapsular la comunicación entre la IU y las capas de datos de tu app. Te permiten quitar la lógica de tus funciones de componibilidad, lo que mejora la capacidad de prueba. Los ViewModel se administran como singleton dentro de un ViewModelStore y tienen una vida útil diferente a la de los valores retenidos. Si bien un ViewModel permanecerá activo hasta que se destruya su ViewModelStore es destruido, los valores retenidos se retiran cuando el contenido se quita de forma permanente de la composición (por ejemplo, para un cambio de configuración, esto significa que un valor retenido se retira si se vuelve a crear la jerarquía de la IU y no se consumió el valor retenido después de que se volvió a crear la composición).

ViewModel también incluye integraciones listas para usar para la inserción de dependencias con Dagger y Hilt, la integración con SavedState y la compatibilidad integrada con corrutinas para iniciar tareas en segundo plano. Esto hace que ViewModel sea un lugar ideal para iniciar tareas en segundo plano y solicitudes de red, interactuar con otras fuentes de datos en tu proyecto y, de manera opcional, capturar y conservar el estado de la IU fundamental que se debe retener en los cambios de configuración en el ViewModel y sobrevivir al cierre de procesos.

retain es más adecuado para objetos que están dentro del alcance de instancias de componibilidad específicas y no requieren reutilización ni uso compartido entre elementos componibles hermanos. Mientras que ViewModel actúa como un buen lugar para almacenar el estado de la IU y realizar tareas en segundo plano, retain es un buen candidato para almacenar objetos para la infraestructura de la IU, como caché, seguimiento de impresiones y estadísticas, dependencias en AndroidView y otros objetos que interactúan con el SO Android o administran bibliotecas de terceros, como procesadores de pagos o publicidad.

Para usuarios avanzados que diseñan patrones de arquitectura de apps personalizados fuera de las recomendaciones de la arquitectura de apps modernas para Android, retain también se puede usar para compilar una API interna "similar a ViewModel". Aunque no se ofrece compatibilidad lista para usar con corrutinas y estado guardado, retain puede servir como componente básico para el ciclo de vida de esos objetos similares a ViewModel con estas funciones integradas. Los detalles sobre cómo diseñar un componente de este tipo están fuera del alcance de esta guía.

retain

ViewModel

Alcance

No hay valores compartidos; cada valor se retiene en un punto específico de la jerarquía de composición y se asocia con él. Retener el mismo tipo en una ubicación diferente siempre actúa en una instancia nueva.

ViewModel son singleton dentro de un ViewModelStore.

Destrucción

Cuando se abandona de forma permanente la jerarquía de composición

Cuando se borra o destruye el ViewModelStore

Funcionalidad adicional

Puede recibir devoluciones de llamada cuando el objeto está o no en la jerarquía de composición .

coroutineScope integrado, compatibilidad con SavedStateHandle, se puede insertar con Hilt

Propiedad de

RetainedValuesStore

ViewModelStore

Casos de uso

  • Conservar valores específicos de la IU locales para instancias de componibilidad individuales
  • Seguimiento de impresiones, posiblemente a través de RetainedEffect
  • Componente básico para definir un componente de arquitectura personalizado "similar a ViewModel" componente
  • Extraer interacciones entre la IU y las capas de datos en una clase separada, tanto para la organización del código como para las pruebas
  • Transformar Flow en objetos State y llamar a funciones de suspensión que no deben interrumpirse por cambios de configuración
  • Compartir estados en áreas grandes de la IU, como pantallas completas
  • Interoperabilidad con View

Combina retain y rememberSaveable o rememberSerializable

A veces, un objeto necesita tener una vida útil híbrida de retained y rememberSaveable o rememberSerializable. Esto puede ser un indicador de que tu objeto debe ser un ViewModel, que puede admitir el estado guardado como se describe en la guía del módulo de estado guardado para ViewModel.

Es posible usar retain y rememberSaveable o rememberSerializable de forma simultánea. Combinar correctamente ambos ciclos de vida agrega una complejidad significativa. Te recomendamos que emplees este patrón como parte de patrones de arquitectura más avanzados y personalizados, y solo cuando se cumplan todas las siguientes condiciones:

  • Estás definiendo un objeto compuesto por una combinación de valores que se deben retener o guardar (p. ej., un objeto que realiza un seguimiento de una entrada del usuario y una caché en la memoria que no se puede escribir en el disco).
  • Tu estado está dentro del alcance de un elemento componible y no es adecuado para el alcance singleton o la vida útil de ViewModel.

Cuando se cumplen todas estas condiciones, te recomendamos que dividas tu clase en tres partes: los datos guardados, los datos retenidos y un objeto "mediador" que no tiene estado propio y delega en los objetos retenidos y guardados para actualizar el estado según corresponda. Este patrón adopta la siguiente forma:

@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.
    }
}

Si separas el estado por vida útil, la separación de responsabilidades y almacenamiento se vuelve muy explícita. Es intencional que los datos guardados no puedan ser manipulados por los datos retenidos, ya que esto evita una situación en la que se intenta una actualización de datos guardados cuando el paquete savedInstanceState ya se capturó y no se puede actualizar. También permite probar situaciones de recreación probando tus constructores sin llamar a Compose ni simular una recreación de actividad.

Consulta el ejemplo completo (RetainAndSaveSample.kt) para ver un ejemplo completo de cómo se puede implementar este patrón.

Memoización posicional y diseños adaptables

Las aplicaciones para Android pueden admitir muchos factores de forma, incluidos teléfonos, dispositivos plegables, tablets y computadoras de escritorio. Las aplicaciones suelen necesitar realizar la transición entre estos factores de forma mediante diseños adaptables. Por ejemplo, una app que se ejecuta en una tablet puede mostrar una vista de lista-detalles de dos columnas, pero puede navegar entre una lista y una página de detalles cuando se presenta en una pantalla de teléfono más pequeña.

Debido a que los valores recordados y retenidos se memoizan de forma posicional, solo se reutilizan si aparecen en el mismo punto de la jerarquía de composición. A medida que tus diseños se adaptan a diferentes factores de forma, pueden alterar la estructura de la jerarquía de composición y generar valores olvidados.

Para los componentes listos para usar, como ListDetailPaneScaffold y NavDisplay (de Jetpack Navigation 3), esto no es un problema y tu estado persistirá en los cambios de diseño. Para los componentes personalizados que se adaptan a los factores de forma, asegúrate de que el estado no se vea afectado por los cambios de diseño haciendo una de las siguientes acciones:

  • Asegúrate de que los elementos componibles con estado siempre se llamen en el mismo lugar de la jerarquía de composición. Implementa diseños adaptables modificando la lógica de diseño en lugar de reubicar objetos en la jerarquía de composición.
  • Usa MovableContent para reubicar elementos componibles con estado de forma correcta. Las instancias de MovableContent pueden mover valores recordados y retenidos de sus ubicaciones antiguas a las nuevas.

Recuerda las funciones de fábrica

Aunque las IUs de Compose se componen de funciones de componibilidad, muchos objetos intervienen en la creación y organización de una composición. El ejemplo más común de esto son los objetos componibles complejos que definen su propio estado, como LazyList, que acepta un LazyListState.

Cuando definas objetos enfocados en Compose, te recomendamos que crees una función remember para definir el comportamiento de recuerdo deseado, incluidos la vida útil y las entradas clave. Esto permite que los consumidores de tu estado creen con confianza instancias en la jerarquía de composición que sobrevivirán y se invalidarán según lo previsto. Cuando definas una función de fábrica de componibilidad, sigue estos lineamientos:

  • Antepón el nombre de la función con remember. De manera opcional, si la implementación de la función depende de que el objeto sea retained y la API nunca evolucionará para depender de una variación diferente de remember, usa el prefijo retain en su lugar.
  • Usa rememberSaveable o rememberSerializable si se elige la persistencia de estado y es posible escribir una implementación Saver correcta.
  • Evita los efectos secundarios o la inicialización de valores basados en CompositionLocal que podrían no ser relevantes para el uso. Recuerda que el lugar donde se crea tu estado podría no ser donde se consume.

@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) }
    )
}