Cómo pensar en Compose

Jetpack Compose es un moderno kit de herramientas declarativas de IU para Android. Compose facilita la escritura y el mantenimiento de la IU de tu app, y proporciona una API declarativa que te permite renderizarla sin cambiar las vistas del frontend de manera imperativa. Si bien tenemos que explicar un poco más estos términos, las consecuencias son importantes para el diseño de tu app.

El paradigma de programación declarativa

Históricamente, una jerarquía de vistas de Android se ha representado como un árbol de widgets de IU. A medida que cambia el estado de la app debido a, por ejemplo, interacciones del usuario, se debe actualizar la jerarquía de la IU para mostrar los datos actuales. La forma más común de actualizar la IU es recorrer el árbol con funciones como findViewById() y cambiar los nodos mediante llamadas a métodos como button.setText(String), container.addChild(View) o img.setImageBitmap(Bitmap). Esos métodos cambian el estado interno del widget.

Manipular las vistas de forma manual aumenta la probabilidad de errores. Si un dato se procesa en varios lugares, es fácil olvidarse de actualizar una de las vistas que lo muestran. También es fácil crear estados ilegales, en los que dos actualizaciones entran en conflicto de manera inesperada. Por ejemplo, una actualización podría intentar establecer un valor para un nodo que se acaba de quitar de la IU. Por lo general, la complejidad del mantenimiento de software aumenta con la cantidad de vistas que deben actualizarse.

En los últimos años, toda la industria comenzó a migrar a un modelo de IU declarativa, lo que simplifica mucho la ingeniería relacionada con la compilación y la actualización de interfaces de usuario. La técnica funciona mediante la regeneración conceptual de toda la pantalla desde cero y, luego, aplicando solo los cambios necesarios. Ese enfoque evita la complejidad de actualizar de forma manual una jerarquía de vistas con estado. Compose es un framework de IU declarativa.

Uno de los desafíos que plantea regenerar toda la pantalla es que puede ser costoso en términos de tiempo, potencia informática y uso de batería. Para mitigar este costo, Compose elige de manera inteligente qué partes de la IU deben volver a dibujarse en un momento determinado. Esto tiene algunas consecuencias en la forma de diseñar los componentes de tu IU, como se explica en Recomposición.

Una función de componibilidad simple

Con Compose, puedes definir un conjunto de funciones que admitan composición, que tomen datos y emitan elementos de la IU para compilar tu interfaz de usuario. Un ejemplo simple es un widget Greeting, que toma una String y emite un widget Text que muestra un mensaje de saludo.

Captura de pantalla de un teléfono que muestra el texto

Figura 1: Función de componibilidad simple a la que se le pasan datos y los usa para renderizar un widget de texto en la pantalla.

Estos son algunos aspectos a destacar sobre esta función:

  • La función figura con la anotación @Composable. Todas las funciones de componibilidad deben tener esa anotación que informa al compilador de Compose que está diseñada para convertir datos en IU.

  • La función toma datos. Las funciones de componibilidad pueden aceptar parámetros, lo que permite que la lógica de la app describa la IU. En este caso, nuestro widget acepta una String para poder saludar al usuario por su nombre.

  • La función muestra texto en la IU. Para ello, llama a la función de componibilidad Text() que crea el elemento de texto de la IU. Para emitir una jerarquía de IU, las funciones de componibilidad llaman a otras funciones del mismo tipo.

  • La función no muestra nada. Las funciones de Compose que emiten la IU no necesitan mostrar nada porque describen el estado de pantalla deseado en lugar de crear widgets de la IU.

  • Esta función es rápida, idempotente y no tiene efectos secundarios.

    • La función se comporta de la misma manera cuando se la llama varias veces con el mismo argumento y no usa otros valores como variables globales o llamadas a random().
    • La función describe la IU sin efectos secundarios, como podrían ser la modificación de propiedades o variables globales.

    Por lo general, todas las funciones de componibilidad se deben escribir con estas propiedades, por los motivos que se indican en Recomposición.

Transición al paradigma declarativo

Gracias a los muchos kits de herramientas de IU imperativa orientados a objetos, se crea una instancia de árbol de widgets para inicializar la IU. Para ello, se suele aumentar un archivo de diseño XML. Cada widget mantiene su propio estado interno y expone los métodos get y set que permiten que la lógica de la app interactúe con el widget.

