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. Por ejemplo, el sistema Android View tiene 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 documentación de Compose describe la composición en 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.
Las tres fases en las que Compose transforma los datos en IU (en orden, datos, composición, diseño, dibujo, IU).
Figura 1: Las tres fases en las que Compose transforma datos en IU.

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 principal.

Conceptualmente, cada una de estas fases se produce para cada fotograma. Sin embargo, para optimizar el 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 de componibilidad 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.

Comprende las fases

En esta sección, se describe con más detalle cómo se ejecutan las tres fases de Compose para los elementos componibles.

Composición

En la fase de composición, el tiempo de ejecución de Compose ejecuta funciones de componibilidad y genera una estructura de árbol que representa tu IU. Este árbol de IU consta de nodos de diseño que contienen toda la información necesaria para las siguientes fases, como se muestra en el siguiente video:

Figura 2: Es el árbol que representa tu IU y que se crea en la fase de composición.

Una subsección del árbol de código y de IU se ve de la siguiente manera:

Un fragmento de código con cinco elementos componibles y el árbol de IU resultante, con nodos secundarios que se ramifican desde sus nodos principales.
Figura 3: Es una subsección de un árbol de IU con el código correspondiente.

En estos ejemplos, cada función que admite composición en el código se asigna a un solo nodo de diseño en el árbol de IU. En ejemplos más complejos, los elementos componibles pueden contener lógica y flujo de control, y producir un árbol diferente según los distintos estados.

Diseño

En la fase de diseño, Compose usa el árbol de IU producido en la fase de composición como entrada. La colección de nodos de diseño contiene toda la información necesaria para decidir el tamaño y la ubicación de cada nodo en el espacio 2D.

Figura 4: Medición y posición de cada nodo de diseño en el árbol de IU durante la fase de diseño.

Durante la fase de diseño, el árbol se recorre con el siguiente algoritmo de tres pasos:

  1. Medir elementos secundarios: Un nodo mide sus elementos secundarios si existen.
  2. Decidir su propio tamaño: En función de estas mediciones, un nodo decide su propio tamaño.
  3. Colocar elementos secundarios: Cada nodo secundario se coloca en relación con la posición del nodo.

Al final de esta fase, cada nodo de diseño tiene lo siguiente:

  • Un ancho y una altura asignados
  • Una coordenada X, Y en la que se debe dibujar

Recuerda el árbol de IU de la sección anterior:

Un fragmento de código con cinco elementos componibles y el árbol de IU resultante, con nodos secundarios que se ramifican desde sus nodos principales

Para este árbol, el algoritmo funciona de la siguiente manera:

  1. El Row mide sus elementos secundarios, Image y Column.
  2. Se mide el Image. No tiene elementos secundarios, por lo que decide su propio tamaño y lo informa al Row.
  3. A continuación, se mide el Column. Primero mide sus propios elementos secundarios (dos elementos Text que admiten composición).
  4. Se mide el primer Text. No tiene elementos secundarios, por lo que decide su propio tamaño y lo informa a Column.
    1. Se mide el segundo Text. No tiene elementos secundarios, por lo que decide su propio tamaño y lo informa a Column.
  5. El nodo Column usa las mediciones de los elementos secundarios para decidir su propio tamaño. Usa el ancho máximo del elemento secundario y la suma de la altura de sus elementos secundarios.
  6. El Column coloca sus elementos secundarios en relación con sí mismo, y los ubica uno debajo del otro de forma vertical.
  7. El nodo Row usa las mediciones de los elementos secundarios para decidir su propio tamaño. Usa la altura máxima del elemento secundario y la suma de los anchos de sus elementos secundarios. Luego, coloca sus elementos secundarios.

Ten en cuenta que cada nodo se visitó solo una vez. El tiempo de ejecución de Compose solo requiere un paso por el árbol de IU para medir y colocar todos los nodos, lo que mejora el rendimiento. Cuando aumenta la cantidad de nodos en el árbol, el tiempo que se dedica a recorrerlo aumenta de forma lineal. En cambio, si se visita cada nodo varias veces, el tiempo de recorrido aumenta de forma exponencial.

Dibujo

En la fase de dibujo, el árbol se recorre de nuevo de arriba hacia abajo, y cada nodo se dibuja en la pantalla por turnos.

