El estado y Jetpack Compose

El estado de una app es cualquier valor que puede cambiar con el paso del tiempo. Esta es una definición muy amplia y abarca desde una base de datos de Room hasta una variable de una clase.

Todas las apps para Android muestran un estado al usuario. Estos son algunos ejemplos de estado de las apps para Android:

  • Una barra de notificaciones que se muestra cuando no se puede establecer una conexión de red
  • Una entrada de blog y los comentarios asociados
  • Las animaciones con efectos de propagación en botones que se reproducen cuando un usuario hace clic en ellas
  • Las calcomanías que un usuario puede dibujar sobre una imagen

Jetpack Compose te ayuda a definir explícitamente el lugar y la manera en que almacenas y usas el estado en una app para Android. Esta guía se enfoca en la conexión entre el estado y los elementos que admiten composición, y en las API que Jetpack Compose ofrece para trabajar de manera más sencilla con el estado en cuestión.

Estado y composición

Compose es declarativo y, por lo tanto, la única manera de actualizarlo es llamar al mismo elemento que admite composición con argumentos nuevos. Estos argumentos son representaciones del estado de la IU. Cada vez que se actualiza un estado, se produce una recomposición. En consecuencia, elementos, como TextField, no se actualizan automáticamente de la misma manera que en las vistas imperativas que se basan en XML. A un elemento que admite composición se le debe informar, de manera explícita, el estado nuevo para que se actualice según corresponda.

@Composable
private fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello!",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(
            value = "",
            onValueChange = { },
            label = { Text("Name") }
        )
    }
}

Si lo ejecutas y tratas de ingresar texto, verás que no sucede nada. Eso se debe a que TextField no se actualiza a sí mismo, sino que lo hace cuando cambia su parámetro value. Esto se debe a cómo funcionan la composición y la recomposición en Compose.

Para obtener más información sobre la composición inicial y la recomposición, consulta Cómo pensar en Compose.

El estado en elementos componibles

Las funciones de componibilidad pueden usar la API de remember para almacenar un objeto en la memoria. Un valor calculado por remember se almacena en la composición durante la composición inicial, y el valor almacenado se muestra durante la recomposición. Se puede usar remember para almacenar tanto objetos mutables como inmutables.

mutableStateOf crea un MutableState<T> observable, que es un tipo observable integrado en el entorno de ejecución de Compose.

interface MutableState<T> : State<T> {
    override var value: T
}

Cualquier cambio en value programa la recomposición de las funciones que admiten composición que lean value.

Existen tres maneras de declarar un objeto MutableState en un elemento que admite composición:

  • val mutableState = remember { mutableStateOf(default) }
  • var value by remember { mutableStateOf(default) }
  • val (value, setValue) = remember { mutableStateOf(default) }

Esas declaraciones son equivalentes y se proporcionan como sintaxis edulcorada para diferentes usos del estado. Elige la que genere el código más fácil de leer en el elemento que admite composición que desees escribir.

La sintaxis del delegado by requiere las siguientes importaciones:

import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue

Puedes usar el valor recordado como un parámetro para otros elementos que admiten composición o incluso como lógica en declaraciones para cambiar los elementos que se muestran. Por ejemplo, si no quieres mostrar el saludo cuando el nombre está vacío, usa el estado en una declaración if:

@Composable
fun HelloContent() {
    Column(modifier = Modifier.padding(16.dp)) {
        var name by remember { mutableStateOf("") }
        if (name.isNotEmpty()) {
            Text(
                text = "Hello, $name!",
                modifier = Modifier.padding(bottom = 8.dp),
                style = MaterialTheme.typography.bodyMedium
            )
        }
        OutlinedTextField(
            value = name,
            onValueChange = { name = it },
            label = { Text("Name") }
        )
    }
}

Aunque remember te ayuda a retener el estado entre recomposiciones, el estado no se retiene entre cambios de configuración. Para ello, debes usar rememberSaveable. rememberSaveable almacena automáticamente cada valor que se puede guardar en un Bundle. Para otros valores, puedes pasar un objeto Saver personalizado.

Otros tipos de estado compatibles

Compose no requiere que uses MutableState<T> para contener el estado, ya que admite otros tipos observables. Antes de leer otro tipo observable en Compose, debes convertirlo en un State<T> para que los elementos componibles puedan recomponer automáticamente cuando cambie el estado.