En el enfoque declarativo de Compose, los widgets son relativamente sin estado y no exponen funciones de los métodos get y set. De hecho, los widgets no se exponen como objetos. Para actualizar la IU, se llama a la misma función de componibilidad con diferentes argumentos. Eso facilita la asignación de estado a los patrones arquitectónicos, por ejemplo, un ViewModel, como se describe en la Guía de arquitectura de apps. Luego, las funciones de componibilidad son responsables de transformar el estado actual de la aplicación en una IU cada vez que se actualizan los datos observables.

Ilustración del flujo de datos en una IU de Compose, desde los objetos de nivel superior hasta sus elementos secundarios.

Figura 2: La lógica de la app proporciona datos a la función de componibilidad de nivel superior. Esa función utiliza los datos para describir la IU mediante llamadas a otras funciones que admiten composición, pasa los datos correspondientes a esas funciones y va descendiendo así sucesivamente en la jerarquía.

Cuando el usuario interactúa con la IU, esta genera eventos como onClick, que deben notificar la lógica de la app que, luego, puede cambiar su estado. Cuando cambia el estado, se vuelve a llamar a las funciones de componibilidad con los datos nuevos. Como consecuencia, se vuelvan a dibujar los elementos de la IU, mediante un proceso que se llama recomposición.

Ilustración de cómo responden los elementos de la IU a la interacción mediante la activación de eventos controlados por la lógica de la app.

Figura 3: El usuario interactuó con un elemento de la IU, lo que provocó la activación de un evento. La lógica de la app responde al evento y, luego, de ser necesario, se vuelve a llamar automáticamente a las funciones de componibilidad con los parámetros nuevos.

Contenido dinámico

Como las funciones de componibilidad están escritas en Kotlin en lugar de XML, pueden ser tan dinámicas como cualquier otro código de Kotlin. Por ejemplo, supongamos que quieres compilar una IU que salude a una lista de usuarios:

@Composable
fun Greeting(names: List<String>) {
    for (name in names) {
        Text("Hello $name")
    }
}

Esta función toma una lista de nombres y genera un saludo para cada uno. Las funciones de componibilidad pueden ser bastante sofisticadas. Puedes usar declaraciones if para decidir si quieres mostrar un elemento de la IU en particular; usar bucles o llamar a funciones de ayuda. Tienes la flexibilidad total del lenguaje subyacente. Poder aprovechar esa potencia y flexibilidad es una de las ventajas clave de Jetpack Compose.

Recomposición

En un modelo de IU imperativo, para cambiar un widget, se debe llamar a un método set en el widget que cambie el estado interno. En Compose, se vuelve a llamar a la función de componibilidad con datos nuevos. Eso provoca la recomposición de la función (los widgets que emitió la función se vuelven a dibujar, de ser necesario, con datos nuevos). El framework de Compose puede recomponer de forma inteligente solo los componentes que cambiaron.

Por ejemplo, veamos esta función de componibilidad que muestra un botón:

@Composable
fun ClickCounter(clicks: Int, onClick: () -> Unit) {
    Button(onClick = onClick) {
        Text("I've been clicked $clicks times")
    }
}

Cada vez que se hace clic en el botón, el llamador actualiza el valor de clicks. Compose vuelve a llamar a la expresión lambda con la función Text para mostrar el valor nuevo mediante un proceso que se llama recomposición. Otras funciones que no dependen del valor no se recomponen.

Como ya dijimos, recomponer todo el árbol de la IU puede ser costoso en términos informáticos, ya que consume potencia y la duración de batería. Compose resuelve este problema gracias a esta recomposición inteligente.

La recomposición es el proceso de volver a llamar a las funciones de componibilidad cuando se modifican las entradas. Eso sucede cuando cambian las entradas de la función. Cuando Compose realiza la recomposición a partir de entradas nuevas, solo llama a las funciones o expresiones lambda que podrían haber cambiado, y omite al resto. Al omitir todas las funciones o lambdas cuyos parámetros no se modificaron, Compose puede realizar la recomposición de forma eficiente.

No dependas nunca de efectos secundarios que surjan de la ejecución de funciones de componibilidad, ya que se puede omitir la recomposición de una función. Si lo hicieras, los usuarios podrían experimentar un comportamiento extraño e impredecible en la app. Un efecto secundario es cualquier cambio visible para el resto de la app. Por ejemplo, las siguientes acciones son efectos secundarios peligrosos:

  • Escribir en una propiedad de un objeto compartido
  • Actualizar un elemento observable en ViewModel
  • Actualizar preferencias compartidas

