Estado en Compose
Al terminar: Distinguir remember, mutableStateOf y rememberSaveable: qué sobrevive a qué.
Al terminar esta lección, Marcador tendrá dos botones de gol que suben el marcador de la tarjeta, y un panel con tres contadores: uno que no se mueve en pantalla, y dos que suben igual hasta que giras el móvil o matas el proceso. Verás con tus ojos qué sobrevive a qué, y sabrás explicarlo.
Importa porque casi todos los fallos de una pantalla de Compose son de estado: un número que no se repinta, un marcador que vuelve a 0 al girar. Temas: State, mutableStateOf, remember y rememberSaveable.
Estado: un valor que la pantalla muestra y que puede cambiar
Estado es cualquier valor que puede cambiar y que la pantalla muestra: un contador, un texto escrito. Para que Compose se entere de que cambió, el valor tiene que vivir dentro de un State<T>: un contenedor observable (la T es el tipo del valor que guarda: State<Int> guarda un número entero). Se crea con mutableStateOf(valor) (o mutableIntStateOf(0), la versión para números enteros, que es más ligera y por eso se usa siempre que el estado es un Int). Cuando escribes en él, Compose vuelve a llamar a las funciones que lo leyeron.
Hay un segundo problema. Una recomposición ejecuta la función entera otra vez, así que una variable local vuelve a empezar desde su valor inicial. Por eso hay remember: guarda un objeto en la composición (la memoria del árbol de pantalla que Compose mantiene) y lo devuelve en cada recomposición. La lambda { … } solo se ejecuta la primera vez.
rememberSaveable guarda cualquier valor que quepa en un Bundle: números, textos, booleanos. Con otros tipos necesita instrucciones extra que se ven más adelante; en esta lección todo son Int. Los goles de la tarjeta, en cambio, usarán remember a propósito: verás cómo se pierden al rotar, y la lección siguiente lo arregla cambiando una palabra.
| Qué le pasa al valor | var normal | remember { mutableIntStateOf } | rememberSaveable { mutableIntStateOf } |
|---|---|---|---|
| Lo cambias | La pantalla no se entera | Se repinta | Se repinta |
| Recomposición | Vuelve a 0 si la función se ejecuta otra vez | Se conserva | Se conserva |
| Rotación | Vuelve a 0 | Vuelve a 0 | Se conserva |
| Muerte del proceso | Vuelve a 0 | Vuelve a 0 | Se conserva |
| El usuario cierra la app desde recientes | Vuelve a 0 | Vuelve a 0 | Vuelve a 0 |
En la demo, el var normal siempre muestra 0 porque nadie repinta su texto; la fila «Recomposición» describe lo que le pasaría si la función se ejecutara otra vez por otro motivo. La última fila la marca Google: rememberSaveable no conserva el estado si el usuario descarta la actividad del todo.
@Composable
fun Carrito(modifier: Modifier = Modifier) {
var unidades by rememberSaveable { mutableIntStateOf(0) } // estado que aguanta el giro: empieza en 0
Row(
modifier,
verticalAlignment = Alignment.CenterVertically, // los tres, centrados en vertical
horizontalArrangement = Arrangement.spacedBy(8.dp), // 8 dp de espacio entre ellos
) {
Button(onClick = { if (unidades > 0) unidades-- }) { Text("-") } // escribe en el estado: repinta
Text("Barras de pan: $unidades") // lee el estado
Button(onClick = { unidades++ }) { Text("+") } // escribe en el estado: repinta
}
}Lo que se ve: una fila con - Barras de pan: 0 +. Si pulsas «+» tres veces y «-» una, el texto dice Barras de pan: 2. Al girar el móvil, sigue en 2.
Si cambiaras rememberSaveable por remember, tras girar el móvil verías 0: la Activity se recrea y la composición empieza de cero. Con una variable normal, var unidades = 0, el número no pasaría nunca de 0, ni siquiera sin girar: nadie avisa a la pantalla de que la variable cambió, así que nadie repinta.
Los import de getValue y setValue hacen posible el by: son las dos operaciones que Kotlin llama al leer y escribir la variable delegada. Sin ellos, unidades++ no compila.
Error típico: una var normal o un remember donde hacía falta más
var goles = 0
Button(onClick = { goles++ }) { Text("Gol: $goles") } // el número no sube nuncavar goles by remember { mutableIntStateOf(0) }
Button(onClick = { goles++ }) { Text("Gol: $goles") } // sube y repintaFalla porque goles++ cambia una variable que Compose no observa: nadie avisa a la pantalla, y si la función se ejecutara de nuevo la variable empezaría en 0. El segundo fallo es más sutil: con remember el contador sube, pero vuelve a 0 al girar. Se corrige con rememberSaveable { mutableIntStateOf(0) }: el panel de esta lección ya lo usa en su tercer contador, y los goles de la tarjeta lo recibirán en la lección siguiente, State hoisting.
Dos herramientas de la vista previa
Para el ejercicio de abajo, la vista previa estática no basta: no pulsa nada. Android Studio trae dos herramientas más.
- Modo interactivo. Pasa el ratón por encima de una vista previa (en modo Split, como en Compose: composables y modifiers): aparece un botón con un icono de mano o de cursor, con el aviso «Start Interactive Mode» (iniciar el modo interactivo). Púlsalo y la vista previa se comporta como una app: puedes pulsar botones y corren los efectos con tiempo. Para salir, el mismo botón, ahora «Stop Interactive Mode». Si tu versión de Android Studio lo muestra con otro nombre, busca el botón con el icono de mano. Aquí no hay
Logcat: lo que quieras comprobar, míralo en pantalla. - Ejecutar una vista previa en el emulador. En el margen izquierdo del editor, junto a la línea
@Preview, hay un triángulo verde: púlsalo y elige Run '…Preview'. Android Studio instala la app y abre solo esa vista previa, a pantalla completa, sin tocarMainActivity. Sirve para girar el móvil y, más adelante, para mirarLogcat. Ojo: crea una configuración de ejecución nueva en el selector de arriba; para volver a ejecutar tu app, elige otra vez app. Para girar el emulador, usa los botones de giro de la barra pegada a la ventana del emulador (un móvil con una flecha curva); el paso de abajo lo explica también con dos órdenes de Terminal.
Reto · Ejercicio: los vasos de agua
En la carpeta ejercicios de tu proyecto (la que creaste en La rotación y la muerte del proceso), crea la subcarpeta vasos_de_agua: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.vasos_de_agua. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → VasosDeAgua. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.vasos_de_agua.
Escribe un composable VasosDeAgua y su vista previa. Lleva dos contadores que empiezan en 0, cada uno en una fila con «-», su texto y «+» (8 dp entre ellos, centrados en vertical):
- «Vasos de agua: N», que sobrevive al giro;
- «Cafés: N», que se mantiene mientras no gires el móvil y se pierde al girar (el
rememberde la tabla).
Ninguno baja de 0. Debajo, una línea que diga «Objetivo cumplido» si llevas 8 vasos o más, y si no «Te faltan N vasos».
Lo que debes ver: en la vista previa estática, «Vasos de agua: 0», «Cafés: 0» y «Te faltan 8 vasos». Primero, en el modo interactivo, pulsa tres veces el «+» de vasos y dos el de cafés: «Vasos de agua: 3», «Cafés: 2» y «Te faltan 5 vasos». Después ejecuta la vista previa en el emulador, repite las pulsaciones y gira el móvil: «Vasos de agua: 3» y «Cafés: 0». Cuando termines, elige otra vez la configuración app en el selector de arriba.
Pistas, solo con lo visto: el contador que sobrevive al giro y el que no son dos columnas distintas de la tabla de arriba, y ambos usan la delegación con sus dos import. El carrito de la panadería te sirve de modelo de la fila y del tope en 0; la última línea es un if que da un valor, como en Condiciones: if y when.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.vasos_de_agua
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@Composable
fun VasosDeAgua(modifier: Modifier = Modifier) {
var vasos by rememberSaveable { mutableIntStateOf(0) } // aguanta el giro
var cafes by remember { mutableIntStateOf(0) } // se pierde al girar
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { if (vasos > 0) vasos-- }) { Text("-") }
Text("Vasos de agua: $vasos")
Button(onClick = { vasos++ }) { Text("+") }
}
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { if (cafes > 0) cafes-- }) { Text("-") }
Text("Cafés: $cafes")
Button(onClick = { cafes++ }) { Text("+") }
}
Text(if (vasos >= 8) "Objetivo cumplido" else "Te faltan ${8 - vasos} vasos")
}
}
@Preview(showBackground = true)
@Composable
private fun VasosDeAguaPreview() {
MaterialTheme { VasosDeAgua() }
}Paso · botones de gol y la prueba de rotar
Objetivo. Que el marcador de la tarjeta deje de venir del guion y sea estado: dos botones («Gol Rayo FC», «Gol Toros») lo suben. Después, un panel con tres contadores para probar qué sobrevive. Al terminar: tras 2 goles al Rayo y 1 al Toros la tarjeta dice 2 - 1, y al girar el móvil vuelve a 0 - 0. Ese reinicio es a propósito: la lección siguiente lo arregla con una palabra (rememberSaveable).
Crear el fichero. En la vista Android, en app → kotlin+java → com.sebastiannarvaez.marcador, clic derecho sobre laboratorio (la carpeta de las pruebas que creaste en Fugas de memoria) → New → Kotlin Class/File, escribe Estado y elige File en la lista. Estado.kt es una demostración de la lección, para ver cómo funciona el estado, y por eso va en laboratorio y no en feature. Debe abrirse un editor vacío cuya primera línea es package com.sebastiannarvaez.marcador.laboratorio.
Qué cambia en PantallaMarcador.kt (obligatorio).
- En la
Columnque hay dentro delScaffold, la líneaTarjetaDePartido(partido, partido.marcador())se sustituye por el bloque de abajo que empieza porvar golesLocal: el marcador de la tarjeta deja de salir departido.marcador()y sale de dos estados conremember; debajo de la tarjeta van dos botones de gol y, entre ellos yOrdenDeLosModifiers(), el panel nuevoComparacionDeEstado(). - Con tanto contenido la columna ya no cabe en pantalla, así que también tiene que poder desplazarse: cambia el
Modifierde esaColumn(el bloque «ElModifierdelColumn», más abajo). - Las dos
var … by remembervan dentro de la lambda de esaColumn, justo encima de la tarjeta (los composables pueden tener variables normales entre sus llamadas). - Los
importlos propone Android Studio con Option+Enter sobre cada nombre en rojo; el de…torneo.marcadordeja de usarse: bórralo (el IDE lo pone en gris), para que el fichero no arrastre imports sin uso. Lo demás del fichero no cambia.
Los ficheros de los bloques de abajo son, en tu proyecto, feature → marcador → ui → PantallaMarcador.kt y laboratorio → Estado.kt.
Piezas nuevas de este paso:
mutableIntStateOf(0),remember { … }yrememberSaveable { … }: lo de esta lección.Marcador(golesLocal, golesVisitante): ladata classde:torneoque ya usaste (la tarjeta ya recibía unMarcador); se construye con dos números.golesLocal++: dentro deonClick, escribe en el estado y provoca la recomposición.Modifier.verticalScroll(rememberScrollState()): hace que la columna se pueda desplazar con el dedo.rememberScrollState()es unrememberque guarda hasta dónde has desplazado.
Intenta escribirlo tú:
- En
PantallaMarcador, una columna que se pueda desplazar; dentro, los goles del local y del visitante como estado recordado que empieza en 0, la tarjeta con ese marcador y, en una fila, dos botones, «Gol Rayo FC» y «Gol Toros» (con el nombre de cada equipo del partido), que suman un gol a su lado. Debajo, el panel nuevo. - En
Estado.kt, el panelComparacionDeEstado, con tres contadores que empiezan en 0 y se suben con su botón: uno guardado en una variable normal, otro recordado y otro recordado que además sobrevive a la rotación. Los botones dicen «var normal (NO se repinta): N», «remember: N» y «saveable: N», los dos últimos en la misma fila, y debajo, el texto «Rota el móvil o haz am kill: solo «saveable» conserva su valor» (escríbelo tal cual, con la tilde de «móvil»).
Lo que debes ver tras 2 goles al Rayo y 1 al Toros, y pulsar tres veces «remember» y dos «saveable»: la tarjeta en 2 - 1, «var normal» en 0, «remember: 3» y «saveable: 2». Si te atascas, abre el bloque.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
Cambios en PantallaMarcador.kt (el resto del fichero no cambia). Los import nuevos:
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.sebastiannarvaez.marcador.laboratorio.ComparacionDeEstadoEl Modifier del Column, con el desplazamiento. Sustituye a la línea Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),:
Modifier.fillMaxSize().padding(paddingValues).verticalScroll(rememberScrollState()).padding(16.dp),verticalScroll va antes del padding(16.dp) por lo que viste del orden de los modifiers: cada uno afecta a lo que viene después. Así el margen de 16 dp queda dentro de la zona que se desplaza y se mueve con el contenido; con el orden contrario, el margen quedaría fuera y fijo.
El bloque que empieza por var golesLocal sustituye a la línea TarjetaDePartido(partido, partido.marcador()), dentro de la Column y encima de OrdenDeLosModifiers(), que sigue igual. Son el estado, la tarjeta, los botones y el panel de comparación:
var golesLocal by remember { mutableIntStateOf(0) }
var golesVisitante by remember { mutableIntStateOf(0) }
TarjetaDePartido(partido, Marcador(golesLocal, golesVisitante))
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { golesLocal++ }) { Text("Gol ${partido.local.nombre}") }
Button(onClick = { golesVisitante++ }) { Text("Gol ${partido.visitante.nombre}") }
}
ComparacionDeEstado()Estado.kt, entero:
package com.sebastiannarvaez.marcador.laboratorio
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Button
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@Composable
fun ComparacionDeEstado(modifier: Modifier = Modifier) {
var golesSinEstado = 0
var golesRemember by remember { mutableIntStateOf(0) }
var golesSaveable by rememberSaveable { mutableIntStateOf(0) }
Column(modifier, verticalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { golesSinEstado++ }) { Text("var normal (NO se repinta): $golesSinEstado") }
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { golesRemember++ }) { Text("remember: $golesRemember") }
Button(onClick = { golesSaveable++ }) { Text("saveable: $golesSaveable") }
}
Text("Rota el móvil o haz am kill: solo «saveable» conserva su valor")
}
}Pruébalo: rotar y matar el proceso
Necesitas el emulador de las lecciones de Android. Si no lo tienes arrancado, ábrelo desde View → Tool Windows → Device Manager, con el botón de arrancar de su fila (como en La Activity). Con la configuración app elegida arriba, pulsa Run (el triángulo verde): Android Studio compila, instala y abre la app.
Para las órdenes de abajo necesitas tres cosas:
- El
applicationId, el nombre con el que Android identifica la app que se ejecuta. Escom.sebastiannarvaez.marcador.debug(el.debuglo añaden las variantes de depuración) si la variante activa de la ventana Build Variants esgratisDebug, ycom.sebastiannarvaez.marcador.pro.debugsi esproDebug(el motivo está en Gradle: flavors y variantes). En lo que sigue se escribe el primero; cambia el nombre si usas el otro. - La pestaña Terminal de Android Studio, abierta en la carpeta de tu proyecto, donde se escriben las órdenes.
adb, la herramienta de línea de comandos para hablar con el emulador (si la Terminal dice que no la encuentra, repasa cómo ponerla en elPATHen Instalar Android Studio y el SDK).adb shellejecuta una orden dentro del emulador.
am kill y am start están explicadas en Rotación y muerte del proceso; las órdenes de girar el móvil, aquí mismo, en el paso 2 de abajo.
Ábrela en el emulador. Pulsa «Gol Rayo FC» dos veces, «Gol Toros» una vez, el var normal dos veces, remember tres veces y saveable dos.
-
Antes de rotar verás algo como esto: tarjeta
2 - 1,var normalen0(el número no se movió aunque pulsaste),remember: 3,saveable: 2. -
Rota el móvil con los botones de giro de la barra pegada a la ventana del emulador (un móvil con una flecha curva), o con dos órdenes en la Terminal:
adb shell settings put system accelerometer_rotation 0 adb shell settings put system user_rotation 1La primera desactiva la rotación automática y la segunda fija la orientación horizontal. Para volver,
adb shell settings put system user_rotation 0restaura la vertical yadb shell settings put system accelerometer_rotation 1devuelve el giro automático. -
Mata el proceso: tras rotar,
rememberquedó en0ysaveableen2; vuelve a pulsarremembertres veces ysaveableuna vez, para que marquen3y3. Después pulsa Home (para dejar la app en segundo plano) y ejecutaadb shell am kill com.sebastiannarvaez.marcador.debug(ames el gestor de actividades de Android;killmata el proceso de una app que está en segundo plano); vuelve desde la lista de recientes. Con la app delante,am killno la mata. Si prefieres volver por terminal,adb shell am start -n com.sebastiannarvaez.marcador.debug/com.sebastiannarvaez.marcador.MainActivityla abre; si respondeWarning: Activity not started, its current task has been brought to the front, es correcto: recupera la tarea y restaura el estado guardado.
| Situación | Tarjeta | var normal | remember | saveable |
|---|---|---|---|---|
| Antes de rotar | 2 - 1 | 0 | 3 | 2 |
| Tras rotar | 0 - 0 | 0 | 0 | 2 |
Tras Home + am kill + volver | 0 - 0 | 0 | 0 | 3 |
Los goles de la tarjeta usan remember, por eso vuelven a 0 igual que el contador remember. Las cifras dependen de cuántas veces pulses.
La vista previa de PantallaMarcador (modo Split, como en Compose: composables y modifiers) sigue funcionando y enseña 0 - 0: la vista previa dibuja una sola vez y no pulsa nada, así que solo ves el valor inicial del estado.
Punto de control
- Run (con la configuración
app) compila sin errores y abre la app. - En el emulador, los botones de gol suben la tarjeta; el botón de
var normalno cambia su número. - Tras rotar, la tarjeta y
remembervuelven a0, ysaveableconserva su valor. - Tras Home,
am killy volver desde recientes, pasa lo mismo: la tarjeta yremembera0,saveableconserva su valor.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.Escribes
var goles = 0dentro de un composable yButton(onClick = { goles++ }) { Text("$goles") }. Pulsas el botón. ¿Qué ves?2.Un contador con
by remember { mutableIntStateOf(0) }marca 3 y giras el móvil. ¿Qué marca después?3.¿Qué dos situaciones sobrevive
rememberSaveable { mutableIntStateOf(0) }y no sobreviveremember?4.Quieres probar la muerte del proceso con
adb shell am kill com.sebastiannarvaez.marcador.debug. ¿Qué tienes que hacer antes?5.En una entrevista te preguntan la diferencia entre
rememberyrememberSaveable. ¿Qué respuesta es correcta?