Compose cuenta con funciones para crear State<T> a partir de tipos observables comunes utilizados en apps para Android: Antes de usar estas integraciones, agrega los artefactos adecuados según se describe a continuación:

  • Flow: collectAsStateWithLifecycle()

    collectAsStateWithLifecycle() recopila valores de un Flow de manera optimizada para ciclos de vida, lo que permite que tu app conserve los recursos. Representa el último valor emitido de State de Compose. Usa esta API como la forma recomendada de recopilar flujos en apps para Android.

    La siguiente dependencia es obligatoria en el archivo build.gradle (debe ser 2.6.0-beta01 o versiones posteriores):

Kotlin

dependencies {
      ...
      implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7")
}

Groovy

dependencies {
      ...
      implementation "androidx.lifecycle:lifecycle-runtime-compose:2.8.7"
}
  • Flow: collectAsState()

    collectAsState es similar a collectAsStateWithLifecycle, ya que también recopila valores de Flow y los transforma en Compose State.

    Usa collectAsState para el código independiente de la plataforma en lugar de collectAsStateWithLifecycle, que es solo para Android.

    No se requieren dependencias adicionales para collectAsState, porque están disponibles en compose-runtime.

  • LiveData: observeAsState()

    observeAsState() comienza a observar este elemento LiveData y representa sus valores a través de State.

    La siguiente dependencia es obligatoria en el archivo build.gradle:

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-livedata:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-livedata:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava2:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava2:1.7.5"
}

Kotlin

dependencies {
      ...
      implementation("androidx.compose.runtime:runtime-rxjava3:1.7.5")
}

Groovy

dependencies {
      ...
      implementation "androidx.compose.runtime:runtime-rxjava3:1.7.5"
}

Con estado frente a sin estado

Un elemento que admite composición y usa remember para almacenar un objeto crea un estado interno, lo que genera un elemento con estado que admite composición. HelloContent es un ejemplo de un elemento con estado que admite composición, ya que mantiene y modifica su estado name de forma interna. Puede ser útil en situaciones en las que no es necesario que el llamador controle el estado, y pueda usar este estado sin tener que administrarlo por su cuenta. Sin embargo, los elementos con estado interno que admiten composición suelen ser menos reutilizables y más difíciles de probar.

Un elemento sin estado que admite composición no mantiene ningún estado. Una manera fácil de lograr este tipo de estado es usar la elevación de estado.

A medida que desarrollas elementos reutilizables que admiten composición, a menudo deseas exponer una versión con estado y otra sin estado del mismo elemento que admite composición. La versión con estado es conveniente para los llamadores a los que no les importa el estado, y la versión sin estado es necesaria para los llamadores que necesitan controlar o elevar el estado.

Elevación de estado

La toma de estado en Compose es un patrón asociado al movimiento del estado a un llamador de un elemento que admite composición a fin de hacer que un elemento sea sin estado. El patrón general para la elevación de estado en Jetpack Compose es reemplazar la variable de estado con dos parámetros:

  • value: T: el valor actual que se mostrará
  • onValueChange: (T) -> Unit: un evento que solicita que cambie el valor, donde T es el valor nuevo propuesto

Sin embargo, no estás limitado a onValueChange. Si hay eventos más específicos adecuados para el elemento que admite composición, deberás definirlos con expresiones lambda.

El estado elevado de esta manera tiene algunas propiedades importantes:

  • Fuente única de información: Mover el estado en lugar de duplicarlo garantizará que exista solo una fuente de información. Eso ayuda a evitar errores.
  • Encapsulamiento: Solo elementos con estado componibles pueden modificar su estado. Es completamente interno.
  • Capacidad de compartir: El estado elevado puede compartirse con varios elementos que admiten composición. Si deseas leer name en un elemento componible diferente, la elevación te permitirá hacerlo.
  • Capacidad de interceptar: Los llamadores a los elementos componibles sin estado pueden decidir ignorar o modificar eventos antes de cambiar el estado.
  • Separado: El estado de los elementos sin estado componibles se puede almacenar en cualquier lugar. Por ejemplo, ahora es posible mover name a un objeto ViewModel.