Las funciones de componibilidad pueden volver a ejecutarse con la misma frecuencia que cada fotograma, como cuando se renderiza una animación. Además, deberán ser rápidas para evitar bloqueos durante las animaciones. Si necesitas realizar operaciones costosas, como leer desde preferencias compartidas, hazlo en una corrutina en segundo plano y pasa el valor obtenido como parámetro a la función de componibilidad.

Por ejemplo, este código crea un elemento que admite composición para actualizar un valor en SharedPreferences. El elemento no debe leer ni escribir las preferencias compartidas. En cambio, este código mueve la lectura y escritura a un ViewModel en una corrutina en segundo plano. La lógica de la app pasa el valor actual con una devolución de llamada para activar una actualización.

@Composable
fun SharedPrefsToggle(
    text: String,
    value: Boolean,
    onValueChanged: (Boolean) -> Unit
) {
    Row {
        Text(text)
        Checkbox(checked = value, onCheckedChange = onValueChanged)
    }
}

En este documento, se analizan varios aspectos que debes tener en cuenta cuando usas Compose:

  • Las funciones de componibilidad pueden ejecutarse en cualquier orden.
  • Las funciones de componibilidad pueden ejecutarse en paralelo.
  • La recomposición omite la mayor cantidad posible de funciones de componibilidad y expresiones lambda.
  • La recomposición es optimista y se puede cancelar.
  • Una función de componibilidad se puede ejecutar con bastante frecuencia, la misma que cada uno de los fotogramas de una animación.

En las siguientes secciones, se explica cómo compilar funciones de componibilidad para llevar a cabo la recomposición. En todos los casos, se recomienda que esas funciones continúen siendo rápidas, idempotentes y sin efectos secundarios.

Las funciones de componibilidad se pueden ejecutar en cualquier orden

Si observas el código de una función de componibilidad, puedes suponer que el código se ejecuta en el orden en el que aparece. Sin embargo, eso no es necesariamente así. Si una función de componibilidad contiene llamadas a otras funciones del mismo tipo, esas funciones podrían ejecutarse en cualquier orden. Compose tiene la opción de reconocer que algunos elementos de la IU tienen mayor prioridad que otros, y los dibuja primero.

Por ejemplo, supongamos que tienes un código como este para dibujar tres pantallas en un diseño de pestaña:

@Composable
fun ButtonRow() {
    MyFancyNavigation {
        StartScreen()
        MiddleScreen()
        EndScreen()
    }
}

Las llamadas a StartScreen, MiddleScreen y EndScreen pueden ocurrir en cualquier orden. Eso significa que no puedes, por ejemplo, hacer que StartScreen() establezca alguna variable global (un efecto secundario) y que MiddleScreen() aproveche ese cambio. Por el contrario, cada una de esas funciones debe ser independiente.

Las funciones de componibilidad se pueden ejecutar en paralelo

Compose puede optimizar la recomposición ejecutando funciones de componibilidad en paralelo. Eso permite que Compose aproveche varios núcleos y ejecute esas funciones fuera de pantalla con una prioridad más baja.

Esa optimización significa que una función de componibilidad se puede ejecutar dentro de un grupo de subprocesos en segundo plano. Si una de esas funciones llama a una función de ViewModel, Compose puede llamar a esa función desde varios subprocesos al mismo tiempo.

Para garantizar que la aplicación se comporte correctamente, ninguna de las funciones de componibilidad debe tener efectos secundarios. Por el contrario, los efectos secundarios se deben activar a partir de devoluciones de llamada como onClick, que siempre se ejecutan en el subproceso de IU.

Cuando se invoca una función de componibilidad, la invocación puede ocurrir en un subproceso diferente al del llamador. Eso significa que se deben evitar códigos que modifiquen variables en una expresión lambda que admite composición, ya que no es seguro para el subproceso y porque es un efecto secundario inadmisible de este tipo de expresión lambda.

Este es un ejemplo de un elemento que admite composición y que muestra una lista y su respectivo recuento:

@Composable
fun ListComposable(myList: List<String>) {
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
            }
        }
        Text("Count: ${myList.size}")
    }
}

Ese código no tiene efectos secundarios y transforma la lista de entradas en la IU. Es excelente para mostrar una lista pequeña. Sin embargo, si la función escribe en una variable local, deja de ser correcto y seguro para el subproceso:

@Composable
@Deprecated("Example with bug")
fun ListWithBug(myList: List<String>) {
    var items = 0

    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Column {
            for (item in myList) {
                Text("Item: $item")
                items++ // Avoid! Side-effect of the column recomposing.
            }
        }
        Text("Count: $items")
    }
}

En este ejemplo se modifica items con cada recomposición. Podría ser con cada fotograma de una animación o cuando se actualiza la lista. De cualquier manera, la IU mostrará el recuento incorrecto. Por lo tanto, este tipo de escritura no es compatible con Compose. Si prohibimos esas escrituras, permitimos que el framework cambie los subprocesos para ejecutar lambdas que admiten composición.

La recomposición realiza omisiones tanto como sea posible

Cuando algunas partes de la IU no son válidas, Compose hace todo lo posible por reescribir las partes que deben actualizarse. Eso significa que se pueden realizar omisiones para volver a ejecutar un determinado elemento que admite composición de un botón sin tener que ejecutar ninguno de los elementos de este tipo que se encuentran por arriba o por debajo de él en el árbol de IU.

Cada función y lambda de componibilidad podría recomponerse por sí misma. En este ejemplo se demuestra cómo la recomposición puede omitir algunos elementos a la hora de renderizar una lista:

/**
 * Display a list of names the user can click with a header
 */
@Composable
fun NamePicker(
    header: String,
    names: List<String>,
    onNameClicked: (String) -> Unit
) {
    Column {
        // this will recompose when [header] changes, but not when [names] changes
        Text(header, style = MaterialTheme.typography.bodyLarge)
        Divider()

        // LazyColumn is the Compose version of a RecyclerView.
        // The lambda passed to items() is similar to a RecyclerView.ViewHolder.
        LazyColumn {
            items(names) { name ->
                // When an item's [name] updates, the adapter for that item
                // will recompose. This will not recompose when [header] changes
                NamePickerItem(name, onNameClicked)
            }
        }
    }
}

/**
 * Display a single name the user can click.
 */
@Composable
private fun NamePickerItem(name: String, onClicked: (String) -> Unit) {
    Text(name, Modifier.clickable(onClick = { onClicked(name) }))
}

Cada uno de esos alcances podría ser lo único que haya que ejecutar durante una recomposición. Compose puede saltar a la expresión lambda Column sin ejecutar ninguna de las superiores cuando el header cambia. Y cuando se ejecuta Column, Compose podría optar por omitir los elementos de LazyColumn si names no cambia.

Nuevamente, la ejecución de todas las expresiones lambda o funciones de componibilidad no debe tener efectos secundarios. De ser necesario que ocurra un efecto secundario, se debe activar a partir de una devolución de llamada.

La recomposición es optimista

La recomposición comienza cada vez que Compose considera que los parámetros de un elemento que admite composición podrían haber cambiado. La recomposición es optimista, es decir que Compose espera completarla antes de que los parámetros vuelvan a cambiar. Si un parámetro cambia antes de que se complete la recomposición, Compose puede cancelarla y volverla a iniciar con el parámetro nuevo.

Cuando se cancela, Compose descarta el árbol de IU de la recomposición. Si hay algún efecto secundario que depende de la IU que se muestra, se aplicará por más que se cancele la composición. Eso puede provocar que el estado de la app sea inconsistente.

Asegúrate de que todas las funciones y expresiones lambda de componibilidad sean idempotentes y no tengan efectos secundarios para controlar la recomposición optimista.

Las funciones de componibilidad se pueden ejecutar con mucha frecuencia

En algunos casos, se puede ejecutar una de esas funciones para cada fotograma de una animación de IU. Si la función realiza operaciones costosas, como leer desde el almacenamiento del dispositivo, puede hacer que se bloquee la IU.

Por ejemplo, si tu widget intentó leer la configuración del dispositivo, podría leer esa configuración cientos de veces por segundo, con efectos desastrosos en el rendimiento de la app.

Si la función de componibilidad necesita datos, debe definir sus parámetros. Luego, se puede mover el trabajo costoso a otro subproceso, fuera de la composición, y pasar los datos a Compose con mutableStateOf o LiveData.

Más información

Para obtener más información sobre cómo pensar en Compose y en las funciones de componibilidad, consulta los siguientes recursos adicionales.

Videos