wandres.dev
COMPOSE: FUNDAMENTOS · composables y recomposición

Recomposición: cuándo se vuelve a ejecutar tu función

El mecanismo que reejecuta la descripción: qué es un ámbito reiniciable y hasta dónde llega, cómo la recomposición inteligente se salta lo que no cambió, qué papel juegan la estabilidad y el modo de salto fuerte, y por qué el orden de ejecución entre hermanos no está garantizado.

⏱ 19 min

Si una función componible describe la interfaz para unos datos, cambiar los datos obliga a volver a describir. A eso se le llama recomposición, y la palabra engaña por lo simple: no significa que Compose reejecute tu pantalla entera, ni que la reejecute cuando tú crees, ni que lo haga en el orden en que la escribiste. El runtime tiene libertad para saltarse funciones completas, para ejecutar a los hermanos en cualquier orden, para hacerlo en paralelo y para abandonar a mitad un trabajo que ya no interesa. Todo lo que puedes dar por cierto es lo que el contrato promete, y conocer ese contrato con precisión es lo que evita escribir código que funciona por casualidad.

🎯 Al terminar esta lección sabrás
  • Definir el ámbito de recomposición y saber hasta dónde se propaga una invalidación.
  • Explicar cómo decide el runtime saltarse la ejecución de una función componible.
  • Entender la estabilidad de los tipos y el modo de salto fuerte activo por defecto.
  • Escribir código que sobreviva al orden arbitrario, al paralelismo y al abandono.

Qué se reejecuta exactamente

Recomponer no es repintar. Cuando un valor observable que tu código leyó durante la composición cambia, el runtime marca como inválido el trozo de árbol que lo leyó y programa una reejecución de ese trozo, no de la pantalla. La unidad de esa granularidad es el ámbito de recomposición, y coincide con la función componible reiniciable más cercana que envuelve la lectura.

El compilador convierte casi todas tus funciones componibles en reiniciables: envuelve el cuerpo entre la apertura y el cierre de un grupo reiniciable y registra una lambda capaz de volver a ejecutarlo con los mismos argumentos. Esa lambda es el ámbito. Hay excepciones deliberadas: las funciones cuyo retorno no es Unit y las marcadas como no reiniciables no crean ámbito propio, de modo que su invalidación sube al ámbito del que las llamó.

@Composable
fun Pantalla(titulo: String, contador: Int) {   // ambito A
    Column {
        Cabecera(titulo)                        // ambito B
        Text(text = "Total: $contador")         // la lectura ocurre aqui, en A
        PieDePagina()                           // ambito C
    }
}

En ese ejemplo, un cambio de contador invalida el ámbito A completo, porque la interpolación se evalúa en el cuerpo de Pantalla. Al reejecutarse A, el runtime vuelve a llamar a Cabecera y a PieDePagina, pero comprueba antes si sus argumentos han cambiado; como no lo han hecho, se salta ambos cuerpos y reutiliza lo que ya había. Si el mismo texto estuviera extraído en una función propia que recibiera el contador, el ámbito invalidado sería esa función pequeña y el resto ni se rozaría. Esa es toda la técnica de elevar la lectura al sitio más profundo posible: no cambia lo que se ve, cambia el tamaño de lo que se reejecuta.

flowchart TD
A[Cambia un valor leido durante la composicion] --> B[Se invalida el ambito reiniciable que lo leyo]
B --> C[El runtime encola la reejecucion para el siguiente fotograma]
C --> D[Al reejecutar compara los argumentos de cada hijo]
D -->|iguales y estables| E[Se salta el cuerpo y reutiliza los nodos]
D -->|distintos| F[Reejecuta el cuerpo del hijo]
style E fill:#a6e3a1,color:#11111b
style F fill:#f9e2af,color:#11111b

La recomposición inteligente

El salto es lo que hace viable todo el modelo. Sin él, describir la pantalla entera en cada cambio sería inasumible. La comprobación que lo decide es más literal de lo que parece: para cada parámetro, el runtime compara el valor recibido con el que había en la ranura correspondiente de la tabla; si todos son equivalentes y el tipo del parámetro cumple ciertas condiciones, el cuerpo no se ejecuta.

La comparación se hace con equals, así que la calidad de tu implementación de igualdad se traduce directamente en rendimiento. Un tipo de datos de Kotlin la genera correctamente; una clase normal sin equals sobrescrito compara por referencia y provocará recomposiciones cada vez que se construya una instancia nueva con el mismo contenido.

Existe una condición previa que a veces se olvida: para poder saltarse una función, el runtime necesita además que la función sea reiniciable y que no tenga parámetros que impidan la comparación. Los informes que genera el compilador de Compose lo dicen literalmente, marcando cada función como saltable o no saltable y cada parámetro como estable o inestable. Leer ese informe antes de intentar optimizar a ojo ahorra días: casi siempre el problema está en dos o tres funciones concretas, y casi nunca donde uno sospechaba.

Hay un detalle que sorprende y que conviene retener: el salto no impide que se ejecuten los hijos. Compose puede saltarse el cuerpo de una función y aun así reejecutar un ámbito interno que fue invalidado por su cuenta. Es lo que suele llamarse el agujero del donut: la invalidación no viaja necesariamente por la jerarquía, viaja por las lecturas. Un ámbito profundo puede recomponerse mil veces sin que ninguno de sus antepasados se ejecute ni una sola.

⚠️
Las lambdas que se recrean rompen el salto

Una lambda literal escrita en el sitio de llamada era, históricamente, una instancia distinta en cada ejecución, y bastaba para que la comparación fallara y el hijo se recompusiera siempre. Con el modo de salto fuerte el compilador memoriza automáticamente esas lambdas cuando sus capturas son estables, de modo que el problema clásico desaparece en la mayoría de los casos. Sigue siendo cierto, en cambio, que una lambda que captura un valor cambiante no puede memorizarse.

Estabilidad y salto fuerte

Para poder comparar por valor, el runtime necesita confiar en el tipo. Un tipo es estable si su igualdad es consistente, si sus propiedades públicas no cambian sin avisar a la composición, y si todas ellas son a su vez estables. Los primitivos, las cadenas, las funciones y los tipos de datos compuestos solo por estables lo son. Una List normal no lo es: la interfaz no garantiza inmutabilidad, y quien la recibe no puede saber si por debajo hay una lista mutable que alguien está modificando.

Puedes declarar la estabilidad tú mismo con @Stable cuando garantizas que los cambios se notifican, o con @Immutable cuando garantizas que nada cambia jamás. Ambas son promesas que el compilador cree sin comprobar: si mientes, la interfaz se queda desactualizada y no habrá ningún error que te lo diga.

@Immutable
data class Articulo(
    val id: Long,
    val titulo: String,
    val etiquetas: List<String>,   // se promete que nadie la mutara
)

Desde que el modo de salto fuerte es el comportamiento por defecto del compilador, el panorama cambió de forma notable. Con él activo, los parámetros de tipo inestable ya no bloquean el salto: se comparan por identidad de instancia en lugar de por valor. Una función que recibe una List puede saltarse su cuerpo si le llega exactamente la misma instancia que la vez anterior, algo que antes era imposible. La estabilidad deja de ser un requisito para saltar y pasa a ser una optimización: los tipos estables comparan por contenido y aciertan más, los inestables comparan por referencia y aciertan cuando la instancia se conserva.

Para el caso concreto de las colecciones existe además una salida limpia: las colecciones inmutables y persistentes de la biblioteca de Kotlin se consideran estables por definición, porque su tipo garantiza lo que la interfaz de List no puede garantizar. Sustituir el tipo del parámetro es un cambio de una línea que devuelve la comparación por contenido y suele eliminar de golpe una familia entera de recomposiciones innecesarias.

Eso no elimina el trabajo de diseño, lo desplaza. Ahora el enemigo del salto no es tanto la inestabilidad como la construcción de instancias nuevas en cada composición del padre. Una lista recalculada, un objeto derivado creado al vuelo o una colección copiada por precaución producen instancias distintas con contenido idéntico, y con ellas el salto se pierde igual que antes.

🧊

Inmutable

Nada cambia nunca después de construirse. La comparación por contenido siempre es fiable.

🔔

Estable

Puede cambiar, pero cada cambio se notifica a la composición. La promesa la haces tú.

🌀

Inestable

Sin garantías. Con salto fuerte todavía se puede saltar, pero solo si llega la misma instancia.

🏗️

Recién construido

El caso que más salto destruye hoy: contenido igual, instancia nueva en cada ejecución del padre.

El orden no está garantizado

Queda la parte del contrato que más código frágil produce, porque no falla en desarrollo y falla en producción. El runtime no promete ejecutar a los hermanos en el orden en que aparecen en tu código. Puede ejecutarlos en cualquier orden, puede ejecutarlos en varios hilos a la vez, y puede empezar una recomposición y abandonarla si llega un cambio que la invalida antes de terminar.

// Fragil: depende de que la primera se ejecute antes que la segunda
var vistos = 0

@Composable
fun ListaFragil(items: List<String>) {
    Column {
        items.forEach { item ->
            vistos++                 // efecto secundario sobre estado externo
            Text(text = "$vistos. $item")
        }
    }
}

Ese contador no solo es incorrecto por acumularse entre recomposiciones: es una carrera si la composición ocurre en paralelo, y queda en un estado indeterminado si el trabajo se abandona a mitad. La regla que lo previene es la que ya conoces del contrato de la función componible, ahora con su motivo real a la vista: el cuerpo no debe tener efectos secundarios porque el runtime se reserva la libertad de ejecutarlo cuando le convenga, tantas veces como le convenga y en el orden que le convenga.

La misma libertad explica una advertencia habitual: no debes suponer que un hijo se ha compuesto antes de que su hermano se componga, ni construir lógica que dependa de que algo ya se haya ejecutado. Si necesitas coordinación entre partes de la interfaz, la respuesta nunca es el orden de ejecución, sino los datos que ambas partes reciben.

Falta una pieza para cerrar el cuadro: el abandono. Una composición programada puede quedarse a medias si llega una invalidación que la deja obsoleta, y el trabajo hecho hasta ese punto se descarta sin llegar al árbol. Esto es una optimización valiosa —evita materializar estados intermedios que nadie llegaría a ver— y al mismo tiempo la razón definitiva de que no puedas confiar en que el cuerpo de tu función se haya ejecutado entero. Cualquier cosa que deba ocurrir sí o sí, y exactamente una vez, no pertenece al cuerpo de la descripción sino a las APIs de efectos, que existen precisamente porque el runtime necesita distinguir entre lo que puede repetir a voluntad y lo que no.

📝
Lo que sí puedes dar por cierto

El contrato es corto: tu función se ejecutará con los argumentos que le pasaste, sus hijos se compondrán antes de que el fotograma llegue a pantalla, y las funciones marcadas como efecto se ejecutarán en un momento definido y fuera del cuerpo. Todo lo demás —cuántas veces, en qué orden, en qué hilo y si llega a terminar— es decisión del runtime.

La recomposición no es una optimización: es la definición de lo que significa que la interfaz sea una función

Cuesta ver la recomposición como algo más que un mecanismo de actualización eficiente, y por eso tanta gente la vive como un enemigo al que hay que contener a base de trucos. El giro conceptual es aceptar que la recomposición no acompaña al modelo declarativo: es el modelo declarativo puesto en marcha. Si afirmas que la interfaz es una función de los datos, entonces cambiar los datos obliga a reevaluar la función, y la única pregunta que queda es cuánto de la función hay que reevaluar. Toda la maquinaria —los ámbitos reiniciables, las claves de posición, la comparación de argumentos, la estabilidad de los tipos— existe para responder a esa pregunta con la respuesta más pequeña posible sin dejar de ser correcta. Vista así, la lista de prohibiciones deja de ser un conjunto de reglas arbitrarias y se convierte en el precio explícito del contrato: si tu función no es pura, la reevaluación no es equivalente a la evaluación original y la premisa se rompe; si tu tipo no tiene una igualdad honesta, el sistema no puede saber que nada cambió y tiene que reejecutar por seguridad; si tu código depende del orden entre hermanos, estás asumiendo que la evaluación es un procedimiento cuando el modelo la trata como una expresión. Y hay una consecuencia que solo se aprecia con perspectiva: el orden no garantizado y el posible paralelismo no son limitaciones que el equipo de Compose no haya podido resolver, son grados de libertad que se han reservado a propósito para poder acelerar el runtime en el futuro sin romper el código correcto. Quien escribe respetando el contrato hereda gratis cada optimización que llegue; quien escribe apoyándose en el comportamiento observado hoy hereda un fallo silencioso mañana. Esa asimetría es la razón práctica de tomarse el contrato en serio incluso cuando saltárselo parece funcionar.

⚔️ Observar la recomposición de cerca
  1. Activa la superposición de recomposiciones en Android Studio y localiza qué parte de tu pantalla se tiñe con más frecuencia.
  2. Extrae la lectura de un valor cambiante a una función componible más pequeña y comprueba cómo se reduce el área invalidada.
  3. Pasa a una función componible una clase sin equals sobrescrito y luego conviértela en tipo de datos; compara el número de recomposiciones.
  4. Genera los informes del compilador de Compose y localiza qué funciones aparecen marcadas como no saltables y por qué.
  5. Reproduce el ejemplo del contador global y explica por escrito los tres motivos distintos por los que es incorrecto.