Fases de Jetpack Compose

Al igual que la mayoría de los kits de herramientas de la IU, Compose renderiza un fotograma a través de varias fases distintas. Si analizamos el sistema Android View, este cuenta con tres fases principales: medición, diseño y dibujo. Compose es muy similar, pero tiene una fase adicional importante que se denomina composición al comienzo.

La composición se describe en nuestros documentos de Compose, lo que incluye Acerca de Compose y El estado y Jetpack Compose.

Las tres fases de un fotograma

Compose tiene tres fases principales:

  1. Composición: Indica qué IU se mostrará. Compose ejecuta funciones que admiten composición y crea una descripción de la IU.
  2. Diseño: Indica dónde se ubicará la IU. Esta fase consta de dos pasos: medición y posición. Los elementos de diseño se miden y se ubican a sí mismos y a cualquier elemento secundario en coordenadas 2D para cada nodo en el árbol de diseño.
  3. Dibujo: Indica cómo se renderiza. Los elementos de la IU se dibujan en un lienzo, por lo general, en la pantalla de un dispositivo.

El orden de estas fases suele ser el mismo, lo que permite que los datos fluyan en una dirección desde la composición hasta el diseño y el dibujo para producir un fotograma (que también se conoce como flujo de datos unidireccional). BoxWithConstraints, LazyColumn y LazyRow son excepciones notables, en las que la composición de sus elementos secundarios depende de la fase de diseño del elemento superior.

Puedes suponer, con seguridad, que estas tres fases se producen, de forma virtual, para cada fotograma, pero, en pos del rendimiento, Compose evita el trabajo repetitivo que calcularía los mismos resultados a partir de las mismas entradas en todas estas fases. Compose omite la ejecución de una función que admite composición si puede volver a usar un resultado anterior, y la IU de Compose no vuelve a diseñar ni a dibujar el árbol completo si no es necesario. Compose realiza solo la cantidad mínima de trabajo que se necesita para actualizar la IU. Esta optimización es posible porque Compose realiza un seguimiento de las lecturas de estado en las diferentes fases.

Lecturas de estado

Cuando lees el valor del estado de una instantánea durante una de las fases que se mencionan anteriormente, Compose realiza un seguimiento automático de la acción que llevaba a cabo cuando se leyó el valor. Este seguimiento permite que Compose vuelva a ejecutar el lector cuando cambie el valor del estado y representa la base de la observabilidad del estado en Compose.

Por lo general, el estado se crea con mutableStateOf(), y puedes acceder a él mediante una de estas dos maneras: de forma directa con la propiedad value o, como alternativa, usando un delegado de propiedad de Kotlin. Puedes obtener más información al respecto en El estado en elementos que admiten composición. Para los fines de esta guía, una "lectura de estado" se refiere a cualquiera de esos métodos de acceso equivalentes.

// State read without property delegate.
val paddingState: MutableState<Dp> = remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(paddingState.value)
)
// State read with property delegate.
var padding: Dp by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.padding(padding)
)

Dentro del delegado de propiedad, se usan las funciones de los métodos "get" y "set" para acceder al value del estado y actualizarlo. Las funciones de estos métodos solo se invocan cuando haces referencia a la propiedad como un valor, en lugar de cuando se crea, motivo por el cual los dos métodos que se mencionan anteriormente son equivalentes.

Cada bloque de código que se puede volver a ejecutar cuando cambia un estado de lectura es un permiso de reinicio. Compose realiza un seguimiento de los cambios de valor de estado y reinicia los permisos en diferentes fases.

Lecturas de estado por fases

Como se mencionó anteriormente, Compose tiene tres fases principales y realiza un seguimiento del estado que se lee en cada una de estas. De esta manera, Compose puede notificar solo las fases específicas que deben realizar trabajos para cada elemento afectado de la IU.

Analicemos cada fase y describamos lo que sucede cuando se lee un valor de estado en una de estas.

Fase 1: Composición

Las lecturas de estado dentro de una función @Composable o un bloque de lambda afectan a la composición y, posiblemente, a las fases posteriores. Cuando cambia el valor de estado, recomposer programa que se vuelvan a ejecutar todas las funciones que admiten composición y que leen ese valor de estado. Ten en cuenta que el tiempo de ejecución puede decidir omitir algunas o todas las funciones que admiten composición si las entradas no cambiaron. Consulta Cómo omitir procesos si las entradas no cambiaron para obtener más información.

Según el resultado de la composición, la IU de Compose ejecuta las fases de diseño y dibujo. Es posible que omita estas fases si el contenido continúa siendo el mismo, y el tamaño y el diseño no cambiarán.