En el caso de ejemplo, extraes el name y el onValueChange de HelloContent, y los mueves hacia arriba en el árbol hasta un elemento que admite composición HelloScreen que llama a HelloContent.

@Composable
fun HelloScreen() {
    var name by rememberSaveable { mutableStateOf("") }

    HelloContent(name = name, onNameChange = { name = it })
}

@Composable
fun HelloContent(name: String, onNameChange: (String) -> Unit) {
    Column(modifier = Modifier.padding(16.dp)) {
        Text(
            text = "Hello, $name",
            modifier = Modifier.padding(bottom = 8.dp),
            style = MaterialTheme.typography.bodyMedium
        )
        OutlinedTextField(value = name, onValueChange = onNameChange, label = { Text("Name") })
    }
}

Si se toma el estado de HelloContent, es más fácil entender el elemento que admite composición, volver a utilizarlo en diferentes situaciones y realizar pruebas. HelloContent está separado de la forma en que se almacena el estado. Esta separación implica que, si modificas o reemplazas HelloScreen, no necesitas cambiar la forma en que se implementa HelloContent.

El patrón en el que el estado baja y los eventos suben se llama flujo unidireccional de datos. En este caso, el estado baja de HelloScreen a HelloContent y los eventos suben de HelloContent a HelloScreen. Si sigues el flujo unidireccional de datos, podrás separar los elementos que admiten composición y muestran el estado de la IU respecto de las partes de la app que almacenan y cambian el estado.

Consulta la página Dónde elevar el estado para obtener más información.

Cómo restablecer el estado en Compose

La API de rememberSaveable se comporta de manera similar a remember porque retiene el estado en todas las recomposiciones y, también, en la actividad o la recreación de procesos con el mecanismo de estado de instancia guardado. Por ejemplo, esto ocurre cuando se rota la pantalla.

Maneras de almacenar el estado

Todos los tipos de datos que se agregan a Bundle se guardan automáticamente. Si deseas guardar algo que no se puede agregar a Bundle, tienes varias opciones.

Parcelize

La solución más simple es agregar la anotación @Parcelize al objeto. El objeto se vuelve parcelable y se puede empaquetar. Por ejemplo, este código hace que un tipo de datos City se vuelva parcelable y lo guarda en el estado.

@Parcelize
data class City(val name: String, val country: String) : Parcelable

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

MapSaver

Si la alternativa @Parcelize no es adecuada por algún motivo, puedes usar mapSaver para definir tu propia regla de conversión de objetos en conjuntos de valores que el sistema pueda guardar en Bundle.

data class City(val name: String, val country: String)

val CitySaver = run {
    val nameKey = "Name"
    val countryKey = "Country"
    mapSaver(
        save = { mapOf(nameKey to it.name, countryKey to it.country) },
        restore = { City(it[nameKey] as String, it[countryKey] as String) }
    )
}

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

ListSaver

Para evitar tener que definir las leyendas del mapa, también puedes usar listSaver y emplear sus índices como leyendas:

data class City(val name: String, val country: String)

val CitySaver = listSaver<City, Any>(
    save = { listOf(it.name, it.country) },
    restore = { City(it[0] as String, it[1] as String) }
)

@Composable
fun CityScreen() {
    var selectedCity = rememberSaveable(stateSaver = CitySaver) {
        mutableStateOf(City("Madrid", "Spain"))
    }
}

Contenedores de estado en Compose

La elevación de estado simple se puede administrar en las mismas funciones de componibilidad. Sin embargo, si aumenta la cantidad de estado del que se debe realizar un seguimiento o si surge la lógica que se debe aplicar en las funciones que admiten composición, te recomendamos que delegues las responsabilidades de lógica y de estado a otras clases: los contenedores de estado.

Consulta la elevación de estado en la documentación de Compose o, para referencia más general, la página Contenedores de estado y estado de la IU en la guía de arquitectura para obtener información.

Vuelve a activar los cálculos de recuerdos cuando cambian las claves

La API de remember se usa con frecuencia junto con MutableState:

var name by remember { mutableStateOf("") }

Aquí, usar la función remember hace que el valor MutableState sobreviva a las recomposiciones.

En general, remember toma un parámetro lambda calculation. Cuando se ejecuta remember por primera vez, invoca la lambda calculation y almacena su resultado. Durante la recomposición, remember muestra el valor que se almacenó por última vez.

