Capa de la IU

La función de la IU es mostrar los datos de la aplicación en la pantalla. La IU también sirve como punto principal de interacción del usuario. Cuando los datos cambian, ya sea debido a la interacción del usuario (como cuando presiona un botón) o una entrada externa (como una respuesta de red), la IU se actualiza para reflejar los cambios. En efecto, la IU es una representación visual del estado de la aplicación tal como se recuperó de la capa de datos.

Sin embargo, los datos de aplicación que obtienes de la capa de datos suelen estar en un formato diferente al de la información que necesitas mostrar. Por ejemplo, es posible que solo necesites parte de los datos de la IU, o bien que debas combinar dos fuentes de datos diferentes para presentar información que al usuario le resulte relevante. Sin importar la lógica que apliques, debes pasarle a la IU toda la información que necesita para renderizarse por completo. La capa de IU es la canalización que convierte los cambios en los datos de la aplicación en un formato que la IU puede presentar y, luego, mostrar.

En una arquitectura típica, los elementos de la IU de la capa de la IU dependen de los contenedores de estado, que, a su vez, dependen de las clases de la capa de datos o de la capa opcional de dominio.
Figura 1: El rol de la capa de la IU en la arquitectura de la app.

Caso de éxito básico

Piensa en una app que recupera artículos de noticias para que los lea un usuario. La app tiene una pantalla de artículos que presenta los artículos que están disponibles para leer y también les permite a los usuarios que accedan marcar como favoritos los que más les interesan. Dado que puede haber muchos artículos en cualquier momento, el lector debe poder explorar por categoría. En resumen, la app les permite a los usuarios hacer lo siguiente:

  • Ver artículos disponibles para leer
  • Explorar artículos por categoría
  • Acceder y marcar artículos como favoritos
  • Acceder a algunas funciones premium si cumplen con los requisitos correspondientes
Una app de noticias de ejemplo que muestra vistas previas de artículos, uno de los cuales está marcado.
Figura 2: Una aplicación de noticias de muestra para un caso de éxito de la IU

En las siguientes secciones, se usa este ejemplo como caso de éxito en el que se presentan los principios del flujo unidireccional de datos y se ilustran los problemas que estos principios ayudan a resolver en el contexto de la arquitectura de la app para la capa de la IU.

Arquitectura de la capa de la IU

El término IU hace referencia a los elementos de la IU, como los contenedores y las funciones de componibilidad, que muestran datos. Para compilar IU de Android, se recomienda el kit de herramientas Jetpack Compose. Debido a que la función de la capa de datos es retener, administrar y proporcionar acceso a los datos de la app, la capa de la IU debe realizar los siguientes pasos:

  1. Consumir los datos de la app y transformarlos en datos que la IU pueda renderizar con facilidad
  2. Consumir los datos que se pueden renderizar en la IU y transformarlos en elementos de la IU para presentarlos al usuario
  3. Consumir eventos de entrada del usuario a partir de esos elementos de la IU ensamblados y reflejar sus efectos en los datos de la IU según sea necesario
  4. Repetir los pasos 1 al 3 durante el tiempo que sea necesario

En el resto de esta guía, se muestra cómo implementar una capa de IU que realice estos pasos. En particular, en esta guía, se abarcan las siguientes tareas y conceptos:

  • Cómo definir el estado de la IU
  • El flujo unidireccional de datos (UDF) como medio para producir y administrar el estado de la IU
  • Cómo exponer el estado de la IU con tipos de datos observables según los principios del flujo unidireccional de datos
  • Cómo implementar una IU que consuma el estado observable de la IU

El más importante de todos es la definición del estado de la IU.

Cómo definir el estado de la IU

En el caso de éxito que se describió anteriormente, la IU muestra una lista de artículos junto con algunos metadatos para cada uno. Esta información que la app presenta al usuario es el estado de la IU.

En otras palabras, si la IU es lo que ve el usuario, el estado de la IU es lo que la app dice que debería ver. Al igual que dos caras de una moneda, la IU es la representación visual del estado de la IU. Cualquier cambio en el estado de la IU se refleja de inmediato en la IU.

