Principios básicos de Compose (Parte 3)

Yago Rey
6 min readMay 27, 2023

--

Memorización posicional

Alguien consciente del rendimiento de los ejemplos que hemos visto en la parte 2 podría ver el código y señalar que estamos creando un árbol completamente nuevo cada vez que ejecutamos compose.

Para aplicaciones grandes, esto creará muchas asignaciones innecesarias en cada pasada sucesiva, lo que destrozará el rendimiento de nuestra APP. Desde el punto de vista de la corrección, también significa que si alguno de esos nodos tiene algún estado privado, no se conservará cada vez que reconstruyamos la jerarquía.

Hay varias formas de arreglar esto, pero Compose utiliza una técnica llamada “Memorización posicional”. Gran parte de la arquitectura de Compose se basa en este concepto, así que intentemos construir un modelo mental sólido de cómo funciona.

En la última sección, presentamos un objeto Composer que contiene el contexto de dónde estamos en el árbol y en qué nodo estamos emitiendo actualmente. Nuestro objetivo es conservar el modelo de programación que teníamos anteriormente, pero intentar reutilizar los nodos que habíamos creado en la ejecución anterior de la interfaz de usuario en lugar de crear nuevos en cada ejecución. Esencialmente, queremos almacenar en caché cada nodo.

La mayoría de las cachés requieren claves, alguna forma de identificar de qué objeto desea recuperar el resultado almacenado en caché. En el ejemplo anterior podemos ver que cada vez que ejecutamos la función TodoApp creamos exactamente el mismo número de Nodes cada vez, y en el mismo orden. Si asumimos que queremos almacenar en caché cada nodo, se deduce que consultaremos el caché en el mismo orden exacto cada vez que se ejecute la función (esta lógica se rompe si introducimos alguna lógica condicional en nuestra aplicación, pero es algo que veremos más tarde), por lo tanto… ¡podríamos utilizar el orden de ejecución como clave de caché!

Podemos realizar un seguimiento de un “índice actual” mientras ejecutamos la función de transformación de la aplicación e incrementarlo cada vez que recuperamos un valor.

Como una implementación simple de esto, añadiremos la función memo a la clase Composer:

interface Composer {
/* emit(...) excluded for brevity */

// Compare each input with the previous value at this position. If any
// have changed, return result of factory, otherwise return previous result
fun <T> memo(vararg inputs: Any?, factory: () -> T): T
}

Como siempre, vamos a olvidarnos de la implementación concreta de esa función, ya que creo que de cara a crearnos nuestro mapa mental no nos aporta nada (este post es una traducción casi literal de este otro, por lo que si tienes curiosidad en ver cómo sería la implementación de la función memo , puedes hacer click en este link y echarle un ojo al código).

La misión de nuestra función memo será asociar el índice de la caché con un componente específico. Esto se basa en la expectativa de que cada vez que se llame a esta función se llamará con la misma cantidad de entradas para una "posición" dada, o de lo contrario, la memoria caché podría desalinearse con el tiempo.

Con esta función memo ,podemos cambiar nuestro ejemplo anterior de TodoApp para aprovechar ahora la memorización:

fun Composer.TodoItem(item: TodoItem) {
emit(memo { Stack(Orientation.Horizontal) }) {
emit(
memo(item.completed) {
Text(if (item.completed) "x" else " ")
}
)
emit(
memo(item.title) {
Text(item.title)
}
)
}
}

fun Composer.TodoApp(items: List<TodoItem>) {
emit(memo { Stack(Orientation.Vertical) }) {
for (item in items) {
TodoItem(item)
}
}
}

Ahora, cada vez que ejecutamos compose, los nodos del árbol se reutilizan a menos que cambien…

Quizás ya hayas detectado algún problema con este enfoque de memorización basado en el orden de ejecución. Esto fallará cuando introduzcamos cualquier tipo de flujo de control en nuestras funciones de transformación. Por ejemplo, veamos la siguiente función TodoApp:

fun Composer.TodoApp(items: List<TodoItem>) {
emit({ Stack(Orientation.Vertical) }) {
for (item in items) {
TodoItem(item)
}
}
val text = "Total: ${items.size} items"
emit(
{ Text() },
{ memo(text) { it.text = text }}
)
}

En este ejemplo, si tuviéramos 2 elementos la primera vez que componemos la aplicación, y 3 elementos la segunda vez, ¿qué sucedería?.

Los dos primeros elementos se memorizarían correctamente. La ejecución anterior tenía los mismos dos elementos, lo que significa que la memoria caché se consultó exactamente la misma cantidad de veces con los mismos valores. Sin problema.

Lo interesante es lo que sucede cuando llegamos al tercer elemento.

La ejecución anterior solo tenía dos elementos, por lo que cuando llegamos al tercer elemento de la lista y comenzamos a consultar la caché, nos encontraremos con que la memoria caché no tendrá ese valor de la ejecución anterior para consultar, por lo que se asignará un nuevo nodo Text en esa posición (3).

La solución no parece del todo mala, pero también es cierto que estamos asumiendo que lo único que cambiará será la cantidad de elementos en la lista. ¿Qué ocurre si lo que cambia es el orden?. Nuestra caché estará desalineada y tendremos un comportamiento indefinido.

Para arreglar esto, necesitamos introducir otro concepto fundamental a la “Memoización Posicional”: Grupos.

interface Composer {
/* emit(...) and memo(...) excluded for brevity */

// start a group, execute block inside that group, end the group
fun group(key: Any?, block: () -> Unit)
}

De nuevo, no nos centraremos en la implementación concreta, ya que además implementar esto correctamente es bastante complicado y creo que explicarlo distraería la atención del post.

Esencialmente, un grupo es lo que convierte el caché lineal en una estructura similar a un árbol, donde podemos identificar cuándo se han movido, eliminado o agregado nodos en ese árbol.

Se espera que el método group reciba una clave. Esta clave se almacenará en caché al igual que las entradas a memo ,pero cuando no coincida con la clave de la ejecución anterior, se buscará en la caché en tiempo de ejecución para determinar si el grupo se ha movido, eliminado o es un nuevo grupo a insertar.

Ahora, si queremos usar correctamente los grupos en nuestro ejemplo de TodoApp, podríamos terminar con algo como:

fun Composer.TodoItem(item: TodoItem) {
group(3) {
emit({ Stack(Orientation.Horizontal) }) {
group(4) {
emit(
{ Text() }
{ memo(item.completed) { it.text = if (item.completed) "x" else " " } }
)
}
group(5) {
emit(
{ Text() }
{ memo(item.title) { it.text = item.title } }
)
}
}
}
}

fun Composer.TodoApp(items: List<TodoItem>) {
group(0) {
emit({ Stack(Orientation.Vertical) }) {
for (item in items) {
group(1) {
TodoItem(item)
}
}
}
}

val text = "Total: ${items.size} items"
group(2) {
emit(
{ Text() },
{ memo(text) { it.text = text } }
)
}
}

En este caso, acabamos de asignar números enteros únicos como claves para cada grupo. Es importante destacar que también hemos añadido la llamada a TodoItem a un grupo, lo que garantizará que cada TodoItem se memorice de forma independiente.

Ahora, cuando el tamaño de items cambia de 2 a 3, sabemos "agregar" elementos en la caché. Lo mismo ocurre para los casos en que los elementos se eliminan del caché.

Los elementos que se “mueven” se manejan de manera similar, aunque el algoritmo para hacerlo es un poco más complejo. No vamos a entrar en esto en detalle, pero lo importante que hay que entender es que hacemos un seguimiento de los “movimientos” en un grupo en función de la clave del grupo secundario.

Si desordenamos la lista items en este ejemplo, el hecho de que cada llamada de TodoItem esté añadida a un grupo con clave 1 significa que Composer no tiene forma de saber que el orden de los elementos cambió.

Esto no es fatal, solo significa que es poco probable que la cantidad de cambios que se memorizan sea mínima, y ​​cualquier estado que estaba asociado con el elemento ahora puede estar asociado con un elemento diferente. Sin embargo, podríamos usar el propio item como clave:

for (item in items) {
group(item) {
TodoItem(item)
}
}

Ahora, cada grupo y su conjunto de valores en caché en ese grupo se moverán al unísono, y luego se llamará a TodoItem con el caché de memorización del mismo grupo de la composición anterior, lo que aumenta la probabilidad de que los cambios sean mínimos, a pesar del costo extra de mover los elementos almacenados en caché.

Si has llegado hasta aquí ¡enhorabuena!, ahora ya conoces un poco más sobre la técnica de memorización de compose, y porqué durante las recomposiciones únicamente se actualizan algunos componentes.

Recuerda que esta es la Parte 3 de una serie de principios básicos.

Seguimos en la parte 4 explicando el concepto de estado en Compose

Te dejo por aquí los links a la parte 1 y a la parte 2.

Y como siempre, recordar que este post no deja de ser una traducción casi literal de: http://intelligiblebabble.com/compose-from-first-principles/

Por lo que todos los aplausos serán para el post original.

Si quieres escribirme puedes contactarme vía Linkedin a través de este link.

--

--