Contenedores de estado y estado de la IU

En la guía sobre capas de la IU, se analiza el flujo unidireccional de datos como medio para producir y administrar el estado de la IU de la capa.

Los datos fluyen de forma unidireccional desde la capa de datos hasta la IU.
Figura 1: Flujo unidireccional de datos

También destaca los beneficios de delegar la administración del flujo unidireccional de datos a una clase especial llamada contenedor de estado. Puedes implementar un contenedor de estado a través de un ViewModel o de una clase sin formato. En este documento, se analizan con más detalle los contenedores de estado y la función que cumplen en la capa de la IU.

Cuando finalices este documento, deberías conocer cómo administrar el estado de la aplicación en la capa de la IU, es decir, la canalización de la producción del estado de la IU. Deberías comprender y saber sobre lo siguiente:

  • Los tipos de estado de la IU que existen en la capa de la IU
  • Los tipos de lógica que operan en esos estados en la capa de la IU
  • La forma de elegir la implementación adecuada de un contenedor de estado, como un ViewModel o una clase simple

Elementos de la canalización de la producción del estado de la IU

El estado de la IU y la lógica que la produce definen la capa de la IU.

Estado de la IU

El estado de la IU es la propiedad que describe la IU. Existen dos tipos de estados de la IU:

  • El estado de la IU de la pantalla es aquello que necesitas mostrar en la pantalla. Por ejemplo, una clase NewsUiState puede contener los artículos de noticias y otra información necesaria para renderizar la IU. Por lo general, este estado se conecta con otras capas de la jerarquía porque incluye datos de app.
  • El estado de un elemento de la IU hace referencia a propiedades intrínsecas a los elementos de la IU que influyen en la forma en que se renderizan. Un elemento de la IU se puede ocultar o mostrar, y puede tener una fuente determinada, con cierto tamaño o color. En las vistas de Android, la Vista es la que administra este estado, ya que es un elemento inherentemente con estado, y expone métodos para modificar o consultar su estado. Un ejemplo de esto son los métodos get y set de la clase TextView para su texto. En Jetpack Compose, el estado es externo al elemento que admite composición, y hasta puedes elevarlo fuera de las inmediaciones de ese elemento hasta la función de componibilidad que realiza la llamada o hasta un contenedor de estado. Un ejemplo es ScaffoldState para el elemento Scaffold que admite composición.

Lógica

El estado de la IU no es una propiedad estática, ya que los datos de la aplicación y los eventos del usuario hacen que el estado de la IU cambie con el paso del tiempo. La lógica determina los detalles del cambio, lo que incluye qué partes del estado de la IU cambiaron, por qué y cuándo debe cambiar.

La lógica produce el estado de la IU
Figura 2: Lógica como productor del estado de la IU

La lógica en una aplicación puede ser empresarial o de IU:

  • La lógica empresarial es la implementación de los requisitos del producto para los datos de app. Por ejemplo, agregar un artículo a favoritos en una app de lectura de noticias cuando el usuario presione el botón. Por lo general, esta lógica para guardar un favorito en un archivo o una base de datos se coloca en las capas de dominio o de datos. El contenedor de estado suele delegar esta lógica a esas capas llamando a los métodos que exponen.
  • La lógica de la IU está relacionada con cómo se muestra el estado de la IU en la pantalla. Por ejemplo, obtener la sugerencia correcta de la barra de búsqueda cuando el usuario selecciona una categoría, desplazarse a un elemento determinado de una lista o establecer la lógica de navegación a una pantalla determinada cuando el usuario hace clic en un botón.

Ciclo de vida de Android y tipos de estado y lógica de la IU