La IU es el resultado de la vinculación de sus elementos en la pantalla con el estado correspondiente.
Figura 3: La IU es el resultado de la vinculación de sus elementos en la pantalla con el estado correspondiente.

Considera este caso de éxito: para cumplir con los requisitos de la app de Noticias, la información necesaria para renderizar por completo la IU se puede encapsular en una clase de datos NewsUiState definida de la siguiente manera:

data class NewsUiState(
    val isSignedIn: Boolean = false,
    val isPremium: Boolean = false,
    val newsItems: List<NewsItemUiState> = listOf(),
    val userMessages: List<Message> = listOf()
)

data class NewsItemUiState(
    val title: String,
    val body: String,
    val bookmarked: Boolean = false,
    ...
)

Para obtener más información sobre el estado de la IU, consulta Estado y Jetpack Compose.

Inmutabilidad

La definición del estado de la IU en el ejemplo anterior es inmutable. El beneficio clave de esto es que los objetos inmutables proporcionan garantías sobre el estado de la aplicación en un momento determinado. De esta manera, se libera la IU para enfocarse en su función principal: leer el estado y actualizar sus elementos según corresponda. Nunca modifiques el estado de la IU directamente en la IU, a menos que la IU sea la única fuente de datos. Infringir este principio genera varias fuentes verídicas para la misma información, lo que genera inconsistencias en los datos y errores leves.

Por ejemplo, considera el caso de éxito anterior. Si la marca bookmarked en un objeto NewsItemUiState del estado de la IU se actualiza en la clase Activity, esa marca compite con la capa de datos como fuente del estado "agregado a favoritos" de un artículo. Las clases de datos inmutables son muy útiles para evitar este tipo de incoherencias.

Convenciones de nombres en esta guía

En esta guía, las clases de estado de la IU se nombran según la funcionalidad de la pantalla o parte de la pantalla que describen. La convención es la siguiente:

funcionalidad + UiState

Por ejemplo, el estado de una pantalla que muestra noticias podría llamarse NewsUiState, y el estado de un elemento de noticias en una lista de noticias podría ser NewsItemUiState.

Cómo administrar el estado con un flujo unidireccional de datos

En la sección anterior, se estableció que el estado de la IU es una instantánea inmutable de los detalles necesarios para la renderización de la IU. Sin embargo, debido a la naturaleza dinámica de los datos en las apps, el estado puede cambiar con el tiempo. Esto podría deberse a la interacción del usuario o a otros eventos que modifiquen los datos subyacentes que se usan para propagar la app.

Estas interacciones pueden beneficiarse de un mediador para procesarlas, definir la lógica que se aplicará a cada evento y transformar las fuentes de datos de respaldo para crear el estado de la IU. Si bien estas interacciones y su lógica pueden estar alojadas en la IU, podrían volverse difíciles de controlar cuando la IU asume demasiada responsabilidad. Además, esto puede afectar la capacidad de prueba porque el código resultante está muy acoplado. A menos que el estado de la IU sea muy simple, asegúrate de que la única responsabilidad de la IU sea el consumo y la visualización del estado de la IU.

En esta sección, se analiza el flujo unidireccional de datos, un patrón de arquitectura que ayuda a aplicar esta separación saludable de responsabilidades.

Contenedores de estado

Los contenedores de estado son las clases responsables de producir el estado de la IU y la lógica necesaria para producir ese estado. Los contenedores de estado están disponibles en varios 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 o un destino de navegación.

En el último caso, la implementación típica es una instancia de un ViewModel, aunque, según los requisitos de la aplicación, una clase simple puede ser suficiente. La app de Noticias del caso de éxito, por ejemplo, usa una clase NewsViewModel como contenedor de estado para producir el estado de la IU de la pantalla que se muestra en esa sección.

Hay muchas maneras de modelar la codependencia entre la IU y su productor de estado. Sin embargo, debido a que la interacción entre la IU y su clase ViewModel se puede comprender en gran medida como una entrada de evento y su resultado de estado posterior, la relación se puede representar como se muestra en el siguiente diagrama:

Los datos de la aplicación fluyen desde la capa de datos hasta el ViewModel. El estado de la IU fluye desde el ViewModel hasta los elementos de la IU, y los eventos fluyen de los elementos de la IU de vuelta al ViewModel.
Figura 4: Diagrama de cómo funciona el flujo unidireccional de datos en la arquitectura de la app

El patrón en el que el estado fluye hacia abajo y los eventos fluyen hacia arriba se denomina flujo unidireccional de datos. Las implicaciones de este patrón para la arquitectura de la app son las siguientes:

  • ViewModel conserva y expone el estado que consumirá la IU. El estado de la IU son los datos de aplicación que transforma ViewModel.
  • La IU notifica al ViewModel los eventos de usuario.
  • ViewModel controla las acciones del usuario y actualiza el estado.
  • El estado actualizado se envía a la IU para su renderización.
  • El proceso anterior se repite para cualquier evento que cause una mutación del estado.

Para pantallas o destinos de navegación, ViewModel funciona con repositorios o clases de caso de uso para obtener datos y transformarlos en el estado de la IU, a la vez que incorpora los efectos de los eventos que pueden causar mutaciones del estado. El caso de éxito mencionado anteriormente contiene una lista de artículos, cada uno con un título, una descripción, una fuente, un nombre de autor, la fecha de publicación y si se agregó a favoritos. La IU de cada elemento de artículo se ve así:

Un solo elemento de artículo de la app del caso de éxito. La IU muestra una miniatura, el título del artículo, el autor, el tiempo de lectura estimado del artículo y un ícono de favorito.
Figura 5: IU de un elemento de artículo en la app del caso de éxito

Un usuario que solicita agregar un artículo a favoritos es un ejemplo de un evento que puede generar mutaciones del estado. Como productor de estado, la responsabilidad del ViewModel es definir toda la lógica necesaria para propagar todos los campos en el estado de la IU y procesar los eventos necesarios para que la IU se renderice por completo.

Un evento de IU se produce cuando el usuario agrega un artículo a favoritos. ViewModel notifica a la capa de datos sobre el cambio de estado. La capa de datos conserva los cambios de datos y actualiza los datos de la aplicación. Los datos de app nuevos con el artículo agregado a favoritos se pasan al ViewModel, que produce el nuevo estado de IU y lo pasa a los elementos de la IU para su visualización.
Figura 6: Diagrama que ilustra el ciclo de eventos y datos en el flujo unidireccional de datos

En las siguientes secciones, se analizan con más detalle los eventos que causan cambios de estado y cómo se pueden procesar mediante un flujo unidireccional de datos.

Tipos de lógica

Agregar un artículo a favoritos es un ejemplo de lógica empresarial porque aporta valor a tu app. Para obtener más información, consulta la página sobre capa de datos. Sin embargo, hay distintos tipos de lógica que es importantes definir:

  • La lógica empresarial es la implementación de los requisitos del producto para los datos de app. Como ya se mencionó, un ejemplo es agregar a favoritos un artículo en la app del caso de éxito. Por lo general, la lógica empresarial se ubica en las capas de dominio o datos, pero nunca en la capa de la IU.
  • La lógica de comportamiento de la IU o lógica de la IU es cómo se muestran los cambios de estado en la pantalla. Algunos ejemplos incluyen obtener el texto adecuado para mostrar en la pantalla con Android Resources, navegar a una pantalla en particular cuando el usuario hace clic en un botón o mostrar un mensaje en pantalla con un aviso o una barra de notificaciones.

Mantén la lógica de la IU en la IU, no en el ViewModel, en especial cuando implica tipos de IU como Context. Si la IU aumenta su complejidad y deseas delegar su lógica a otra clase para favorecer la capacidad de prueba y la separación de los problemas, puedes crear una clase simple como contenedor de estado. Las clases simples creadas en la IU pueden tomar dependencias del SDK de Android porque siguen el ciclo de vida de la IU. Los objetos del ViewModel tienen una vida útil más larga.