var padding by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    // The `padding` state is read in the composition phase
    // when the modifier is constructed.
    // Changes in `padding` will invoke recomposition.
    modifier = Modifier.padding(padding)
)

Fase 2: Diseño

La fase de diseño consta de dos pasos: medida y posición. En el paso de medición, se ejecuta la lambda de medición que se pasa al elemento Layout que admite composición, el método MeasureScope.measure de la interfaz LayoutModifier, etc. En el paso de posición, se ejecuta el bloque de posición de la función layout, el bloque de lambda de Modifier.offset { … }, etc.

Las lecturas de estado durante cada uno de estos pasos afectan el diseño y, posiblemente, la fase de dibujo. Cuando cambia el valor de estado, la IU de Compose programa la fase de diseño. También ejecuta la fase de dibujo si cambió el tamaño o la posición.

Para mayor precisión, el paso de medición y el paso de posición tienen permisos de reinicio diferentes, lo que significa que las lecturas de estado del paso de posición no vuelven a invocar el paso de medición anterior. Sin embargo, estos dos pasos suelen estar entrelazados, por lo que una lectura de estado en el paso de posición puede afectar a otros permisos de reinicio que pertenecen al paso de medición.

var offsetX by remember { mutableStateOf(8.dp) }
Text(
    text = "Hello",
    modifier = Modifier.offset {
        // The `offsetX` state is read in the placement step
        // of the layout phase when the offset is calculated.
        // Changes in `offsetX` restart the layout.
        IntOffset(offsetX.roundToPx(), 0)
    }
)

Fase 3: Dibujo

Las lecturas de estado mientras se dibuja el código afectan la fase de dibujo. Entre algunos ejemplos comunes, se incluyen Canvas(), Modifier.drawBehind y Modifier.drawWithContent. Cuando cambia el valor de estado, la IU de Compose solo ejecuta la fase de dibujo.

var color by remember { mutableStateOf(Color.Red) }
Canvas(modifier = modifier) {
    // The `color` state is read in the drawing phase
    // when the canvas is rendered.
    // Changes in `color` restart the drawing.
    drawRect(color)
}

Cómo optimizar las lecturas de estado

A medida que Compose realiza un seguimiento localizado de las lecturas de estado, podemos minimizar la cantidad de trabajo que se realiza mediante la lectura de cada estado en una fase correcta.

Veamos un ejemplo. Aquí se observa un objeto Image() que usa el modificador de desplazamiento para desplazar la posición final del diseño, lo que produce, como resultado, un efecto de paralaje a medida que el usuario se desplaza.

Box {
    val listState = rememberLazyListState()

    Image(
        // Non-optimal implementation!
        Modifier.offset(
            with(LocalDensity.current) {
                // State read of firstVisibleItemScrollOffset in composition
                (listState.firstVisibleItemScrollOffset / 2).toDp()
            }
        )
    )

    LazyColumn(state = listState)
}

Este código funciona, pero no brinda un rendimiento óptimo. Tal como se describe, el código lee el valor de estado de firstVisibleItemScrollOffset y lo pasa a la función Modifier.offset(offset: Dp). A medida que el usuario se desplaza, cambiará el valor de firstVisibleItemScrollOffset. Como sabemos, Compose realiza un seguimiento de cualquier lectura de estado para poder reiniciar (volver a invocar) el código de lectura, que, en nuestro ejemplo, es el contenido de Box.

Este es un ejemplo de cómo se lee el estado dentro en la fase de composición. No es algo malo en absoluto y, de hecho, representa la base de la recomposición, lo que permite que los cambios en los datos emitan una IU nueva.

Sin embargo, en este ejemplo, no es óptimo, ya que cada evento de desplazamiento producirá que se vuelva a evaluar todo el contenido que admite composición y, luego, este se medirá, se diseñará y, por último, se dibujará. Activaremos la fase de Compose en cada desplazamiento aunque el elemento que mostremos no haya cambiado, solo donde se muestra. Podemos optimizar nuestra lectura de estado solo para volver a activar la fase de diseño.

Puedes encontrar otra versión del modificador de desplazamiento: Modifier.offset(offset: Density.() -> IntOffset).

Esta versión toma un parámetro lambda, en el que el bloque de lambda muestra el desplazamiento resultante. Actualicemos nuestro código para usarlo:

Box {
    val listState = rememberLazyListState()

    Image(
        Modifier.offset {
            // State read of firstVisibleItemScrollOffset in Layout
            IntOffset(x = 0, y = listState.firstVisibleItemScrollOffset / 2)
        }
    )

    LazyColumn(state = listState)
}