La capa de la IU tiene dos partes: una dependiente y la otra independiente del ciclo de vida de la IU. Esta separación determina las fuentes de datos disponibles para cada parte y, por lo tanto, requiere diferentes tipos de estado y de lógica de la IU.

  • Capa independiente del ciclo de vida de la IU: Esta parte de la capa de la IU se ocupa de los datos que producen capas de la app (capas de datos o dominio) y la define la lógica empresarial. El ciclo de vida, los cambios de configuración y la recreación de la Activity en la IU pueden afectar el hecho de si la canalización de la producción del estado de la IU está activa, pero no afectan la validez de los datos producidos.
  • Capa dependiente del ciclo de vida de la IU: Esta parte de la capa de la IU se ocupa de la lógica de la IU, y los cambios en la configuración o el ciclo de vida la influencian de forma directa. Estos cambios afectan directamente la validez de las fuentes de datos que se leen dentro de la capa y, como consecuencia, su estado solo puede cambiar cuando está activo su ciclo de vida. Algunos ejemplos son los permisos de tiempo de ejecución y la obtención de recursos dependientes de la configuración, como strings localizadas.

La siguiente tabla contiene un resumen de lo anterior:

Capa independiente del ciclo de vida de la IU Capa dependiente del ciclo de vida de la IU
Lógica empresarial Lógica de la IU
Estado de la IU de la pantalla

Canalización de la producción del estado de la IU

La canalización de la producción del estado de la IU hace referencia a los pasos que se toman para producir el estado de la IU. Estos pasos comprenden la aplicación de los tipos de lógica definidos con anterioridad y dependen por completo de las necesidades de la IU. Algunas IUs pueden beneficiarse tanto de las partes de la canalización independientes del ciclo de vida de la IU como de las que dependen de él.

Es decir, las siguientes permutaciones de la canalización de la capa de la IU son válidas:

  • Estado de la IU producido y administrado por la propia IU Por ejemplo, un contador básico simple y reutilizable:

    @Composable
    fun Counter() {
        // The UI state is managed by the UI itself
        var count by remember { mutableStateOf(0) }
        Row {
            Button(onClick = { ++count }) {
                Text(text = "Increment")
            }
            Button(onClick = { --count }) {
                Text(text = "Decrement")
            }
        }
    }
    
  • Lógica de la IU → IU (por ejemplo, ocultar o mostrar un botón que permita al usuario saltar a la parte superior de una lista)

    @Composable
    fun ContactsList(contacts: List<Contact>) {
        val listState = rememberLazyListState()
        val isAtTopOfList by remember {
            derivedStateOf {
                listState.firstVisibleItemIndex < 3
            }
        }
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Show or hide the button (UI logic) based on the list scroll position
        AnimatedVisibility(visible = !isAtTopOfList) {
            ScrollToTopButton()
        }
    }
    
  • Lógica empresarial → IU (un elemento de la IU que muestra la foto del usuario actual en la pantalla)

    @Composable
    fun UserProfileScreen(viewModel: UserProfileViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        // Call on the UserAvatar Composable to display the photo
        UserAvatar(picture = uiState.profilePicture)
    }
    
  • Lógica empresarial → Lógica de la IU → IU (un elemento de la IU que se desplaza para mostrar la información correcta en la pantalla para un estado de IU determinado)

    @Composable
    fun ContactsList(viewModel: ContactsViewModel = hiltViewModel()) {
        // Read screen UI state from the business logic state holder
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
        val contacts = uiState.contacts
        val deepLinkedContact = uiState.deepLinkedContact
    
        val listState = rememberLazyListState()
    
        // Create the LazyColumn with the lazyListState
        ...
    
        // Perform UI logic that depends on information from business logic
        if (deepLinkedContact != null && contacts.isNotEmpty()) {
            LaunchedEffect(listState, deepLinkedContact, contacts) {
                val deepLinkedContactIndex = contacts.indexOf(deepLinkedContact)
                if (deepLinkedContactIndex >= 0) {
                  // Scroll to deep linked item
                  listState.animateScrollToItem(deepLinkedContactIndex)
                }
            }
        }
    }
    