Para obtener más información sobre los contenedores de estado y cómo se ajustan al contexto de la ayuda de la IU de compilación, consulta la Guía de estado de Jetpack Compose.

¿Por qué usar el flujo unidireccional de datos?

El flujo unidireccional de datos modela el ciclo de producción del estado, como se muestra en la Figura 4. También separa el lugar donde se originan los cambios de estado, el lugar en el que se transforman y el lugar donde se consumen. Esta separación permite que la IU haga exactamente lo que su nombre implica: mostrar información observando cambios de estado y retransmitiendo la intención del usuario pasando esos cambios al ViewModel.

En otras palabras, el flujo unidireccional de datos permite lo siguiente:

  • Coherencia de los datos. Hay una sola fuente de información para la IU.
  • Capacidad de realizar pruebas. Como la fuente de estado está aislada, se puede probar de forma independiente de la IU.
  • Capacidad de mantenimiento. La mutación del estado sigue un patrón bien definido en el que las mutaciones son el resultado de los eventos de un usuario y las fuentes de datos con las que trabajan.

Cómo exponer el estado de la IU

Después de definir el estado de tu IU y determinar cómo administrarás la producción de ese estado, el siguiente paso es presentar el estado producido en la IU.

Cuando usas un flujo unidireccional de datos para administrar la producción del estado, puedes considerar que el estado producido es una transmisión. Es decir que se generarán varias versiones del estado a lo largo del tiempo. Expón el estado de la IU en un contenedor de datos observables, como StateFlow. Esto permite que la IU reaccione a cualquier cambio realizado en el estado sin tener que extraer de forma manual los datos directamente desde el ViewModel. Esto también ofrece el beneficio de tener siempre la versión más reciente del estado de la IU almacenada en caché, lo cual es útil para realizar un restablecimiento rápido del estado después de cambios de configuración.

class NewsViewModel(...) : ViewModel() {

    val uiState: NewsUiState = 
}

Para obtener una introducción a los flujos de Kotlin, consulta Flujos de Kotlin en Android. Para obtener información sobre cómo usar StateFlow como un contenedor de datos observable, consulta el codelab Efectos secundarios y estados avanzados en Jetpack Compose.

Cuando los datos expuestos a la IU son relativamente simples, vale la pena unirlos en un tipo de estado de IU porque así se transmite la relación entre la emisión del contenedor de estado y su pantalla o elemento de la IU asociados. A medida que el elemento de la IU se vuelve más complejo, es sencillo agregarlo a la definición del estado de la IU, por lo que puedes adaptarlo a la información adicional necesaria para renderizar ese elemento.

Una forma común de crear una transmisión de UiState es exponer una propiedad mutableStateOf con un private set, lo que mantiene el estado mutable dentro de ViewModel, pero de solo lectura para la IU.

class NewsViewModel(...) : ViewModel() {

    var uiState by mutableStateOf(NewsUiState())
        private set

    ...
}

ViewModel puede exponer los métodos que mutan de forma interna el estado, lo que publica actualizaciones para que consuma la IU. Tomemos, por ejemplo, el caso en el que necesitas realizar una acción asíncrona. Puedes iniciar una corrutina con viewModelScope y, luego, actualizar el estado mutable cuando se complete.

class NewsViewModel(
    private val repository: NewsRepository,
    ...
) : ViewModel() {

    var uiState by mutableStateOf(NewsUiState())
        private set

    private var fetchJob: Job? = null

    fun fetchArticles(category: String) {
        fetchJob?.cancel()
        fetchJob = viewModelScope.launch {
            try {
                val newsItems = repository.newsItemsForCategory(category)
                uiState = uiState.copy(newsItems = newsItems)
            } catch (ioe: IOException) {
                // Handle the error and notify the UI when appropriate.
                val messages = getMessagesFromThrowable(ioe)
                uiState = uiState.copy(userMessages = messages)
            }
        }
    }
}

