Saltar al contenido
K
Jetpack ComposePaso a paso40 min de lectura · 6 min de máquina Intermedio

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 valorvar normalremember { mutableIntStateOf }rememberSaveable { mutableIntStateOf }
Lo cambiasLa pantalla no se enteraSe repintaSe repinta
RecomposiciónVuelve a 0 si la función se ejecuta otra vezSe conservaSe conserva
RotaciónVuelve a 0Vuelve a 0Se conserva
Muerte del procesoVuelve a 0Vuelve a 0Se conserva
El usuario cierra la app desde recientesVuelve a 0Vuelve a 0Vuelve 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.

ejemplo: el carrito de una panadería (con comentarios)
@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

con el bug: una var normal, la pantalla no cambia
var goles = 0
Button(onClick = { goles++ }) { Text("Gol: $goles") }   // el número no sube nunca
corregido: el valor vive en un State
var goles by remember { mutableIntStateOf(0) }
Button(onClick = { goles++ }) { Text("Gol: $goles") }   // sube y repinta

Falla 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 tocar MainActivity. Sirve para girar el móvil y, más adelante, para mirar Logcat. 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 remember de 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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/vasos_de_agua/VasosDeAgua.kt
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).

app
kotlin+java → com.sebastiannarvaez.marcador
feature
marcador
ui
PantallaMarcador.ktse modifica
laboratorio
Estado.ktse crea
Un fichero nuevo y uno modificado.

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).

  1. En la Column que hay dentro del Scaffold, la línea TarjetaDePartido(partido, partido.marcador()) se sustituye por el bloque de abajo que empieza por var golesLocal: el marcador de la tarjeta deja de salir de partido.marcador() y sale de dos estados con remember; debajo de la tarjeta van dos botones de gol y, entre ellos y OrdenDeLosModifiers(), el panel nuevo ComparacionDeEstado().
  2. Con tanto contenido la columna ya no cabe en pantalla, así que también tiene que poder desplazarse: cambia el Modifier de esa Column (el bloque «El Modifier del Column», más abajo).
  3. Las dos var … by remember van dentro de la lambda de esa Column, justo encima de la tarjeta (los composables pueden tener variables normales entre sus llamadas).
  4. Los import los propone Android Studio con Option+Enter sobre cada nombre en rojo; el de …torneo.marcador deja 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 { … } y rememberSaveable { … }: lo de esta lección.
  • Marcador(golesLocal, golesVisitante): la data class de :torneo que ya usaste (la tarjeta ya recibía un Marcador); se construye con dos números.
  • golesLocal++: dentro de onClick, escribe en el estado y provoca la recomposición.
  • Modifier.verticalScroll(rememberScrollState()): hace que la columna se pueda desplazar con el dedo. rememberScrollState() es un remember que guarda hasta dónde has desplazado.

Intenta escribirlo tú:

  1. 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.
  2. En Estado.kt, el panel ComparacionDeEstado, 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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
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.ComparacionDeEstado

El Modifier del Column, con el desplazamiento. Sustituye a la línea Modifier.fillMaxSize().padding(paddingValues).padding(16.dp),:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
            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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
            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:

app/src/main/java/com/sebastiannarvaez/marcador/laboratorio/Estado.kt
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. Es com.sebastiannarvaez.marcador.debug (el .debug lo añaden las variantes de depuración) si la variante activa de la ventana Build Variants es gratisDebug, y com.sebastiannarvaez.marcador.pro.debug si es proDebug (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 el PATH en Instalar Android Studio y el SDK). adb shell ejecuta 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.

  1. Antes de rotar verás algo como esto: tarjeta 2 - 1, var normal en 0 (el número no se movió aunque pulsaste), remember: 3, saveable: 2.

  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 1

    La primera desactiva la rotación automática y la segunda fija la orientación horizontal. Para volver, adb shell settings put system user_rotation 0 restaura la vertical y adb shell settings put system accelerometer_rotation 1 devuelve el giro automático.

  3. Mata el proceso: tras rotar, remember quedó en 0 y saveable en 2; vuelve a pulsar remember tres veces y saveable una vez, para que marquen 3 y 3. Después pulsa Home (para dejar la app en segundo plano) y ejecuta adb shell am kill com.sebastiannarvaez.marcador.debug (am es el gestor de actividades de Android; kill mata el proceso de una app que está en segundo plano); vuelve desde la lista de recientes. Con la app delante, am kill no la mata. Si prefieres volver por terminal, adb shell am start -n com.sebastiannarvaez.marcador.debug/com.sebastiannarvaez.marcador.MainActivity la abre; si responde Warning: Activity not started, its current task has been brought to the front, es correcto: recupera la tarea y restaura el estado guardado.

SituaciónTarjetavar normalremembersaveable
Antes de rotar2 - 1032
Tras rotar0 - 0002
Tras Home + am kill + volver0 - 0003

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 normal no cambia su número.
  • Tras rotar, la tarjeta y remember vuelven a 0, y saveable conserva su valor.
  • Tras Home, am kill y volver desde recientes, pasa lo mismo: la tarjeta y remember a 0, saveable conserva su valor.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.Escribes var goles = 0 dentro de un composable y Button(onClick = { goles++ }) { Text("$goles") }. Pulsas el botón. ¿Qué ves?

  2. 2.Un contador con by remember { mutableIntStateOf(0) } marca 3 y giras el móvil. ¿Qué marca después?

  3. 3.¿Qué dos situaciones sobrevive rememberSaveable { mutableIntStateOf(0) } y no sobrevive remember?

  4. 4.Quieres probar la muerte del proceso con adb shell am kill com.sebastiannarvaez.marcador.debug. ¿Qué tienes que hacer antes?

  5. 5.En una entrevista te preguntan la diferencia entre remember y rememberSaveable. ¿Qué respuesta es correcta?

Faltan 5 de 5