Cómo crear la arquitectura de tu IU de Compose

En Compose, la IU es inmutable: no hay forma de actualizarla después de que se la dibuja. Lo que puedes controlar es el estado de tu IU. Cada vez que cambia el estado de la IU, Compose vuelve a crear las partes del árbol de IU que cambiaron. Los elementos componibles pueden aceptar estados y exponer eventos; por ejemplo, un TextField acepta un valor y expone una devolución de llamada de onValueChange que solicita al controlador de devolución de llamada que cambie el valor.

var name by remember { mutableStateOf("") }
OutlinedTextField(
    value = name,
    onValueChange = { name = it },
    label = { Text("Name") }
)

Debido a que los elementos componibles aceptan estados y exponen eventos, el patrón de flujo de datos unidireccional se adapta bien a Jetpack Compose. Esta guía se centra en cómo implementar el patrón de flujo de datos unidireccional en Compose, cómo implementar eventos y contenedores de estados, y cómo trabajar con ViewModels en Compose.

Flujo de datos unidireccional

Un flujo de datos unidireccional (FDU) es un patrón de diseño en el que el estado circula hacia abajo y los eventos hacia arriba. Si sigues el flujo unidireccional de datos, podrás separar los elementos componibles que muestran el estado de la IU respecto de las partes de la app que almacenan y cambian el estado.

El bucle de actualización de la IU de una app que usa un flujo unidireccional de datos se ve de la siguiente manera:

  • Evento: Parte de la IU genera un evento y lo pasa hacia arriba, como un clic en un botón que se pasa al ViewModel para que lo procese, o un evento que se pasa desde otras capas de tu app, como para indicar que caducó la sesión del usuario.
  • Estado de actualización: Un controlador de evento puede cambiar el estado.
  • Estado de visualización: El contenedor hace circular el estado hacia abajo, y la IU lo muestra.

Flujo de datos unidireccional

Seguir ese patrón cuando usas Jetpack Compose tiene varias ventajas:

  • Capacidad de prueba: El estado de desacoplación de la IU que la muestra facilita la prueba por separado de ambos.
  • Encapsulación de estado: Como el estado solo se puede actualizar en un lugar y solamente hay una fuente de verdad para el estado de un elemento componible, es menos probable que se generen errores por estados incoherentes.
  • Coherencia de la IU: Todas las actualizaciones de estado se reflejan de inmediato en la IU mediante el uso de contenedores de estado observables como LiveData o StateFlow.

Flujo de datos unidireccional en Jetpack Compose

