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
fun HelloContent() {
   Column(modifier = Modifier.padding(16.dp)) {
       Text(
           text = "Hello!",
           modifier = Modifier.padding(bottom = 8.dp),
           style = MaterialTheme.typography.h5
       )
       OutlinedTextField(
           value = "",
           onValueChange = { },
           label = { Text("Name") }
       )
   }
}

Si ejecutas el código, 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. Tiene que ver con la manera en que 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 que admiten composición

Las funciones que admiten composición pueden almacenar un solo objeto en la memoria por medio del elemento remember que admite composición. 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 programará la recomposición de las funciones que admiten composición que lean value. En el caso de ExpandingCard, cada vez que cambia expanded, hace que se vuelva a componer ExpandingCard.

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.h5
           )
       }
       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

Jetpack Compose no requiere que uses MutableState<T> para contener el estado. Jetpack Compose admite otros tipos observables. Antes de leer otro tipo observable en Jetpack Compose, debes convertirlo en un State<T> para que Jetpack Compose pueda 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:

Puedes compilar una función de extensión para Jetpack Compose a los efectos de leer otros tipos observables si tu app usa una clase observable personalizada. Consulta la implementación de los módulos integrados a fin de obtener ejemplos de cómo hacer esto. Cualquier objeto que permita que Jetpack Compose se adhiera a cada cambio puede convertirse en State<T> y leerse mediante un elemento que admite composición.

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, como hace ExpandingCard con onExpand y onCollapse.

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 que admiten composición podrán modificar su estado. Es completamente interno.
  • Capacidad de compartir: El estado elevado puede compartirse con varios elementos que admiten composición. Si quisiéramos usar name en un elemento diferente que admite composición, la elevación nos permitirá hacerlo.
  • Capacidad de interceptar: Los llamadores a los elementos sin estado que admiten composición pueden decidir ignorar o modificar eventos antes de cambiar el estado.
  • Separación: El estado para ExpandingCard sin estado 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.h5
        )
        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.

Cómo restablecer el estado en Compose

Usa rememberSaveable para restablecer el estado de tu IU después de volver a crear una actividad o un proceso. rememberSaveable conserva el estado en todas las recomposiciones. Además, rememberSaveable también lo conserva en toda la recreación de la actividad y el proceso.

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.

Crea paquetes

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

Cómo administrar el estado en Compose

La elevación de estado simple se puede administrar en las mismas funciones que admiten composición. 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.

En esta sección, se explica cómo administrar el estado de diferentes maneras en Compose. Según la complejidad del elemento que admite composición, debes tener en cuenta diferentes alternativas:

  • Elementos que admiten composición para administrar el estado de elementos simples de la IU.
  • Contenedores de estado para administrar el estado de elementos complejos de la IU. Poseen el estado de los elementos de la IU y su lógica.
  • ViewModels de los componentes de la arquitectura como un tipo especial de contenedor de estado a cargo de brindar acceso a la lógica empresarial y al estado de la pantalla o la IU.

Los contenedores de estado están disponibles en una variedad de tamaños, según el alcance de los elementos correspondientes de la IU que administran, lo que comprende desde un solo widget, como una barra inferior de la app, hasta una pantalla completa. Los contenedores de estado se pueden combinar; es decir, es posible que un contenedor de estado se integre en otro contenedor de estado, en especial, cuando se agregan estados.

En el siguiente diagrama, se muestra un resumen de las relaciones entre las entidades que se involucran en la administración de estado de Compose. En el resto de la sección, se explica cada entidad en detalle:

  • Un elemento que admite composición puede depender de 0 o más contenedores de estado (que pueden ser objetos sin formato, ViewModel o ambos), según su complejidad.
  • Es probable que un contenedor de estado simple dependa de un objeto ViewModel si necesita acceder a la lógica empresarial o al estado de la pantalla.
  • Un objeto ViewModel depende de las capas empresariales o de datos.

Diagrama en el que se muestran las dependencias en la administración de estado, como se describe en la lista anterior.

Resumen de las dependencias (opcionales) para cada entidad que se involucra en la administración de estado de Compose.

Tipos de estado y lógica

En una app para Android, existen diferentes tipos de estados que debes tener en cuenta:

  • El estado de elemento de la IU es el estado elevado de los elementos de esta. Por ejemplo, ScaffoldState controla el estado del elemento Scaffold que admite composición.

  • El estado de la pantalla o la IU hace referencia a qué debe mostrarse en la pantalla, por ejemplo, una clase CartUiState que puede incluir los elementos del carrito, mensajes para mostrárselos al usuario o marcas de carga. Por lo general, este estado se conecta con otras capas de la jerarquía porque incluye datos de la aplicación.