En los casos en que se apliquen ambos tipos de lógica a la canalización de la producción del estado de la IU, la lógica empresarial siempre se debe aplicar antes que la lógica de la IU. Si intentaras aplicarla después de la lógica de la IU, esto implicaría que la lógica empresarial depende de la lógica de la IU. En las siguientes secciones, se explica por qué esto es un problema a través de una mirada profunda a los diferentes tipos de lógica y sus contenedores de estado.

Flujos de datos desde la capa de producción de datos hasta la IU
Figura 3: Aplicación de la lógica en la capa de la IU

Contenedores de estado y sus responsabilidades

La responsabilidad de un contenedor de estado es almacenar el estado para que la app pueda leerlo. En los casos en que se necesita lógica, actúa como un intermediario y proporciona acceso a las fuentes de datos que alojan la lógica requerida. De esta manera, el contenedor de estado delega la lógica a la fuente de datos adecuada.

Esto produce los siguientes beneficios:

  • IU simples: La IU solo vincula su estado.
  • Capacidad de mantenimiento: La lógica definida en el contenedor de estado se puede iterar sin cambiar la IU.
  • Capacidad de prueba: La IU y su lógica de producción del estado se pueden probar de forma independiente.
  • Legibilidad: Los lectores del código pueden ver con claridad las diferencias entre el código de presentación de la IU y el código de producción del estado de la IU.

Sin importar su tamaño o alcance, cada elemento de la IU tiene una relación de 1:1 con el contenedor de estado correspondiente. Además, un contenedor de estado debe poder aceptar y procesar cualquier acción del usuario que pueda generar un cambio de estado de la IU y debe producir el cambio de estado posterior.

Tipos de contenedores de estado

Al igual que con los tipos de estado y lógica de la IU, hay dos clases de contenedores de estado en la capa de la IU definidas por su relación con el ciclo de vida de la IU:

  • El contenedor de estado de lógica empresarial
  • El contenedor de estado de lógica de la IU

En las siguientes secciones, se analizan con más detalle los tipos de contenedores de estado. Comenzaremos con el de lógica empresarial.

Lógica empresarial y su contenedor de estado

Los contenedores de estado de lógica empresarial procesan eventos del usuario y transforman los datos de las capas de datos o dominio en el estado de la IU de la pantalla. Para proporcionar una experiencia del usuario óptima cuando se consideran el ciclo de vida de Android y los cambios en la configuración de la app, los contenedores de estado que usan la lógica empresarial deben tener las siguientes propiedades:

Property Detalle
Produce el estado de la IU Los contenedores de estado de lógica empresarial son responsables de producir el estado de la IU para sus IUs. Este estado de la IU suele ser el resultado del procesamiento de eventos del usuario y la lectura de datos de las capas de dominio y datos.
Se retiene mediante la recreación de la actividad. Los contenedores de estado de lógica empresarial retienen su estado y sus canalizaciones de procesamiento de estado en la recreación de Activity, lo que ayuda a brindar una experiencia del usuario fluida. En los casos en que el contenedor de estado no se pueda retener y se vuelva a crear (por lo general, después del cierre del proceso), el contenedor de estado debe poder recrear con facilidad su último estado para garantizar una experiencia del usuario coherente.
Posee un estado de larga duración Los contenedores de estado de lógica empresarial a menudo se usan para administrar el estado de los destinos de navegación. Como resultado, en general, conservan su estado en todos los cambios de navegación hasta que se quitan del gráfico de navegación.
Es único para su IU y no se puede reutilizar Los contenedores de estado de lógica empresarial suelen producir estados para una función determinada de la app, por ejemplo, un TaskEditViewModel o un TaskListViewModel y, por lo tanto, solo se aplican a esa función. El mismo contenedor de estado puede admitir estas funciones de la app en diferentes factores de forma. Por ejemplo, las versiones de la app para dispositivos móviles, TV y tablets pueden reutilizar el mismo contenedor de estado de lógica empresarial.

Por ejemplo, considera el destino de navegación del autor en la app de "Ahora en Android":

