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 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 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 los 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 superior.

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 que admiten composición.

Composición

En la fase de composición, el tiempo de ejecución de Compose ejecuta funciones que admiten composición 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 fases siguientes, como se muestra en el siguiente video:

Figura 2: El árbol que representa tu IU que se crea en la fase de composición

Una subsección del código y el árbol 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: Una subsección de un árbol de IU con el código correspondiente

En estos ejemplos, cada función de componibilidad 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 que admiten composición pueden contener lógica y flujo de control, y producir un árbol diferente según los diferentes 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: La medición y la 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 atraviesa con el siguiente algoritmo de tres pasos:

  1. Medir los elementos secundarios: Un nodo mide sus elementos secundarios si existen.
  2. Decidir su propio tamaño: Según estas mediciones, un nodo decide su propio tamaño.
  3. Ubicar los elementos secundarios: Cada nodo secundario se ubica en relación con la posición propia de un nodo.

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

  • Un ancho y una altura asignados
  • Una coordenada x, y donde 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. Row mide sus elementos secundarios, Image y Column.
  2. Se mide Image. No tiene elementos secundarios, por lo que decide su propio tamaño y lo informa a Row.
  3. A continuación, se mide Column. Primero, mide sus propios elementos secundarios (dos elementos Text que admiten composición).
  4. Se mide el primer elemento Text. No tiene elementos secundarios, por lo que decide su propio tamaño y lo informa a Column.
    1. Se mide el segundo elemento Text. No tiene elementos secundarios, por lo que decide su propio tamaño y lo informa a Column.
  5. 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. Column ubica sus elementos secundarios en relación con sí mismo y los coloca uno debajo del otro de forma vertical.
  7. 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, ubica sus elementos secundarios.

Ten en cuenta que cada nodo se visitó solo una vez. El tiempo de ejecución de Compose requiere solo un paso por el árbol de IU para medir y ubicar todos los nodos, lo que mejora el rendimiento. Cuando aumenta la cantidad de nodos en el árbol, el tiempo que se tarda en atravesarlo aumenta de forma lineal. Por el contrario, si cada nodo se visitara varias veces, el tiempo de recorrido aumentaría de forma exponencial.

Dibujo

En la fase de dibujo, el árbol se atraviesa nuevamente de arriba abajo, y cada nodo se dibuja en la pantalla por turno.

Figura 5: La fase de dibujo dibuja los píxeles en la pantalla.

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

  1. Row dibuja cualquier contenido que pueda tener, como un color de fondo.
  2. Image se dibuja a sí mismo.
  3. Column se dibuja a sí mismo.
  4. El primer y el segundo elemento Text se dibujan a sí mismos, 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 cambie 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 usando un delegado de propiedad de Kotlin. Puedes obtener más información al respecto en El estado en elementos que admiten composición. A 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)
)

En el interior del delegado de propiedades, se usan las funciones "getter" y "setter" para acceder y actualizar el value del estado. Las funciones de estos métodos get y set 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 value de estado y reinicia los permisos en diferentes fases.

Lecturas de estado por fases

Como se mencionó anteriormente, hay tres fases principales en Compose, y Compose realiza un seguimiento del estado que se lee en cada una de ellas. Esto permite que Compose notifique solo las fases específicas que deben realizar el trabajo para cada elemento afectado de tu IU.

En las siguientes secciones, se describe cada fase y lo que sucede cuando se lee un valor de estado dentro de ella.

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 de estado, recomposer programa que se vuelvan a ejecutar todas las funciones que admiten composición y que leen ese value 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 Layout que admite composición, el MeasureScope.measure método 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 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.

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

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.

Optimiza las lecturas de estado

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

Considera el siguiente 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 está escrito, 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 de firstVisibleItemScrollOffset cambiará. Como aprendiste, Compose realiza un seguimiento de cualquier lectura de estado para que pueda reiniciar (volver a invocar) el código de lectura, que en este ejemplo es el contenido de Box.

Este es un ejemplo de lectura de 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 todo el contenido que admite composición y, luego, este se mida, se diseñe y, por último, se dibuje. 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 solo para volver a activar la fase de diseño.

Desplazamiento 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 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, 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 forma de retroceder mientras se está 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 { mutableIntStateOf(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 no natural 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. En consecuencia, 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 actualizado de imageHeightPx.

Composición del segundo fotograma

Compose inicia el segundo fotograma, activado 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 de 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.