wandres.dev
ESTABILIDAD Y SKIPPING · rendimiento en Compose

Cómo decide Compose recomponer

Recomponer no es redibujar: es reejecutar selectivamente trozos de código que el runtime memorizó en una tabla de slots. Esta lección desmonta la maquinaria de esa decisión —la invalidación de un scope reiniciable, la comparación de los parámetros de cada llamada frente a los valores recordados, y el papel que juega la noción de estabilidad de un tipo— para explicar por qué a veces Compose puede saltarse una función entera y por qué otras veces se niega a intentarlo siquiera. Se distinguen con precisión los conceptos de composable reiniciable y composable saltable, se define formalmente qué exige el compilador para considerar estable a un tipo, y se sitúa la comparación de igualdad en el lugar exacto donde ocurre.

⏱ 18 min

Recomponer es la operación más frecuente de una app en Compose y también la peor entendida. Casi todo el mundo imagina que el runtime vuelve a dibujar la pantalla; no hace nada parecido. Lo que hace es mucho más quirúrgico y mucho más interesante: recorre un árbol de invocaciones que él mismo memorizó durante la primera composición y, en cada nodo, se plantea una pregunta binaria —¿me han cambiado los argumentos?— cuya respuesta decide si vuelve a ejecutar ese trozo de código o lo salta entero, con todos sus descendientes. Toda la disciplina del rendimiento en Compose cabe en entender cómo se responde esa pregunta, cuándo el runtime se niega a responderla y qué significa exactamente que un tipo sea estable. Esta lección desmonta esa maquinaria hasta el nivel del compilador, porque sin ella las lecciones siguientes serían recetas sin razón.

🎯 Al terminar esta lección sabrás
  • Distinguir composición, recomposición y dibujado, y situar la tabla de slots donde se memoriza todo.
  • Entender la comparación de parámetros que decide si una llamada se salta o se vuelve a ejecutar.
  • Definir con precisión las condiciones que el compilador exige para considerar estable a un tipo.
  • Separar los conceptos de composable reiniciable y composable saltable que emite el compilador.

De composición a recomposición

Cuando una función @Composable se ejecuta por primera vez, no devuelve nada: emite. Cada llamada anidada deja un rastro ordenado en una estructura llamada tabla de slots, un buffer plano donde el runtime guarda, en orden de ejecución, qué se invocó, con qué argumentos y qué valores se recordaron con remember. Esa tabla es la memoria del árbol de interfaz. La composición inicial es el acto de llenarla; la recomposición es el acto de recorrerla otra vez comparando lo nuevo con lo guardado.

Conviene separar tres fases que el vocabulario cotidiano mezcla. La composición ejecuta las funciones componibles y produce o actualiza el árbol de nodos. El layout mide y coloca esos nodos. El dibujo los pinta. Son fases independientes y una recomposición no implica necesariamente rehacer las otras dos: cambiar un texto obliga a las tres, cambiar un color de fondo puede resolverse solo en la última. Cuando en este nivel hablamos de rendimiento hablamos de la primera, que es donde se decide cuánto código de tu aplicación se ejecuta.

La recomposición no se dispara sola ni globalmente. Se dispara cuando un objeto observable —un MutableState, un StateFlow recolectado con collectAsState— cambia de valor. El runtime sabe exactamente qué scopes de recomposición leyeron ese objeto durante la última ejecución, porque los registró como suscriptores mientras se leían. Cambiar el valor invalida esos scopes y solo esos. Aquí está la primera idea profunda: Compose no propaga cambios hacia abajo por el árbol como haría un sistema de diffing ingenuo, sino que salta directamente a los puntos que leyeron el dato.

@Composable
fun Pantalla(vm: PerfilViewModel) {
    val state by vm.collectAsState()   // este scope lee el estado
    Cabecera(nombre = state.nombre)     // recibe un String
    Contador(valor = state.mensajes)    // recibe un Int
}

Cuando state cambia, se invalida el scope de Pantalla. Al reejecutarse, el runtime vuelve a encontrar las llamadas a Cabecera y Contador, y en cada una decide por separado si tiene que entrar. Que el padre se recomponga no obliga a los hijos a recomponerse: obliga a evaluar si deben hacerlo. Esta frase es probablemente la más importante de toda la lección, porque invierte la intuición heredada de los sistemas de interfaz imperativos, donde reconstruir un contenedor implicaba reconstruir su contenido.

El mecanismo que permite volver a encontrar cada llamada en su sitio se llama memoización posicional: la identidad de un nodo no viene de una clave que tú asignes, sino de la posición del sitio de llamada dentro del código fuente, que el compilador convierte en un identificador estable. Por eso remember funciona sin que le des nombre a nada, y por eso los bucles necesitan claves explícitas: dentro de un bucle, todas las iteraciones comparten sitio de llamada y hace falta un dato adicional para distinguirlas.

La comparación de parámetros

Esa evaluación es una comparación de igualdad, parámetro a parámetro, entre el valor que se pasa ahora y el que quedó registrado en la tabla de slots la vez anterior. El compilador de Compose reescribe cada función componible para insertar esa comparación en el prólogo generado: si todos los parámetros son iguales al valor recordado, el cuerpo no se ejecuta y el runtime avanza el cursor de la tabla saltándose el subárbol completo.

El código que el compilador genera se puede imaginar, muy simplificado, así:

// Pseudocodigo de lo que el compilador inserta en el prologo.
fun Cabecera(nombre: String, composer: Composer) {
    composer.startRestartGroup(claveDelSitioDeLlamada)
    val distinto = composer.changed(nombre)      // compara con el slot guardado
    if (distinto || !composer.skipping) {
        // cuerpo real de la funcion
    } else {
        composer.skipToGroupEnd()                // salta el subarbol entero
    }
    composer.endRestartGroup()
}

La llamada a skipToGroupEnd es la instrucción clave: no ejecuta nada, solo avanza el cursor de la tabla hasta el final del grupo, dejando intactos todos los nodos que ya estaban allí. Saltarse un subárbol de doscientos nodos cuesta, literalmente, mover un índice.

La comparación usa equals, con una precisión importante: para tipos primitivos y String la igualdad estructural es barata y fiable; para clases de datos, equals compara campo a campo; para clases sin equals sobreescrito, cae en identidad referencial. Pero la comparación por sí sola no basta, y aquí aparece el matiz que casi todos pasan por alto: el compilador solo se molesta en generar la comparación si el tipo del parámetro es estable. Si no lo es, no hay comparación posible que garantice nada, y la función se marca como no saltable: se reejecutará siempre que su padre se recomponga, aunque los argumentos sean idénticos por valor.

flowchart TD
INV[Un estado leido cambia de valor] --> SCOPE[El runtime invalida los scopes que lo leyeron]
SCOPE --> Q{Todos los parametros son estables y comparan iguales}
Q -->|Si| SKIP[Salta el cuerpo y reutiliza el subarbol entero]
Q -->|No| RUN[Ejecuta el cuerpo y emite de nuevo]
RUN --> HIJOS[La misma decision se repite en cada hijo]
style SKIP fill:#a6e3a1,color:#11111b
style RUN fill:#f9e2af,color:#11111b

Hay un matiz que se pasa por alto y que explica por qué los saltos no ocurren en la primera pasada: durante la composición inicial no hay nada guardado con lo que comparar, así que todo se ejecuta. Los saltos son un fenómeno exclusivo de la recomposición, y por eso una pantalla pesada al abrirse y fluida después no tiene un problema de estabilidad sino de coste de construcción, que se ataca con otras técnicas.

La consecuencia práctica es asimétrica y conviene interiorizarla: un parámetro inestable no solo obliga a reejecutar esa función, sino que arrastra a todo lo que cuelga de ella si a su vez recibe parámetros derivados. Un solo tipo mal modelado en la raíz de una pantalla puede desactivar el mecanismo de salto de una jerarquía entera.

Qué significa que un tipo sea estable

Estabilidad no es un adjetivo poético: es un contrato con tres cláusulas que el compilador verifica o que tú prometes explícitamente. Un tipo es estable si y solo si cumple las tres.

⚖️

equals consistente

Para dos instancias cualesquiera, el resultado de equals es siempre el mismo mientras esas instancias existan. La comparación no puede dar hoy verdadero y mañana falso.

📣

Cambios notificados

Si alguna propiedad pública cambia, la composición debe enterarse. Un campo var corriente no notifica a nadie; un mutableStateOf sí.

🧬

Estabilidad transitiva

Todos los tipos de las propiedades públicas deben ser, a su vez, estables. La estabilidad se hereda hacia arriba y se rompe por el eslabón más débil.

Merece la pena detenerse en la primera cláusula, que suele leerse por encima. No dice que equals deba ser correcto ni razonable: dice que debe ser consistente en el tiempo. Un tipo cuyo equals compara campos que pueden mutar viola esa cláusula aunque su implementación sea perfectamente sensata, porque el mismo par de instancias daría verdadero antes de la mutación y falso después. La consistencia temporal es lo que permite al runtime cachear el veredicto de la comparación y confiar en él hasta el siguiente cambio anunciado.

ℹ️
La estabilidad no es transitiva hacia abajo, es exigente hacia abajo

Que un tipo sea estable no dice nada sobre sus contenedores: una clase estable puede vivir dentro de una inestable. Lo que sí ocurre es lo contrario, y es una exigencia estricta: para que un contenedor sea estable, todo lo que expone públicamente debe serlo. Por eso la corrección se propaga desde las hojas hacia la raíz, y por eso arreglar el tipo más profundo suele desbloquear estabilidad en varios niveles de golpe.

La segunda cláusula es la más sutil y la que explica casi todos los casos raros. Compose no compara el mundo entero en cada frame: confía en ser avisado. Un tipo cuyo contenido puede mutar en silencio es inestable no porque la comparación fuera cara, sino porque sería mentirosa: equals podría devolver verdadero mientras el contenido ya ha cambiado, y la interfaz mostraría datos viejos. Ante la duda, el compilador elige la corrección sobre la velocidad y desactiva el salto.

Hay una consecuencia de diseño que se deriva de aquí y que gobierna todo el nivel: como la estabilidad se rompe por el eslabón más débil y se demuestra hacia arriba, el sitio más rentable para invertir esfuerzo es el modelo de datos que alimenta la pantalla, no los composables que lo consumen. Un estado bien tipado hace estables a decenas de funciones sin tocar ninguna de ellas.

Reiniciable frente a saltable

El compilador clasifica cada función componible con dos etiquetas independientes que conviene no confundir. Reiniciable significa que la función tiene su propio scope de recomposición y puede reejecutarse por sí sola sin que su padre lo haga; es lo normal para cualquier composable que devuelva Unit y no esté marcado como inline. Saltable significa que, además, el compilador pudo generar la comparación de parámetros y por tanto puede evitar ejecutarla.

Un composable puede ser reiniciable pero no saltable —el caso habitual cuando recibe un tipo inestable—, y ese es exactamente el estado que produce recomposiciones inútiles: tiene scope propio, se le invalida, y al no poder compararse nada se ejecuta entero cada vez. Las funciones que devuelven un valor, las anotadas con @NonRestartableComposable y las inline como Column o Row no son reiniciables, y su trabajo se atribuye al scope del llamante.

// Reiniciable y saltable: devuelve Unit y todos sus parametros son estables.
@Composable
fun Etiqueta(texto: String, activa: Boolean) { /* ... */ }

// Reiniciable pero no saltable: el parametro impide generar la comparacion.
@Composable
fun Panel(datos: MutableList<String>) { /* ... */ }

// No reiniciable: devuelve un valor, su trabajo cuenta en el scope del llamante.
@Composable
fun colorDeFondo(activa: Boolean): Color = if (activa) Color.Blue else Color.Gray

Esta clasificación es exactamente la que emitirán los informes del compilador que estudiaremos más adelante en el nivel, y saber leerla convierte una intuición vaga sobre el rendimiento en un diagnóstico con nombre y apellidos. Que una función no sea reiniciable no es malo en sí: significa que su coste se contabiliza en el scope de quien la llama, lo que en el caso de Column o Row es precisamente lo deseable, porque evita crear miles de scopes triviales para contenedores que no tienen lógica propia.

📝
Por que el cuerpo de un composable debe ser puro

Como el runtime decide libremente si ejecutar una función, cuántas veces y en qué orden —incluso puede empezar una recomposición, cancelarla a mitad y reintentarla—, el cuerpo de un composable tiene que ser idempotente y estar libre de efectos observables. Cualquier trabajo que deba ocurrir exactamente una vez pertenece a LaunchedEffect, a SideEffect o, en la arquitectura que estás construyendo, al canal de side effects de tu container. Esta exigencia no es una recomendación estilística: es la contrapartida que el runtime cobra por poder saltarse trabajo.

💡
Lambdas y la trampa del parametro que siempre cambia

Una lambda escrita en el sitio de la llamada es un parámetro más y también se compara. Si captura variables, el compilador la envuelve en un objeto recordado y estable siempre que lo capturado sea estable; si captura algo inestable, la lambda misma se vuelve un argumento nuevo en cada recomposición y anula el salto. Pasar onClick = { vm.borrar(id) } es seguro cuando vm e id son estables, y deja de serlo en cuanto uno de los dos no lo es.

Compose no optimiza: negocia garantias

Aquí está el cambio de perspectiva que separa a quien memoriza reglas de quien entiende el sistema. Es tentador leer el mecanismo de salto como una optimización que el runtime aplica cuando puede, un extra bienintencionado que a veces falla. No lo es. El salto es una consecuencia lógica de una demostración, y el compilador solo la aplica cuando puede demostrarla. La pregunta que se hace no es “¿sería más rápido saltar?” sino “¿puedo garantizar que saltar no produce una interfaz incorrecta?”. Y para garantizarlo necesita exactamente lo que exige la definición de estabilidad: que la igualdad signifique algo permanente, que las mutaciones se anuncien, y que esas dos propiedades se cumplan recursivamente en todo lo que el tipo contiene. Cuando alguna de esas piezas falta, el compilador no está siendo pesimista ni torpe: está siendo honesto, porque saltar sin esas garantías significaría mostrar datos obsoletos, y una interfaz rápida que miente es infinitamente peor que una lenta que dice la verdad. De ahí se sigue el corolario que gobierna todo este nivel y que enlaza directamente con MVI: el rendimiento en Compose no se consigue apretando tuercas al renderizado, se consigue modelando los datos de forma que el compilador pueda demostrar cosas sobre ellos. La velocidad no es una técnica de dibujado. Es un efecto secundario del diseño de tipos.

⚔️ Traza la decision a mano
  1. Escribe una pantalla con un padre que lea el estado y tres hijos que reciban, respectivamente, un Int, un String y una data class de campos primitivos. Razona qué hijos se saltan cuando cambia solo el Int.
  2. Explica con tus palabras la diferencia entre reiniciable y saltable, y da un ejemplo de función que sea lo primero pero no lo segundo.
  3. Justifica por qué la cláusula de notificación de cambios es necesaria para la corrección y no solo para el rendimiento.
  4. Toma un composable tuyo que reciba una lambda y determina qué captura; decide si esa captura conserva o rompe la estabilidad del parámetro.
  5. Argumenta por qué un solo parámetro inestable cerca de la raíz puede anular el salto de una jerarquía completa.