La app de Now in Android demuestra cómo un destino de navegación que representa una función principal de la app debería tener su propio contenedor de estado de lógica empresarial único.
Figura 4: La app de Now in Android

En calidad de contenedor de estado de lógica empresarial, AuthorViewModel produce el estado de la IU en este caso:

@HiltViewModel
class AuthorViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle,
    private val authorsRepository: AuthorsRepository,
    newsRepository: NewsRepository
) : ViewModel() {

    val uiState: StateFlow<AuthorScreenUiState> = …

    // Business logic
    fun followAuthor(followed: Boolean) {
      …
    }
}

Observa que AuthorViewModel tiene los atributos descritos anteriormente:

Property Detalle
Produce el AuthorScreenUiState El AuthorViewModel lee datos del AuthorsRepository y NewsRepository, y los usa para producir el AuthorScreenUiState. También aplica la lógica empresarial cuando el usuario desea seguir o dejar de seguir un Author delegando al elemento AuthorsRepository.
Tiene acceso a la capa de datos Se le pasa una instancia de AuthorsRepository y NewsRepository a su constructor, lo que le permite implementar la lógica empresarial para seguir un Author.
Sobrevive a la recreación de la Activity Debido a que se implementa con un ViewModel, se conservará durante la recreación rápida de una Activity. En el caso del cierre del proceso, se puede leer el objeto SavedStateHandle para proporcionar la cantidad mínima de información necesaria a fin de restablecer el estado de la IU desde la capa de datos.
Posee un estado de larga duración El alcance de ViewModel se define en el gráfico de navegación, por lo que, a menos que se quite el destino de autor de ese gráfico, el estado de la IU en el StateFlow de uiState permanecerá en la memoria. El uso de StateFlow también agrega el beneficio de hacer que la aplicación de la lógica empresarial que produce el estado sea diferida, ya que el estado solo se produce si hay un recopilador del estado de la IU.
Es única en su IU El AuthorViewModel solo se aplica al destino de navegación del autor y no se puede volver a usar en ningún otro lugar. Si existe alguna lógica empresarial que se vuelva a utilizar en los destinos de navegación, esa lógica empresarial debe encapsularse en un componente con alcance de capas de datos o dominio.

El ViewModel como contenedor de estado de lógica empresarial

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. 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 u otros motivos.
  • Está integrado con otras bibliotecas de Jetpack, como Hilt.

Lógica de la IU y su contenedor de estado

La lógica de la IU es aquella que opera en datos que proporciona la propia IU. Esto puede estar en el estado de los elementos de la IU o en fuentes de datos de la IU, como la API de permisos o Resources. Los contenedores de estado que usan la lógica de la IU suelen tener las siguientes propiedades:

  • Generan el estado de la IU y administran el estado de los elementos de la IU.
  • No permanecen vigentes después de la recreación de la Activity: Los contenedores de estado alojados en la lógica de la IU a menudo dependen de fuentes de datos de la IU, y tratar de retener esta información en los cambios de configuración muy frecuentemente ocasiona una fuga de memoria. Si los contenedores de estado necesitan datos para conservarlos durante los cambios de configuración, deben delegar a otro componente más adecuado para permanecer vigentes después de la recreación de la Activity. Por ejemplo, en Jetpack Compose, los estados de los elementos de la IU componibles creados con funciones remembered suelen delegar a rememberSaveable para preservar el estado durante la recreación de la Activity. Algunos ejemplos de esas funciones son rememberScaffoldState() y rememberLazyListState().
  • Tienen referencias a fuentes de datos con alcance de IU: Se puede hacer referencia a las fuentes de datos, como las APIs de ciclo de vida y Resources, y leerlas de forma segura, ya que el contenedor de estado de lógica de la IU tiene el mismo ciclo de vida que la IU.
  • Se pueden reutilizar en varias IUs: Es posible que se vuelvan a usar diferentes instancias del mismo contenedor de estado de lógica de la IU en diferentes partes de la app. Por ejemplo, se puede usar un contenedor de estado para administrar eventos de entrada del usuario para un grupo de chips en una página de búsqueda de chips de filtros y también para el campo "Para" de los destinatarios de un correo electrónico.