En el ejemplo anterior, la clase NewsViewModel intenta recuperar artículos para una categoría determinada y, luego, refleja el resultado del intento (ya sea éxito o error) en el estado de la IU, en el que la IU puede reaccionar de forma adecuada. Para obtener más información sobre el manejo de errores, consulta la sección Mostrar errores en la pantalla.

Consideraciones adicionales

Además de la guía anterior, ten en cuenta lo siguiente cuando expongas el estado de la IU:

  • Usa un solo objeto de estado de la IU para controlar los estados relacionados entre sí. Esta implementación genera menos inconsistencias y facilita la comprensión del código. Si expones la lista de elementos de noticias y la cantidad de favoritos en dos transmisiones diferentes, podrías terminar en una situación en la que una se actualice y la otra no. Si usas una sola transmisión, todos los elementos se mantendrán actualizados. Además, parte de la lógica empresarial podría requerir una combinación de fuentes. Por ejemplo, es posible que debas mostrar un botón de favoritos solo si el usuario accede a su cuenta y está suscrito a un servicio de noticias premium. Puedes definir una clase de estado de IU de la siguiente manera:

    data class NewsUiState(
        val isSignedIn: Boolean = false,
        val isPremium: Boolean = false,
        val newsItems: List<NewsItemUiState> = listOf()
    )
    
    val NewsUiState.canBookmarkNews: Boolean get() = isSignedIn && isPremium
    

    En esta declaración, la visibilidad del botón de favoritos es una propiedad derivada de otras dos. A medida que la lógica empresarial se vuelve más compleja, tener una clase UiState singular en la que todas las propiedades estén disponibles de inmediato se vuelve cada vez más importante.

  • Estados de la IU: ¿una o varias transmisiones? El principio guía clave para elegir entre exponer el estado de la IU en una sola o en varias transmisiones es la relación entre los elementos emitidos. Las mayores ventajas de una exposición de una sola transmisión son la comodidad y la coherencia de los datos: los consumidores de estado siempre tienen la información más reciente disponible en cualquier momento. Sin embargo, hay instancias en las que tener transmisiones de estado separadas del ViewModel podría ser apropiado:

    • Tipos de datos no relacionados: Algunos estados necesarios para procesar la IU podrían ser completamente independientes entre sí. En casos como estos, los costos de agrupar estos estados dispares podrían superar los beneficios, en especial si uno de estos estados se actualiza con más frecuencia que otro.

    • Diffing de UiState: Cuantos más campos haya en un objeto UiState, más probable será que la transmisión emita contenido como resultado de la actualización en uno de sus campos. Debido a que los elementos de la IU no tienen un mecanismo de diferenciación para comprender si las emisiones consecutivas son distintas o iguales, cada emisión genera una actualización para el elemento de la IU. Por lo tanto, podría requerirse una mitigación con los métodos de la API de Flow, como distinctUntilChanged().

Para obtener más información sobre la renderización y el estado de la IU, consulta Ciclo de vida de los elementos componibles.

Cómo consumir el estado de la IU

Para consumir el flujo de objetos UiState en la IU, usa el operador de terminal para el tipo de datos observable que usas. Por ejemplo, para flujos de Kotlin, usa el método collect() o sus variantes.

Cuando uses contenedores de datos observables en la IU, asegúrate de tener en cuenta el ciclo de vida de la IU. No hagas que la IU observe el estado de la IU cuando el elemento componible no se muestre al usuario. Para obtener más información sobre este tema, consulta esta entrada de blog. Cuando se usan flujos, es mejor controlar los asuntos del ciclo de vida con el permiso de corrutina adecuado y la API de collectAsStateWithLifecycle:

@Composable
private fun ConversationScreen(
    conversationViewModel: ConversationViewModel = viewModel()
) {

    val messages by conversationViewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = { message: Message -> conversationViewModel.sendMessage(message) }
    )
}

@Composable
private fun ConversationScreen(
    messages: List<Message>,
    onSendMessage: (Message) -> Unit
) {

    MessagesList(messages, onSendMessage)
    /* ... */
}

Cómo mostrar las operaciones en curso