Figura 5: En la fase de dibujo, se dibujan los píxeles en la pantalla.

Con el ejemplo anterior, el contenido del árbol se dibuja de la siguiente manera:

  1. El Row dibuja cualquier contenido que pueda tener, como un color de fondo.
  2. El Image se dibuja solo.
  3. El Column se dibuja solo.
  4. El primer y el segundo Text se dibujan, respectivamente.

Figura 6: Un árbol de IU y su representación dibujada

Lecturas de estado

Cuando lees el value de un snapshot state durante una de las fases mencionadas anteriormente, Compose realiza un seguimiento automático de lo que estaba haciendo cuando leyó el value. Este seguimiento permite que Compose vuelva a ejecutar el lector cuando cambia el value 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 describieron 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 estado value 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.

En las siguientes secciones, se describe cada fase y 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 value del estado, el recomposer programa que se vuelvan a ejecutar todas las funciones de componibilidad que leen el value de ese 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, entre otros. 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 { … } y funciones similares.

Las lecturas de estado durante cada uno de estos pasos afectan el diseño y, posiblemente, la fase de dibujo. Cuando cambia el value del 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.

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 value del 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)
}

Diagrama que muestra que una lectura de estado durante la fase de dibujo solo activa la fase de dibujo para que se ejecute de nuevo.

Cómo optimizar las lecturas de estado

Dado que Compose realiza un seguimiento localizado de las lecturas de estado, puedes minimizar la cantidad de trabajo que se realiza leyendo cada estado en una fase adecuada.

Ten en cuenta el siguiente ejemplo. En este ejemplo, se muestra 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 value del estado firstVisibleItemScrollOffset y lo pasa a la función Modifier.offset(offset: Dp). A medida que el usuario se desplaza, el value del firstVisibleItemScrollOffset cambiará. Como aprendiste, Compose realiza un seguimiento de cualquier lectura de estado para poder reiniciar (volver a invocar) el código de lectura, que, en este ejemplo, es el contenido de Box.

Este es un ejemplo de cómo se lee un estado dentro de 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.

Punto clave: Este ejemplo no es óptimo porque cada evento de desplazamiento produce que se vuelva a evaluar, medir, diseñar y, por último, dibujar todo el contenido que admite composición. Activas la fase de Compose en cada desplazamiento, aunque el contenido que se muestra no haya cambiado, solo su posición. Puedes optimizar la lectura de estado para que solo se vuelva a activar la fase de diseño.

Compensación con lambda

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. Actualiza el 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 brindas 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 el 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 value de firstVisibleItemScrollOffset, Compose solo tiene que reiniciar las fases de diseño y dibujo.

Desde luego, con frecuencia, es absolutamente necesario leer los estados en la fase de composición. Aun así, existen casos en los que puedes minimizar la cantidad de recomposiciones si filtras 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)

En esta guía, se mencionó anteriormente 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() }
        )
    )
}

En este ejemplo, se implementa una columna vertical, con la imagen en la parte superior y el texto debajo. Usa Modifier.onSizeChanged() para obtener el tamaño resuelto de la imagen y, luego, usa Modifier.padding() en el texto para 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 el código no llega 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.

Composición del primer fotograma

Durante la fase de composición del primer fotograma, imageHeightPx es inicialmente 0. Por lo tanto, el código proporciona el texto con Modifier.padding(top = 0). La fase de diseño posterior invoca la devolución de llamada del modificador onSizeChanged, que actualiza imageHeightPx a la altura real de la imagen. Luego, Compose programa una recomposición para el siguiente fotograma. Sin embargo, durante la fase de dibujo actual, el texto se renderiza con un padding de 0, ya que aún no se refleja el valor imageHeightPx actualizado.

Composición del segundo fotograma

Compose inicia el segundo fotograma, que se activa por el cambio en el valor de imageHeightPx. En la fase de composición de este fotograma, el estado se lee dentro del bloque de contenido Box. Ahora, el texto se proporciona con un padding que coincide con precisión con la altura de la imagen. Durante la fase de diseño, se vuelve a establecer imageHeightPx. Sin embargo, no se programa ninguna recomposición adicional porque el valor sigue siendo coherente.

Diagrama que muestra un bucle de recomposición en el que un cambio de tamaño en la fase de diseño activa una recomposición, lo que luego hace que el diseño vuelva a ocurrir.

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(), 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.