Además del estado de almacenamiento en caché, también puedes usar remember para almacenar cualquier objeto o resultado de una operación en la composición que sea costosa de inicializar o calcular. Es posible que no desees repetir este cálculo en cada recomposición. Un ejemplo es la creación de este objeto ShaderBrush, que es una operación costosa:

val brush = remember {
    ShaderBrush(
        BitmapShader(
            ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
            Shader.TileMode.REPEAT,
            Shader.TileMode.REPEAT
        )
    )
}

remember almacena el valor hasta que abandona la composición. Sin embargo, hay una manera de hacer que el valor almacenado en caché sea no válido. La API de remember también toma un parámetro key o keys. Si cambia alguna de estas claves, la próxima vez que se recomponga la función, remember hace que la caché se vuelva no válida y vuelve a ejecutar el bloque de cálculo lambda. Este mecanismo te permite controlar la vida útil de un objeto en la composición. El cálculo sigue siendo válido hasta que cambian las entradas, en lugar de hasta que el valor recordado salga de la composición.

En los siguientes ejemplos, se muestra cómo funciona este mecanismo.

En este fragmento, se crea un ShaderBrush y se usa como pintura de fondo de un elemento Box componible. remember almacena la instancia ShaderBrush porque su recreación es costosa, como se explicó anteriormente. remember toma avatarRes como el parámetro key1, que es la imagen de fondo seleccionada. Si cambia avatarRes, el pincel se recompone con la imagen nueva y se vuelve a aplicar a la Box. Esto puede ocurrir cuando el usuario selecciona otra imagen como fondo de un selector.

@Composable
private fun BackgroundBanner(
    @DrawableRes avatarRes: Int,
    modifier: Modifier = Modifier,
    res: Resources = LocalContext.current.resources
) {
    val brush = remember(key1 = avatarRes) {
        ShaderBrush(
            BitmapShader(
                ImageBitmap.imageResource(res, avatarRes).asAndroidBitmap(),
                Shader.TileMode.REPEAT,
                Shader.TileMode.REPEAT
            )
        )
    }

    Box(
        modifier = modifier.background(brush)
    ) {
        /* ... */
    }
}

En el siguiente fragmento, se eleva el estado a una clase de contenedor de estado sin formato MyAppState. Expone una función rememberMyAppState para inicializar una instancia de la clase con remember. Exponer esas funciones para crear una instancia que sobreviva a las recomposiciones es un patrón común en Compose. La función rememberMyAppState recibe windowSizeClass, que sirve como parámetro key para remember. Si cambia este parámetro, la app necesita recrear la clase contenedora de estado sin formato con el valor más reciente. Esto puede ocurrir si, por ejemplo, el usuario rota el dispositivo.

@Composable
private fun rememberMyAppState(
    windowSizeClass: WindowSizeClass
): MyAppState {
    return remember(windowSizeClass) {
        MyAppState(windowSizeClass)
    }
}

@Stable
class MyAppState(
    private val windowSizeClass: WindowSizeClass
) { /* ... */ }

Compose usa la implementación equals de la clase para decidir si una clave cambió y, luego, invalida el valor almacenado.

Almacena el estado con claves más allá de la recomposición

La API de rememberSaveable es un wrapper alrededor de remember que puede almacenar datos en un Bundle. Esta API permite que el estado sobreviva no solo a la recomposición, sino también a la recreación de la actividad y la finalización del proceso iniciado por el sistema. rememberSaveable recibe parámetros input para el mismo propósito que remember recibe keys. La caché se invalida cuando cambia alguna de las entradas. La próxima vez que se recomponga la función, rememberSaveable volverá a ejecutar el bloque de cálculo lambda.

En el siguiente ejemplo, rememberSaveable almacena userTypedQuery hasta que cambia typedQuery:

var userTypedQuery by rememberSaveable(typedQuery, stateSaver = TextFieldValue.Saver) {
    mutableStateOf(
        TextFieldValue(text = typedQuery, selection = TextRange(typedQuery.length))
    )
}

Más información

Para obtener más información sobre el estado y Jetpack Compose, consulta los siguientes recursos adicionales.

Ejemplos

Codelabs

Videos

Blogs