Una forma sencilla de representar estados de carga en una clase UiState es con un campo booleano:

data class NewsUiState(
    val isFetchingArticles: Boolean = false,
    ...
)

El valor de esta marca representa la presencia o ausencia de una barra de progreso en la IU.

@Composable
fun LatestNewsScreen(
    modifier: Modifier = Modifier,
    viewModel: NewsViewModel = viewModel()
) {
    Box(modifier.fillMaxSize()) {

        if (viewModel.uiState.isFetchingArticles) {
            CircularProgressIndicator(Modifier.align(Alignment.Center))
        }

        // Add other UI elements. For example, the list.
    }
}

Cómo mostrar errores en la pantalla

Mostrar errores en la IU es similar a mostrar operaciones en curso porque ambos se representan fácilmente con valores booleanos que denotan su presencia o ausencia. Sin embargo, los errores también pueden incluir un mensaje asociado para retransmitir al usuario, o bien para una acción asociada con ellos que reintente la operación fallida. Por lo tanto, mientras una operación en curso se carga o no, es posible que los estados de error necesiten modelarse con clases de datos que alojen los metadatos adecuados para el contexto de ese error.

Considera el ejemplo anterior que mostraba una barra de progreso mientras se recuperaban los artículos. Si esta operación da como resultado un error, es posible que quieras mostrar uno o más mensajes al usuario en los que se detalle lo que salió mal.

data class Message(val id: Long, val message: String)

data class NewsUiState(
    val userMessages: List<Message> = listOf(),
    ...
)

Luego, puedes presentar los mensajes de error al usuario en forma de elementos de la IU, como barras de notificaciones. Para obtener más información sobre cómo se producen y consumen los eventos de la IU, consulta Eventos de la IU.

Subprocesos y simultaneidad

Asegúrate de que todo el trabajo que se realice en un ViewModel sea seguro para el subproceso principal, es decir, que se pueda llamar desde el subproceso principal. Las capas de datos y dominio son responsables de mover el trabajo a un subproceso diferente.

Si un ViewModel realiza operaciones de larga duración, también es responsable de mover esa lógica a un subproceso en segundo plano. Las corrutinas de Kotlin son una excelente manera de administrar operaciones simultáneas, y los componentes de la arquitectura de Jetpack proporcionan compatibilidad integrada. Para obtener más información sobre el uso de corrutinas en apps para Android, consulta Corrutinas de Kotlin en Android.

Los cambios en la navegación de las apps suelen deberse a las emisiones que parecen eventos. Por ejemplo, después de que una clase SignInViewModel realiza un acceso, el UiState puede tener un campo isSignedIn establecido en true. Consume activadores como estos tal como los que se abordan en la sección anterior Cómo consumir el estado de la IU, pero remite la implementación del consumo al componente Navigation.

Para obtener más información sobre la navegación por la IU, consulta Navigation 3.

Paging

La biblioteca de Paging se consume en la IU con un tipo llamado PagingData. Debido a que PagingData representa y contiene elementos que pueden cambiar con el tiempo (es decir, no es un tipo inmutable), no lo representes en un estado de IU inmutable. En cambio, debes exponer estos datos desde el ViewModel de forma independiente en su propia transmisión.

En el siguiente ejemplo, se muestra la API de Compose de la biblioteca de Paging:

@Composable
fun MyScreen(flow: Flow<PagingData<String>>) {
    val lazyPagingItems = flow.collectAsLazyPagingItems()
    LazyColumn {
        items(
            lazyPagingItems.itemCount,
            key = lazyPagingItems.itemKey { it }
        ) { index ->
            val item = lazyPagingItems[index]
            Text("Item is $item")
        }
    }
}

Animaciones

Para proporcionar transiciones de navegación de primer nivel fluidas, puedes esperar a que la segunda pantalla cargue los datos antes de iniciar la animación.

Para obtener más información sobre las transiciones de navegación, consulta Navigation 3 y Transiciones de elementos compartidos en Compose.

Recursos adicionales

Mira contenido

Ejemplos

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