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 que se recuerdan se pueden reutilizar en todas las recomposiciones, como se explica en El estado y Jetpack Compose.

Si bien remember sirve como herramienta para conservar valores entre recomposiciones, el estado a menudo debe perdurar más allá de la vida útil 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 conservados en Compose.

Elige la vida útil correcta

En Compose, hay varias funciones que puedes usar para conservar el estado en 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 actividad?

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

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

¿Los valores persisten tras el cierre del proceso?

Tipos de datos admitidos

Todos

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

Debe poder serializarse
(ya sea con un Saver personalizado o con kotlinx.serialization)

Casos de uso

  • Objetos que se limitan a la composición
  • Objetos de configuración para elementos componibles
  • Estado que se podría volver a crear sin perder la fidelidad de la IU
  • Memorias caché
  • Objetos de larga duración o "administradores"
  • Entrada del usuario
  • Estado que la app no puede recrear, como la entrada de campos 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 recompone, vuelve a ejecutar su código, pero cualquier llamada a remember devuelve sus valores de la composición anterior en lugar de volver a ejecutar el cálculo.

Cada instancia de una función componible tiene su propio conjunto de valores recordados, lo que se conoce como memoización posicional. Cuando los valores recordados se almacenan en caché para usarse en las recomposiciones, se vinculan a su posición en la jerarquía de composición. Si un elemento componible se usa 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 (incluido cuando se quita un valor y se vuelve a agregar para moverlo a una ubicación diferente sin usar el elemento key componible o MovableContent), o bien cuando se llaman con diferentes parámetros de key.

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

  • Crear objetos de estado interno, como la posición de desplazamiento o el estado de animación
  • Cómo 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 en el cierre del proceso iniciado 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 almacenar en caché los objetos de forma posicional en todas las recomposiciones, también puede guardar valores para que se puedan restablecer en las recreaciones de actividades, incluso a partir de cambios de configuración y cierres de procesos (cuando el sistema cierra 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 persistencia de tipos complejos que se pueden serializar con la biblioteca kotlinx.serialization. Elige rememberSerializable si tu tipo está marcado (o puede estarlo) 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 del 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 lugar. En general, debes usar rememberSaveable o rememberSerializable para memorizar 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 almacenados en caché serializándolos en un Bundle. Esto tiene dos consecuencias:

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

Para almacenar tipos de datos más complejos 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 los tipos de datos comunes, como State, List, Map, Set, etcétera, de forma predeterminada y los convierte automáticamente en tipos compatibles por ti. A continuación, se muestra 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 se encuentra entre remember y rememberSaveable/rememberSerializable en cuanto al tiempo que almacena en caché sus valores. Se denomina de manera diferente porque los valores retenidos también experimentan un ciclo de vida diferente al de sus contrapartes recordadas.

Cuando se conserva un valor, se almacena en caché según su posición y se guarda en una estructura de datos secundaria que tiene una vida útil independiente vinculada a la vida útil de la app. Un valor retenido puede sobrevivir a los cambios de configuración sin serializarse, pero no puede sobrevivir a la finalización del proceso. Si no se usa un valor después de que se recrea la jerarquía de composición, el valor retenido se descarta (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 de medios (como ExoPlayer) y evitar interrupciones en la reproducción de medios 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, tanto retain como ViewModel ofrecen una funcionalidad similar en su capacidad más utilizada para conservar instancias de objetos en los cambios de configuración. La elección entre retain o ViewModel depende del tipo de valor que deseas conservar, cómo debe definirse su alcance y si necesitas funcionalidad adicional.

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

ViewModel también incluye integraciones listas para usar para la inyecció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 debe retenerse en los cambios de configuración en ViewModel y sobrevivir a la finalización del proceso.

retain es más adecuado para objetos que se limitan a instancias componibles específicas y no requieren reutilización ni uso compartido entre elementos componibles hermanos. Mientras que ViewModel es 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és, seguimiento de impresiones y estadísticas, dependencias de AndroidViews y otros objetos que interactúan con el SO Android o administran bibliotecas de terceros, como procesadores de pagos o publicidad.

Para los usuarios avanzados que diseñan patrones de arquitectura de apps personalizados fuera de las recomendaciones de la arquitectura de apps para Android modernas: retain también se puede usar para compilar una API interna similar a ViewModel. Aunque no se ofrece compatibilidad inmediata con corrutinas y estados guardados, retain puede servir como bloque de compilación para el ciclo de vida de esos elementos similares a ViewModel con estas funciones integradas. Los detalles específicos para diseñar un componente de este tipo no se incluyen en esta guía.

retain

ViewModel

Delimitación del alcance

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

Los 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á en la jerarquía de composición o no.

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

Propiedad de

RetainedValuesStore

ViewModelStore

Casos de uso

  • Cómo conservar valores específicos de la IU locales para instancias individuales de elementos componibles
  • Seguimiento de impresiones, posiblemente a través de RetainedEffect
  • Componente básico para definir una arquitectura personalizada similar a ViewModel
  • Extraer las interacciones entre las capas de IU y datos en una clase separada, tanto para la organización del código como para las pruebas
  • Transformar Flows en objetos State y llamar a funciones de suspensión que no deberían 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 debería 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. Recomendamos emplear 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 conservar o guardar (p. ej., un objeto que hace un seguimiento de la entrada del usuario y una caché en la memoria que no se puede escribir en el disco).
  • Tu estado se limita a un elemento componible y no es adecuado para el alcance o la vida útil de singleton 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.
    }
}

Al separar el estado por ciclo de vida, la separación de responsabilidades y el almacenamiento se vuelven muy explícitos. Es intencional que los datos de guardado no se puedan manipular con los datos de retención, ya que esto evita una situación en la que se intenta una actualización de los datos de guardado cuando el paquete savedInstanceState ya se capturó y no se puede actualizar. También permite probar escenarios de recreación probando tus constructores sin llamar a Compose ni simular una recreación de Activity.

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

Memorizació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 con diseños adaptativos. Por ejemplo, una app que se ejecuta en una tablet puede mostrar una vista de lista y 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.

Dado que los valores recordados y retenidos se memorizan posicionalmente, 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 tu 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á durante los cambios de diseño. En el caso de los componentes personalizados que se adaptan a los factores de forma, asegúrate de que los cambios de diseño no afecten el estado. Para ello, realiza 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 adaptativos modificando la lógica del 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

Si bien las IU de Compose se componen de funciones de componibilidad, muchos objetos participan 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 almacenamiento en memoria previsto, incluidos los parámetros de entrada de vida útil y 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 esperado. Cuando definas una función de fábrica componible, sigue estos lineamientos:

  • Agrega el prefijo remember al nombre de la función. 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 del estado y es posible escribir una implementación correcta de Saver.
  • Evita efectos secundarios o inicializar valores basados en CompositionLocals que podrían no ser relevantes para el uso. Recuerda que el lugar donde se crea tu estado podría no ser el mismo en el que 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) }
    )
}