Los resultados funcionan según el estado y los eventos. Por ejemplo, un TextField solo se actualiza cuando se actualiza su parámetro value y expone una devolución de llamada de onValueChange, un evento que solicita el valor que cambia por uno nuevo. Compose define el objeto State como un contenedor de valor, y los cambios en el valor del estado activan una recomposición. Puedes mantener el estado en un remember { mutableStateOf(value) } o en un rememberSaveable { mutableStateOf(value) según el tiempo que necesites recordar el valor.

El tipo del valor del elemento componible TextField es String, por lo que puede provenir de cualquier lugar: desde un valor codificado, desde un ViewModel o desde el elemento componible superior. No es necesario que lo conserves en un objeto State, pero debes actualizar el valor cuando se llama a onValueChange.

Cómo definir parámetros de elementos componibles

Cuando defines los parámetros de estado de un elemento componible, debes tener en cuenta las siguientes preguntas:

  • ¿Qué tan reutilizable o flexible es el elemento componible?
  • ¿Cómo afectan los parámetros de estado al rendimiento de este elemento componible?

Para incentivar la separación y la reutilización, cada elemento componible debe contener la menor cantidad de información posible. Por ejemplo, cuando se compila un elemento componible para retener el encabezado de un artículo de noticias, prioriza pasar únicamente la información que se debe mostrar, en lugar de todo el artículo de noticias:

@Composable
fun Header(title: String, subtitle: String) {
    // Recomposes when title or subtitle have changed.
}

@Composable
fun Header(news: News) {
    // Recomposes when a new instance of News is passed in.
}

A veces, el uso de parámetros individuales también mejora el rendimiento. Por ejemplo, si News contiene más información que solo title y subtitle, siempre que se pase una nueva instancia de News a Header(news), el elemento componible se volverá a componer, incluso si title y subtitle no cambiaron.

Considera cuidadosamente la cantidad de parámetros que pasas. Tener una función con demasiados parámetros disminuye la ergonomía de la función, por lo que, en este caso, se agrupan en una clase.

Eventos en Compose

Cada entrada a tu app debe representarse como un evento: toques, cambios de texto y hasta temporizadores u otras actualizaciones. A medida que estos eventos cambian el estado de tu IU, ViewModel debería ser el que procese y actualice el estado de la IU.

La capa de IU nunca debería cambiar fuera del controlador de eventos porque eso puede generar inconsistencias y errores en tu aplicación.

Se recomienda pasar valores inmutables para lambdas del controlador de estados y eventos. Este enfoque tiene los siguientes beneficios:

  • Mejoras la capacidad de reutilización.
  • Garantizas que la IU no cambie el valor del estado de forma directa.
  • Evitas problemas de simultaneidad porque te aseguras de que el estado no haya mutado de otro subproceso.
  • A menudo, reduces la complejidad del código.

Por ejemplo, un elemento componible que acepta una String y una lambda como parámetros se puede llamar desde muchos contextos y es altamente reutilizable. Supongamos que la barra superior de tu app siempre muestra el texto y tiene un botón Atrás. Puedes definir un elemento componible MyAppTopAppBar más genérico que reciba el texto y el controlador del botón Atrás como parámetros:

@Composable
fun MyAppTopAppBar(topAppBarText: String, onBackPressed: () -> Unit) {
    TopAppBar(
        title = {
            Text(
                text = topAppBarText,
                textAlign = TextAlign.Center,
                modifier = Modifier
                    .fillMaxSize()
                    .wrapContentSize(Alignment.Center)
            )
        },
        navigationIcon = {
            IconButton(onClick = onBackPressed) {
                Icon(
                        Icons.Filled.ArrowBack,
                        contentDescription = localizedString
                    )
            }
        },
        // ...
    )
}

ViewModels, estados y eventos: un ejemplo

Si usas ViewModel y mutableStateOf, también puedes ingresar un flujo de datos unidireccional en tu app si se cumple una de las siguientes condiciones:

  • El estado de tu IU se expone a través de LiveData como la implementación del contenedor de estado observable.
  • ViewModel controla los eventos que provienen de la IU y otras capas de tu app, y actualiza el contenedor del estado según los eventos.

Por ejemplo, cuando implementas una pantalla de acceso, presionar un botón Acceder debería hacer que tu app muestre un ícono giratorio de progreso y una llamada de red. Si el acceso fue exitoso, tu app navega a otra pantalla. En caso de que se produzca un error, la app muestra un objeto Snackbar. A continuación, se muestra cómo modelarías el estado de la pantalla y el evento:

La pantalla tiene cuatro estados:

  • Salir: El usuario aún no accedió.
  • En curso: Cuando tu app intenta acceder a la cuenta del usuario mediante una llamada de red.
  • Error: Se produjo un error al acceder a la cuenta.
  • Accedió: El usuario accedió correctamente.

Puedes modelar estos estados como una clase sellada. ViewModel expone el estado como un State, establece el estado inicial y lo actualiza según sea necesario. El ViewModel también maneja el evento de acceso exponiendo un método onSignIn().

sealed class UiState {
    object SignedOut : UiState()
    object InProgress : UiState()
    object Error : UiState()
    object SignIn : UiState()
}

class MyViewModel : ViewModel() {
    private val _uiState = mutableStateOf<UiState>(SignedOut)
    val uiState: State<UiState>
        get() = _uiState

    // ...
}

Además de la API de mutableStateOf, Compose proporciona extensiones para LiveData, Flow y Observable a fin de registrarse como un objeto de escucha y representar el valor como un estado.

class MyViewModel : ViewModel() {
    private val _uiState = MutableLiveData<UiState>(SignedOut)
    val uiState: LiveData<UiState>
        get() = _uiState

    // ...
}

@Composable
fun MyComposable(viewModel: MyViewModel) {
    val uiState = viewModel.uiState.observeAsState()
    // ...
}