Por lo general, el contenedor de estado de lógica de la IU se implementa con una clase sin formato. Esto se debe a que la IU en sí es responsable de la creación del contenedor de estado de lógica de la IU y este tiene el mismo ciclo de vida que la IU en sí. En Jetpack Compose, por ejemplo, el contenedor de estado es parte de la composición y sigue su ciclo de vida.

Esto se puede ilustrar en el siguiente ejemplo de la muestra de Now in Android:

Now in Android usa un contenedor de estado de clase sin formato para administrar la lógica de la IU
Figura 5: La app de ejemplo de Now in Android

El ejemplo de Now in Android muestra una barra de la aplicación inferior o un riel para su navegación, según el tamaño de la pantalla del dispositivo. Las pantallas más pequeñas usan la barra de la aplicación inferior y las más grandes, el riel de navegación.

Dado que la lógica para decidir el elemento de la IU de navegación adecuado que se usa en la función de componibilidad NiaApp no depende de la lógica empresarial, se puede administrar con un contenedor de estado de clase sin formato llamado NiaAppState:

@Stable
class NiaAppState(
    val navController: NavHostController,
    val windowSizeClass: WindowSizeClass
) {

    // UI logic
    val shouldShowBottomBar: Boolean
        get() = windowSizeClass.widthSizeClass == WindowWidthSizeClass.Compact ||
            windowSizeClass.heightSizeClass == WindowHeightSizeClass.Compact

    // UI logic
    val shouldShowNavRail: Boolean
        get() = !shouldShowBottomBar

   // UI State
    val currentDestination: NavDestination?
        @Composable get() = navController
            .currentBackStackEntryAsState().value?.destination

    // UI logic
    fun navigate(destination: NiaNavigationDestination, route: String? = null) { /* ... */ }

     /* ... */
}

En el ejemplo anterior, se destacan los siguientes detalles sobre NiaAppState:

  • No permanece vigente después de la recreación de la Activity: NiaAppState es remembered en la composición mediante su creación con una función de componibilidad rememberNiaAppState que sigue las convenciones de nombres de Compose. Después de volver a crear la Activity, se pierde la instancia anterior y se crea una nueva con todas sus dependencias pasadas, lo que resulta adecuado para la nueva configuración de la Activity recreada. Estas dependencias pueden ser nuevas o restablecerse a partir de configuraciones anteriores. Por ejemplo, se usa rememberNavController() en el constructor NiaAppState y se delega a rememberSaveable para preservar el estado durante la recreación de Activity.
  • Tienen referencias a fuentes de datos con alcance de IU: Las referencias a navigationController, Resources y otros tipos de alcance del ciclo de vida similares se pueden conservar de forma segura en NiaAppState porque comparten el mismo alcance del ciclo de vida.

Cómo elegir entre un ViewModel y una clase sin formato para un contenedor de estado

En las secciones anteriores, elegir entre un ViewModel y un contenedor de estado de clase sin formato se reduce a analizar la lógica aplicada al estado de la IU y a las fuentes de datos en las que opera la lógica.

En resumen, el siguiente diagrama muestra la posición de los contenedores de estado en la canalización de la producción del estado de la IU:

Flujos de datos desde la capa de producción de datos hasta la capa de la IU
Figura 6: Contenedores de estado en la canalización de la producción del estado de la IU. Las flechas representan el flujo de datos.

En última instancia, debes producir el estado de la IU con contenedores de estado más cercanos al lugar en que se consume. De manera menos formal, debes mantener el estado lo más bajo posible sin perder la propiedad adecuada. Si necesitas acceso a la lógica empresarial y quieres que el estado de la IU persista mientras se pueda navegar a una pantalla, incluso durante la recreación de la Activity, un objeto ViewModel es una excelente elección para la implementación del contenedor de estado de lógica empresarial. En el caso de un estado y una lógica de la IU de menor duración, una clase sin formato cuyo ciclo de vida depende solo de la IU debería ser suficiente.