Entonces, ¿por qué tiene un mejor rendimiento? El bloque de lambda que le brindamos al modificador se invoca durante la fase de diseño (específicamente, durante el paso de posición de esta fase), lo que significa que nuestro estado de firstVisibleItemScrollOffset ya no se lee durante la composición. Como Compose realiza un seguimiento del estado de lectura, este cambio implica que, si se modifica el valor de firstVisibleItemScrollOffset, Compose solo tiene que reiniciar las fases de diseño y dibujo.

Este ejemplo se basa en los diferentes modificadores de desplazamiento para poder optimizar el código resultante, pero la idea general es cierta: intenta localizar las lecturas de estado en la fase más baja posible, lo que permite que Compose realice el menor trabajo posible.

Desde luego, con frecuencia, es absolutamente necesario leer los estados en la fase de composición. Aun así, existen casos en los que podemos minimizar la cantidad de recomposiciones si filtramos los cambios de estado. Para obtener más información al respecto, consulta derivedStateOf: Convierte uno o varios objetos de estado en otro estado.

Bucle de recomposición (dependencia de la fase cíclica)

Anteriormente mencionamos que las fases de Compose siempre se invocan en el mismo orden y que no hay manera de retroceder mientras estamos en el mismo fotograma. Sin embargo, eso no prohíbe que las apps ingresen a bucles de composición en fotogramas diferentes. Observa este ejemplo:

Box {
    var imageHeightPx by remember { mutableStateOf(0) }

    Image(
        painter = painterResource(R.drawable.rectangle),
        contentDescription = "I'm above the text",
        modifier = Modifier
            .fillMaxWidth()
            .onSizeChanged { size ->
                // Don't do this
                imageHeightPx = size.height
            }
    )

    Text(
        text = "I'm below the image",
        modifier = Modifier.padding(
            top = with(LocalDensity.current) { imageHeightPx.toDp() }
        )
    )
}

Aquí implementamos (de manera incorrecta) una columna vertical, con la imagen en la parte superior y el texto debajo. Usamos Modifier.onSizeChanged() para conocer el tamaño resuelto de la imagen y, luego, utilizamos Modifier.padding() en el texto a fin de desplazarlo hacia abajo. La conversión antinatural de Px a Dp ya indica que el código tiene algún problema.

El problema con este ejemplo es que no llegamos al diseño "final" dentro de un solo fotograma. El código se basa en un suceso de varios fotogramas, que realiza un trabajo innecesario y hace que la IU aparezca en la pantalla para el usuario.

Analicemos cada fotograma para ver qué está sucediendo:

En la fase de composición del primer fotograma, imageHeightPx tiene un valor de 0, y el texto se brinda con Modifier.padding(top = 0). Luego, sigue la fase de diseño, y se llama a la devolución de llamada para el modificador onSizeChanged. En ese momento, imageHeightPx se actualiza a la altura real de la imagen. Compose programa la recomposición para el siguiente fotograma. En la fase de dibujo, se renderiza el texto con el padding de 0, ya que todavía no se refleja el cambio de valor.

Luego, Compose inicia el segundo fotograma que programó el cambio de valor de imageHeightPx. El estado se lee en el bloque de contenido de Box, y este se invoca en la fase de composición. Esta vez, el texto se brinda con un padding que coincide con la altura de la imagen. En la fase de diseño, el código sí vuelve a establecer el valor de imageHeightPx, pero no se programa ninguna recomposición, ya que el valor se mantiene igual.

Al final, obtenemos el padding deseado en el texto, pero no es óptimo usar un fotograma adicional para regresar el valor de padding a una fase diferente y, como resultado, se producirá un fotograma con contenido superpuesto.

Es posible que este ejemplo parezca forzado, pero ten cuidado con este patrón general:

  • Modifier.onSizeChanged(), onGloballyPositioned() u otras operaciones de diseño
  • Actualiza algún estado
  • Usa ese estado como entrada para un modificador de diseño (padding(), height() o similares)
  • Posiblemente tendrás que repetir el proceso

Para solucionar el problema del ejemplo anterior, usa las primitivas de diseño correctas. El ejemplo anterior se puede implementar con un objeto Column() simple, pero es posible que tengas un ejemplo más complejo que necesite una solución personalizada, que requerirá escribir un diseño personalizado. Consulta la guía de Diseños personalizados para obtener más información.

El principio general aquí es tener una sola fuente de información para varios elementos de la IU que se deben medir y ubicar con respecto al otro. Usar un primitivo de diseño correcto o crear un diseño personalizado implica que el elemento superior compartido mínimo funcione como la fuente de confianza que puede coordinar la relación entre varios elementos. Introducir un estado dinámico no cumple con este principio.