Además, existen diferentes tipos de lógica:

  • La lógica de comportamiento de la IU o la lógica de la IU está relacionada con cómo se muestran los cambios de estado en la pantalla. Por ejemplo, la lógica de navegación decide la pantalla que se mostrará a continuación, mientras que la lógica de la IU decide cómo se muestran los mensajes del usuario en la pantalla que podría estar usando barras de notificaciones o avisos. La lógica de comportamiento de la IU siempre debe estar activa en Composition.

  • La lógica empresarial hace referencia a qué hacer con los cambios de estado. Por ejemplo, realizar un pago o almacenar las preferencias del usuario. Por lo general, esta lógica se ubica en las capas empresariales o de datos, nunca en la capa de la IU.

Elementos que admiten composición como fuente de confianza

Contar con la lógica de la IU y el estado de sus elementos en objetos que admiten composición es un buen enfoque si el estado y la lógica son simples. Por ejemplo, a continuación, se muestra el elemento MyApp que admite composición y que controla ScaffoldState y CoroutineScope:

@Composable
fun MyApp() {
    MyTheme {
        val scaffoldState = rememberScaffoldState()
        val coroutineScope = rememberCoroutineScope()

        Scaffold(scaffoldState = scaffoldState) {
            MyContent(
                showSnackbar = { message ->
                    coroutineScope.launch {
                        scaffoldState.snackbarHostState.showSnackbar(message)
                    }
                }
            )
        }
    }
}

Como el objeto ScaffoldState incluye propiedades mutables, todas las interacciones con este deben producirse en el elemento MyApp que admite composición. De lo contrario, si lo pasamos a otros elementos que admiten composición, podrían mutar su estado, lo que no cumple con el principio de fuente de confianza única y dificulta más el seguimiento de errores.

Contenedores de estado como fuente de confianza

Cuando un elemento que admite composición incluye una lógica de IU compleja que involucra el estado de varios elementos de esta, se debe delegar esa responsabilidad a los contenedores de estado. De esta manera, se permite probar esta lógica de forma aislada y se reduce la complejidad del elemento que admite composición. Este enfoque favorece el principio de separación de problemas: el elemento que admite composición está a cargo de emitir los elementos de la IU y el contenedor del estado incluye la lógica de la IU y el estado de sus elementos.

Los contenedores de estado son clases sin formato que se crean y recuerdan en Composition. Como siguen el ciclo de vida del elemento que admite composición, pueden tomar dependencias de Compose.

Si aumenta la responsabilidad del elemento MyApp que admite composición de la sección Elementos que admiten composición como fuente de confianza, podemos crear un contenedor de estado de MyAppState para administrar su complejidad:

// Plain class that manages App's UI logic and UI elements' state
class MyAppState(
    val scaffoldState: ScaffoldState,
    val navController: NavHostController,
    private val resources: Resources,
    /* ... */
) {
    val bottomBarTabs = /* State */

    // Logic to decide when to show the bottom bar
    val shouldShowBottomBar: Boolean
        @Composable get() = /* ... */

    // Navigation logic, which is a type of UI logic
    fun navigateToBottomBarRoute(route: String) { /* ... */ }

    // Show snackbar using Resources
    fun showSnackbar(message: String) { /* ... */ }
}

@Composable
fun rememberMyAppState(
    scaffoldState: ScaffoldState = rememberScaffoldState(),
    navController: NavHostController = rememberNavController(),
    resources: Resources = LocalContext.current.resources,
    /* ... */
) = remember(scaffoldState, navController, resources, /* ... */) {
    MyAppState(scaffoldState, navController, resources, /* ... */)
}

Como MyAppState toma dependencias, te recomendamos que brindes un método que recuerde una instancia de MyAppState en Composition. En este caso, la función rememberMyAppState.

Ahora, MyApp se enfoca en emitir elementos de la IU y delega toda la lógica de la IU y el estado de sus elementos a MyAppState:

@Composable
fun MyApp() {
    MyTheme {
        val myAppState = rememberMyAppState()
        Scaffold(
            scaffoldState = myAppState.scaffoldState,
            bottomBar = {
                if (myAppState.shouldShowBottomBar) {
                    BottomBar(
                        tabs = myAppState.bottomBarTabs,
                        navigateToRoute = {
                            myAppState.navigateToBottomBarRoute(it)
                        }
                    )
                }
            }
        ) {
            NavHost(navController = myAppState.navController, "initial") { /* ... */ }
        }
    }
}