Los contenedores de estado se pueden combinar

Los contenedores de estado pueden depender de otros contenedores de estado, siempre que las dependencias tengan una vida útil igual o más corta. Estos son algunos ejemplos:

  • Un contenedor de estado de lógica de la IU puede depender de otro contenedor de estado de lógica de la IU.
  • Un contenedor de estado a nivel de la pantalla puede depender de un contenedor de estado de lógica de la IU.

En el siguiente fragmento de código, se muestra cómo el DrawerState de Compose depende de otro contenedor de estado interno, SwipeableState, y cómo el contenedor de estado de lógica de la IU de una app puede depender de DrawerState:

@Stable
class DrawerState(/* ... */) {
  internal val swipeableState = SwipeableState(/* ... */)
  // ...
}

@Stable
class MyAppState(
  private val drawerState: DrawerState,
  private val navController: NavHostController
) { /* ... */ }

@Composable
fun rememberMyAppState(
  drawerState: DrawerState = rememberDrawerState(DrawerValue.Closed),
  navController: NavHostController = rememberNavController()
): MyAppState = remember(drawerState, navController) {
  MyAppState(drawerState, navController)
}

Un ejemplo de una dependencia que puede sobrevivir a un contenedor de estado sería un contenedor de estado de lógica de la IU que dependiera de un contenedor de estado a nivel de la pantalla. Eso disminuiría la capacidad de reutilización del contenedor de estado de menor duración y le daría acceso a más lógica y estado de los que realmente necesita.

Si el contenedor de estado de menor duración necesita cierta información de un contenedor de estado de mayor alcance, pasa solo la información que necesita como parámetro en lugar de pasar la instancia del contenedor de estado. Por ejemplo, en el siguiente fragmento de código, la clase de contenedor de estado de lógica de la IU recibe solo lo que necesita como parámetros de ViewModel, en lugar de pasar toda la instancia de ViewModel como dependencia.

class MyScreenViewModel(/* ... */) {
  val uiState: StateFlow<MyScreenUiState> = /* ... */
  fun doSomething() { /* ... */ }
  fun doAnotherThing() { /* ... */ }
  // ...
}

@Stable
class MyScreenState(
  // DO NOT pass a ViewModel instance to a plain state holder class
  // private val viewModel: MyScreenViewModel,

  // Instead, pass only what it needs as a dependency
  private val someState: StateFlow<SomeState>,
  private val doSomething: () -> Unit,

  // Other UI-scoped types
  private val scaffoldState: ScaffoldState
) {
  /* ... */
}

@Composable
fun rememberMyScreenState(
  someState: StateFlow<SomeState>,
  doSomething: () -> Unit,
  scaffoldState: ScaffoldState = rememberScaffoldState()
): MyScreenState = remember(someState, doSomething, scaffoldState) {
  MyScreenState(someState, doSomething, scaffoldState)
}

@Composable
fun MyScreen(
  modifier: Modifier = Modifier,
  viewModel: MyScreenViewModel = viewModel(),
  state: MyScreenState = rememberMyScreenState(
    someState = viewModel.uiState.map { it.toSomeState() },
    doSomething = viewModel::doSomething
  ),
  // ...
) {
  /* ... */
}

En el siguiente diagrama, se representan las dependencias entre la IU y los diferentes contenedores de estado del fragmento de código anterior:

La IU depende tanto del contenedor de estado de lógica de la IU como del de nivel de la pantalla
Figura 7: La IU depende de diferentes contenedores de estado. Las flechas representan las dependencias.

Ejemplos

En los siguientes ejemplos de Google, se demuestra el uso de los contenedores de estado en la capa de la IU. Explóralos para ver esta guía en práctica: