Recomposición
Al terminar: Entender qué dispara una recomposición, ver una fila que se recompone de más y arreglarla.
Al terminar esta lección, Marcador tendrá una cuarta pestaña, «Recomp.», con una lista de 8 goleadores en la que cada fila enseña cuántas veces se ha recompuesto. Con un interruptor la pantalla alterna entre una versión con un fallo, en la que las 8 filas se recomponen cada vez que pulsas «Avanzar minuto», y la versión arreglada, en la que no se recompone ninguna. Además verás cada recomposición escrita en Logcat con la etiqueta Recomp.
Importa porque una pantalla que se recompone de más gasta batería y va a tirones, y no da ningún error: hay que saber medirla. Temas: qué dispara una recomposición, estabilidad, skipping (saltarse el trabajo) y claves.
Qué dispara una recomposición
En Estado en Compose viste que una recomposición es volver a ejecutar un composable cuando cambia un estado que leyó. Hoy ves qué la dispara exactamente y cuándo se la salta. Compose no lo hace al azar: apunta qué bloques de código leyeron un valor de estado mientras se componían, y cuando ese valor cambia vuelve a ejecutar solo esos.
Qué bloque es exactamente depende de dónde está la lectura. La regla, en lo que se ve: Compose vuelve a ejecutar la función más cercana que contiene la lectura. Column, Row y Box no cuentan como funciones aparte: lo que escribes dentro de ellas cuenta como parte de la función que las llama. El contenido de un Button o de un Card, en cambio, sí es un bloque aparte, que se recompone por su cuenta.
Dos ejemplos de dos líneas cada uno:
var minuto by remember { mutableIntStateOf(0) } // un estado que leen A y B
// A) lectura en el cuerpo (o dentro de una Column): se recompone LA FUNCIÓN ENTERA
Column { Text("Minuto $minuto") }
// B) lectura dentro de un Button: se recompone SOLO el contenido del botón
Button(onClick = { }) { Text("Minuto $minuto") }En el caso A, al cambiar minuto se vuelve a ejecutar la función entera. En el B, Compose vuelve a ejecutar solo el bloque del botón y el padre no se entera. La pantalla que construirás en el paso lee minuto en el cuerpo, dentro de una Column, como en A, y por eso es el padre entero el que se recompone.
Dos consecuencias prácticas:
- Un composable puede ejecutarse varias veces antes de dibujarse en pantalla: no metas dentro nada que dependa de cuántas veces corre (salvo, como hoy, para medir).
- La pregunta que decide el rendimiento es: cuando el padre se recompone, ¿salta cada hijo?
Estabilidad y skipping
Para saltarse un hijo, Compose compara sus parámetros con los de la pasada anterior. Cómo los compara depende de si el tipo es estable.
La regla que aplica en Marcador se llama strong skipping (salto fuerte), y viene activada por defecto:
| Parámetro | Se compara con | Se salta cuando… |
|---|---|---|
Estable (Int, String, y una data class con campos estables compilada con el compilador de Compose) | equals() | El valor es igual |
| Inestable | === | Es la misma instancia |
«Compilada con el compilador de Compose» quiere decir con el plugin kotlin-compose que :app aplica en build.gradle.kts (Module :app), el de Los ficheros de Gradle de tu proyecto.
== compara el contenido (usa equals() por dentro) y === pregunta si son literalmente el mismo objeto, como viste en data class e igualdad. Es la diferencia clave: dos listas con el mismo contenido pero creadas por separado son iguales con == y distintas con ===. Y qué cuenta como inestable en Marcador:
Jugador y Partido viven en :torneo, que no aplica ese plugin (su build.gradle.kts no lo tiene), así que cuentan como inestables, y con ellos Pair<Jugador, Int>. Las listas (List, Set, Map) también: son interfaces (los contratos que viste en Herencia, interfaces y composición), y detrás de una List podría haber una lista mutable.
fun main() {
val a = listOf("Nico" to 3) // una lista con un par
val b = listOf("Nico" to 3) // otra lista, creada por separado, con el mismo contenido
println(a == b) // == compara el contenido
println(a === b) // === pregunta si son el mismo objeto
val c = a // c apunta a la misma lista que a
println(a === c)
}
// Un parámetro inestable con `a` y luego `b` NO se salta: === dice «cambió». Con `a` y luego `c`, sí.true
false
trueError típico: crear la lista en el cuerpo del padre
El fallo no da error: la pantalla funciona, y se recompone de más. El padre lee minuto, así que se recompone cada vez que minuto cambia. Si dentro crea la lista de filas, cada pasada produce instancias nuevas con el mismo contenido.
var minuto by remember { mutableIntStateOf(0) }
val goleadores = torneo.goleadoresConGoles() // se ejecuta en CADA recomposición del padre
Text("Minuto $minuto") // esta lectura recompone la función
LazyColumn { items(goleadores, key = { (j, _) -> "jugador-${j.nombre}" }) { par -> FilaContada(par) } }val goleadores = remember(torneo) { torneo.goleadoresConGoles() }Falla en cadena: sube minuto → se recompone el padre → goleadoresConGoles() crea Pair nuevos → FilaContada recibe un Pair<Jugador, Int> inestable → se compara con === → «cambió» → las 8 filas se recomponen. Con remember(torneo) la lista se calcula una vez y devuelve la misma instancia mientras torneo sea el mismo (el torneo es la clave de remember: si cambia, recalcula), así que === dice «igual» y las filas saltan.
Hay otras salidas (marcar tus clases como estables a mano, por ejemplo) que no usas hoy.
Medir una recomposición: las piezas, un ejemplo y un ejercicio
Para medir hace falta contar cuántas veces se ejecuta una función sin que contar cambie el resultado. Tres piezas que usarás hoy:
Switch(checked = …, onCheckedChange = { … }): un interruptor de Material 3;checkedes su valor yonCheckedChangerecibe el nuevo (state hoisting de State hoisting, otra vez).intArrayOf(0): un array es una lista de tamaño fijo cuyos elementos se numeran desde 0; este tiene uno solo, yveces[0]++le suma 1. Se usa esta «caja» y noremember { 0 }porqueremember { 0 }te da un número suelto: si lo cambias, la próxima vez que se ejecute la funciónrememberte devuelve otra vez el 0 original. El array es una caja: lo que cambias es lo que hay dentro, y la caja es la misma en cada ejecución. Y como no es un estado, cambiarla no provoca recomposiciones, que es lo que se necesita para medir sin alterar lo medido.rememberdentro de unif: se puede, porque Compose le da a cada rama su propio hueco. Al cambiar de rama se olvida lo recordado en la otra. Aquí sirve para alternar el fallo y el arreglo en la misma pantalla; en una pantalla real no iría dentro de unif.
Esas piezas en un ejemplo con otros datos (un recetario), sin la pantalla de Marcador:
@Composable
fun Recetario() {
var pulsaciones by remember { mutableIntStateOf(0) } // estado que lee el padre
var recordar by rememberSaveable { mutableStateOf(false) } // el interruptor
val ingredientes = if (recordar) {
remember { listOf("harina", "huevos", "leche") } // la MISMA lista en cada pasada
} else {
listOf("harina", "huevos", "leche") // una lista NUEVA en cada pasada
}
Column {
Text("Pulsaciones: $pulsaciones") // esta lectura recompone Recetario
Button(onClick = { pulsaciones++ }) { Text("Pulsar") }
Row(verticalAlignment = Alignment.CenterVertically) {
Switch(checked = recordar, onCheckedChange = { recordar = it }) // checked: valor actual; it: el nuevo
Text("Recordar la lista")
}
TarjetaDeReceta("Crepes", ingredientes) // List: inestable, se compara con ===
}
}
@Composable
private fun TarjetaDeReceta(nombre: String, ingredientes: List<String>) {
val pasadas = remember { intArrayOf(0) } // una caja de un elemento; no es estado
pasadas[0]++ // suma 1 cada vez que la función se ejecuta
Text("$nombre: ${ingredientes.size} ingredientes (pasadas: ${pasadas[0]})")
}Lo que ves en pantalla:
- al abrir:
Pulsaciones: 0yCrepes: 3 ingredientes (pasadas: 1); - con el interruptor apagado y tres pulsaciones:
Pulsaciones: 3ypasadas: 4; - al encenderlo:
pasadas: 5, porque la lista cambia de instancia una vez; - con tres pulsaciones más:
Pulsaciones: 6y la tarjeta sigue enpasadas: 5.
Reto · Ejercicio: lista frente a texto, medido
Crea la subcarpeta medir_pasadas dentro de ejercicios (la carpeta de La rotación y la muerte del proceso): clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.medir_pasadas. Luego clic derecho sobre esa carpeta → New → Kotlin Class/File → File → PantallaPasadas. Escribe en ese fichero tres composables:
PantallaPasadas: un contador de votos que empieza en 0 y se muestra como «Votos: N», y un botón «Votar» que lo sube. En el cuerpo, en cada pasada, crea una lista con los colores rojo, verde y azul, y un texto con esos tres colores separados por comas. Debajo del botón, una etiqueta de cada tipo.EtiquetaConLista: recibe la lista y escribe «Con lista: 3 colores (pasadas: N)».EtiquetaConTexto: recibe el texto y escribe «Con texto: rojo, verde, azul (pasadas: N)».
En las dos etiquetas, N cuenta las veces que se ha ejecutado la función, con la caja de un elemento que no es estado (como TarjetaDeReceta). Añade una vista previa y ábrela en modo interactivo (explicado en Estado en Compose); el modo interactivo no escribe en Logcat, por eso aquí se mide en pantalla. Pulsa «Votar» tres veces. Antes de probar, responde: ¿qué número verás en cada etiqueta?
Debes ver, al abrir, «Votos: 0» y las dos etiquetas con «(pasadas: 1)». Tras tres votos, «Votos: 3», «Con lista: … (pasadas: 4)» y «Con texto: … (pasadas: 1)».
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.medir_pasadas
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun PantallaPasadas(modifier: Modifier = Modifier) {
var votos by remember { mutableIntStateOf(0) }
val colores = listOf("rojo", "verde", "azul") // una lista NUEVA en cada pasada
val coloresEnTexto = "rojo, verde, azul" // un texto igual en cada pasada
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("Votos: $votos") // esta lectura recompone PantallaPasadas
Button(onClick = { votos++ }) { Text("Votar") }
EtiquetaConLista(colores)
EtiquetaConTexto(coloresEnTexto)
}
}
@Composable
fun EtiquetaConLista(colores: List<String>) { // List: inestable, se compara con ===
val pasadas = remember { intArrayOf(0) }
pasadas[0]++
Text("Con lista: ${colores.size} colores (pasadas: ${pasadas[0]})")
}
@Composable
fun EtiquetaConTexto(colores: String) { // String: estable, se compara con equals
val pasadas = remember { intArrayOf(0) }
pasadas[0]++
Text("Con texto: $colores (pasadas: ${pasadas[0]})")
}
@Preview(showBackground = true)
@Composable
private fun PantallaPasadasPreview() {
MaterialTheme { PantallaPasadas() }
}La etiqueta con texto se queda en pasadas: 1. Un String es estable y se compara por contenido con equals(): aunque el padre se recomponga y el texto se vuelva a construir, es igual que el anterior, así que Compose se salta la etiqueta. La lista, en cambio, es inestable y se compara con ===: una lista nueva es siempre «distinta», aunque tenga lo mismo.
Paso · la pestaña «Recomp.», con contador en pantalla
Objetivo. Crear PantallaRecomposicion: un minuto que sube con un botón, un interruptor que alterna el fallo y el arreglo, y una lista de 8 filas que cuentan sus recomposiciones. Al terminar, con el interruptor apagado, pulsar «Avanzar minuto» 3 veces deja recomposiciones: 3 en las 8 filas; con el interruptor encendido no suben.
Crear el fichero. En la vista Android, clic derecho sobre el paquete laboratorio (en app → kotlin+java → com.sebastiannarvaez.marcador) → New → Kotlin Class/File → File → Recomposicion. Es una prueba de la lección, y por eso va en laboratorio, la carpeta de las pruebas para ver cómo funciona Android. No instalas nada nuevo: todo lo que usa ya está en tu proyecto.
Modificar AppMarcador.kt (en ui, doble clic en el panel). Tres cambios: un import nuevo (PantallaRecomposicion está en el paquete laboratorio, no en ui: Option+Enter sobre el nombre en rojo lo propone), la lista titulos gana una cuarta entrada, y el when (pestana) gana una rama nueva entre la de «Partidos» y el else (el else sigue siendo «Tablas»).
Piezas nuevas de este paso:
remember(torneo) { … }:remembercon una clave; recalcula solo cuando la clave cambia.- Ya las conoces:
Log.d("Recomp", mensaje), que escribe en Logcat con la etiquetaRecomp(des el nivel «debug»), de El ciclo de vida de la Activity; y elifcomo expresión, que devuelve el valor del último elemento de la rama elegida, de Condiciones: if y when. ElSwitch,intArrayOfy elrememberdentro de unifson los de arriba.
Contar dentro del cuerpo del composable, como hace FilaContada, va contra lo que has leído arriba (un composable puede ejecutarse varias veces antes de dibujarse en pantalla). Aquí se hace a propósito y solo para medir; no es un patrón para la app.
Intenta escribirlo tú, en este orden:
FilaContada, una fila que recibe un jugador con sus goles y cuenta cuántas veces se ejecuta, sin que contar provoque nada: cada vez anota en Logcat, con la etiquetaRecomp, el nombre y el número de composición, y dibuja a la izquierda «Ana · 2 goles» y a la derecha «recomposiciones: N» (las veces que se ha vuelto a ejecutar después de la primera).PantallaRecomposicion, que recibe el torneo: un minuto que empieza en 0 y se muestra en grande («Minuto 3»), un botón «Avanzar minuto» que lo sube, y un interruptor que sobrevive a la rotación, con un texto al lado: apagado dice «Con el bug: lista nueva cada vez» y calcula la lista de goleadores en cada pasada; encendido dice «Con arreglo: remember(torneo)» y la recuerda mientras el torneo sea el mismo. Debajo, la lista perezosa de goleadores, unaFilaContadapor jugador, identificada por su nombre.- En
AppMarcador, una cuarta pestaña, «Recomp.», con su barra superior «Recomposición» y la pantalla nueva.
Debes ver 8 filas con recomposiciones: 0 al abrir la pestaña, y con el interruptor apagado, tras pulsar 3 veces «Avanzar minuto», recomposiciones: 3 en las 8.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.laboratorio
import android.util.Log
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.sebastiannarvaez.marcador.feature.partidos.ui.goleadoresConGoles
import com.sebastiannarvaez.marcador.torneo.Jugador
import com.sebastiannarvaez.marcador.torneo.Torneo
@Composable
fun PantallaRecomposicion(torneo: Torneo, modifier: Modifier = Modifier) {
var minuto by remember { mutableIntStateOf(0) }
var conArreglo by rememberSaveable { mutableStateOf(false) }
// BUG: lista nueva en cada recomposicion de esta funcion.
// ARREGLO: la misma lista mientras `torneo` sea el mismo.
val goleadores = if (conArreglo) {
remember(torneo) { torneo.goleadoresConGoles() }
} else {
torneo.goleadoresConGoles()
}
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
// Esta lectura de `minuto` es lo que recompone a ESTA funcion cuando cambia.
Text("Minuto $minuto", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { minuto++ }) { Text("Avanzar minuto") }
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Switch(checked = conArreglo, onCheckedChange = { conArreglo = it })
Text(if (conArreglo) "Con arreglo: remember(torneo)" else "Con el bug: lista nueva cada vez")
}
LazyColumn(contentPadding = PaddingValues(vertical = 8.dp)) {
items(goleadores, key = { (jugador, _) -> "jugador-${jugador.nombre}" }) { par ->
FilaContada(par)
}
}
}
}
@Composable
private fun FilaContada(par: Pair<Jugador, Int>) {
val veces = remember { intArrayOf(0) }
veces[0]++
// La primera composicion cuenta 1; recompuesta = cuantas veces se ha vuelto a ejecutar.
val recomposiciones = veces[0] - 1
Log.d("Recomp", "${par.first.nombre}: composicion n.º ${veces[0]}")
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalArrangement = Arrangement.SpaceBetween) {
Text("${par.first.nombre} · ${par.second} goles", Modifier.weight(1f))
Text("recomposiciones: $recomposiciones")
}
}En AppMarcador.kt, junto a los demás import:
import com.sebastiannarvaez.marcador.laboratorio.PantallaRecomposicionLa lista de títulos pasa a tener cuatro entradas:
val titulos = listOf("Marcador", "Partidos", "Tablas", "Recomp.")Y la rama nueva del when, entre la de «Partidos» y el else:
3 -> Scaffold(
Modifier.padding(paddingValues),
topBar = { CenterAlignedTopAppBar(title = { Text("Recomposición") }) },
contentWindowInsets = WindowInsets(0),
) { interior -> PantallaRecomposicion(torneo, Modifier.padding(interior)) }El resto de AppMarcador.kt no cambia.
Mídelo
Arranca el emulador como en La Activity y ejecuta la app con Run (selector app). Para leer pocas líneas, borra antes el Logcat: en la ventana Logcat (View → Tool Windows → Logcat) el icono de la papelera, o en la Terminal de Android Studio:
adb logcat -c- Abre la pestaña Recomp. con el interruptor apagado. Las 8 filas dicen
recomposiciones: 0. - Pulsa Avanzar minuto 3 veces. Las 8 filas dicen
recomposiciones: 3: el contenido no ha cambiado y las 8 se han recompuesto. - Mira Logcat. Cada pulsación escribe 8 líneas, una por fila. En la ventana Logcat escribe en su filtro
tag:Recomp; o, en la Terminal,adb logcat -d -s Recomp(-dvuelca lo ya escrito y termina,-sfiltra por etiqueta):
adb logcat -d -s RecompCada línea acaba con algo como esto (Logcat le antepone la hora, el proceso y la letra D de «debug»; los nombres salen de los datos de ejemplo y los números crecen con cada pulsación):
Recomp : Nico: composicion n.º 2
Recomp : …- Toca el propio interruptor (no el texto que va a su lado: el texto no es clicable). Hay una recomposición, porque la lista pasa de ser recreada a estar recordada y cambia la instancia una vez. Las filas suben en 1.
- Pulsa Avanzar minuto otras 3 veces. Las filas se quedan en el mismo número. Con
remember(torneo), el padre se recompone y los hijos saltan.
Los números exactos dependen de cuántas pulsaciones hagas; lo que importa es que sube en todas las filas con el bug y en ninguna con el arreglo. Si tu emulador es pequeño y no caben las 8 filas, solo cuentan las que se ven: LazyColumn compone únicamente las visibles, como viste en Listas con LazyColumn.
Punto de control
- La app compila y se ejecuta con Run sin errores en rojo, y la barra inferior tiene cuatro pestañas, la última «Recomp.».
- Con el interruptor apagado, tras 3 pulsaciones de «Avanzar minuto», las 8 filas dicen
recomposiciones: 3, y Logcat con el filtrotag:Recompmuestra 8 líneas por pulsación. - Con el interruptor encendido, otras 3 pulsaciones no cambian ningún contador.
Comprueba lo que entendiste
6 preguntas · necesitas 5 para completar la lección
1.¿Qué provoca que Compose recomponga un composable?
2.Con el salto fuerte activado (el modo por defecto), ¿cómo se compara un parámetro inestable con el de la pasada anterior?
3.En la pantalla «Recomp.» con el interruptor apagado, pulsas «Avanzar minuto» tres veces. ¿Qué ves en las filas?
4.¿Qué hace
remember(torneo) { torneo.goleadoresConGoles() }que no hace la llamada suelta en el cuerpo?5.¿Por qué
Jugadorcuenta como tipo inestable en Marcador?6.Una tarjeta recibe los ingredientes como una
List<String>y se recompone cada vez que el padre se recompone, porque el padre crea la lista en su cuerpo. Si en cambio los recibiera como un soloStringcon el mismo texto, ¿qué pasaría?