Como puedes ver, aumentar las responsabilidades de un elemento que admite composición incrementa la necesidad de un contenedor de estado. Las responsabilidades pueden estar en la lógica de la IU o solo en la cantidad de estado del que se debe realizar un seguimiento.

ViewModel como fuente de confianza

Si las clases de contenedores de estado sin formato están a cargo de la lógica de la IU y el estado de sus elementos, ViewModel es un tipo especial de contenedor de estado que está a cargo de lo siguiente:

  • brindar acceso a la lógica empresarial de la aplicación que, por lo general, se ubica en otras capas de la jerarquía, como las capas empresariales y de datos, y
  • preparar los datos de la aplicación para mostrarlos en una pantalla particular, que se convierte en el estado de la pantalla o de la IU.

Los objetos ViewModel tienen un vida útil más extensa que Composition, ya que permanecen vigentes tras los cambios de configuración. Pueden seguir el ciclo de vida del host del contenido de Compose, es decir, actividades o fragmentos, o el ciclo de vida de un destino o el gráfico de navegación si utilizas la biblioteca de navegación. Debido a su vida útil más extensa, los objetos ViewModel no deberían retener referencias de larga duración en el estado que se vincula a la vida útil de Composition. Si lo hacen, se podrían producir fugas de memoria.

Te recomendamos que los elementos que admiten composición en el nivel de pantalla usen objetos ViewModel, de modo que brinden acceso a la lógica empresarial y sean la fuente de confianza para el estado de su IU. Consulta la sección ViewModel y contenedores de estado para comprender por qué los objetos ViewModel son una buena opción.

A continuación, se muestra un ejemplo de un ViewModel que se usa en un elemento que admite composición en el nivel de la pantalla:

data class ExampleUiState(
    dataToDisplayOnScreen: List<Example> = emptyList(),
    userMessages: List<Message> = emptyList(),
    loading: Boolean = false
)

class ExampleViewModel(
    private val repository: MyRepository,
    private val savedState: SavedStateHandle
) : ViewModel() {

    var uiState by mutableStateOf<ExampleUiState>(...)
        private set

    // Business logic
    fun somethingRelatedToBusinessLogic() { ... }
}

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    ...

    Button(onClick = { viewModel.somethingRelatedToBusinessLogic() }) {
        Text("Do something")
    }
}

ViewModel y contenedores de estado

Los beneficios de ViewModel en el desarrollo de Android permiten que sean adecuados para brindar acceso a la lógica empresarial y preparar los datos de la aplicación a fin de mostrarlos en la pantalla. Específicamente, los beneficios son los siguientes:

  • Las operaciones que activan los objetos ViewModel permanecen vigentes tras los cambios de configuración.
  • La integración con Navigation:
    • Navigation almacena en caché los objetos ViewModel mientras la pantalla se encuentra en la pila de actividades. Es importante que los datos que se carguen previamente estén disponibles de forma instantánea cuando regreses a tu destino. Esta tarea es más difícil de realizar con un contenedor de estado que sigue el ciclo de vida de la pantalla que admite composición.
    • El objeto ViewModel también se borra cuando se quita el destino de la pila de actividades, lo que garantiza que se limpie el estado automáticamente. Es diferente a detectar la eliminación que admite composición, que puede producirse por varios motivos, por ejemplo, dirigirse a una pantalla nueva, debido a un cambio de configuración, etc.
  • La integración con otras bibliotecas de Jetpack, como Hilt.

Como los contenedores de estado se pueden combinar, y los objetos ViewModel y los contenedores de estado sin formato tienen responsabilidades diferentes, es posible que un elemento que admite composición en el nivel de pantalla tenga un ViewModel que brinde acceso a la lógica empresarial Y un contenedor de estado que administre la lógica de su IU y el estado de sus elementos. Como los objetos ViewModel tiene una vida útil más extensa que los contenedores de estado, estos pueden tomar los ViewModel como dependencia si es necesario.

En el siguiente código, se muestra un objeto ViewModel y un contenedor de estado sin formato que funcionan juntos en ExampleScreen:

private class ExampleState(
    val lazyListState: LazyListState,
    private val resources: Resources,
    private val expandedItems: List<Item> = emptyList()
) { ... }

@Composable
private fun rememberExampleState(...) { ... }

@Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {

    val uiState = viewModel.uiState
    val exampleState = rememberExampleState()

    LazyColumn(state = exampleState.lazyListState) {
        items(uiState.dataToDisplayOnScreen) { item ->
            if (exampleState.isExpandedItem(item) {
                ...
            }
            ...
        }
    }
}

Más información

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

Codelabs

Videos