State hoisting
Al terminar: Separar composables con estado y sin estado y subir el estado al sitio correcto.
Al terminar esta lección, la pantalla del marcador estará partida en dos piezas: un composable que solo dibuja (MarcadorContent) y otro que guarda los goles (PantallaMarcador). Además, los goles sobrevivirán a rotar el móvil, cosa que en Estado en Compose se perdía. Ojo: son dos cambios independientes. Partir la pantalla (hoisting) da orden y previsualización; la rotación la arregla solo pasar de remember a rememberSaveable. Y verás el marcador 2-1 y el 7-0 en el panel de vista previa de Android Studio sin ejecutar la app.
Importa porque esta partición es la forma en que se escribe toda pantalla de Compose: el estado sube a un dueño y las pantallas que dibujan no lo crean. Temas: state hoisting, composables con estado y sin estado, flujo de datos unidireccional.
El estado sube, los eventos suben; los datos bajan
Hasta ahora, PantallaMarcador hacía tres cosas a la vez: guardaba los goles, dibujaba la tarjeta y decidía qué hacía cada botón. Eso la hace difícil de probar y de reutilizar: para ver un 7-0 tendrías que pulsar siete veces.
State hoisting («elevar el estado») es sacar el estado de un composable y dejarlo en quien lo llama. El composable pasa a recibir dos cosas:
| Parámetro | Qué es | Dirección |
|---|---|---|
estado | El valor actual que hay que pintar (del tipo que sea: un número, un texto, un dato propio) | Baja (del dueño al composable) |
onCambio: (…) -> Unit | Una función que el composable llama para decir «quiero que cambie» | Sube (del composable al dueño) |
A eso se le llama flujo de datos unidireccional (unidirectional data flow): el estado baja, los eventos suben, y nadie escribe el estado desde abajo.
Los dos tipos de composable tienen nombre:
- Con estado (stateful): crea y guarda su estado (
remember { … }dentro). Es cómodo, pero solo se puede usar con el estado que él mismo crea. - Sin estado de dominio (stateless): recibe por parámetros todos los datos del asunto de la app (el partido, sus goles, que se llaman estado de dominio). Mismos parámetros, misma pantalla. Lo único que puede guardar es estado de presentación, el que describe cómo se ve la pantalla y no qué ocurre en el partido, por ejemplo hasta dónde está desplazada la columna (
rememberScrollState()). Por eso los goles no viven aquí.
// Sin estado: dibuja lo que recibe y avisa de lo que pasa.
@Composable
fun Contador(valor: Int, onSumar: () -> Unit) { // valor baja; onSumar es el evento que sube
Button(onClick = onSumar) { Text("Goles: $valor") } // al pulsar, avisa; no cambia nada él mismo
}
// Con estado: es el dueño. Crea el valor y decide qué hace «sumar».
@Composable
fun ContadorConEstado() {
var goles by rememberSaveable { mutableIntStateOf(0) } // el estado vive aquí
Contador(valor = goles, onSumar = { goles++ }) // pasa el valor y qué hacer con el evento
}Contador se puede previsualizar con valor = 7 sin pulsar nada; ContadorConEstado no.
Error típico: dejar el estado dentro de la pieza que dibuja
No da error de compilación: da una pantalla que no se puede reutilizar ni previsualizar con otro valor.
@Composable
fun MarcadorMalo(partido: Partido) {
var golesLocal by remember { mutableIntStateOf(0) } // nadie de fuera lo ve ni lo cambia
Button(onClick = { golesLocal++ }) { Text("Gol ${partido.local.nombre}: $golesLocal") }
}@Composable
fun MarcadorBueno(partido: Partido, golesLocal: Int, onGolLocal: () -> Unit) {
Button(onClick = onGolLocal) { Text("Gol ${partido.local.nombre}: $golesLocal") }
}Falla porque el dueño del estado es quien lo crea: si es la pieza que dibuja, nadie más puede leerlo ni probarlo. La excepción es el estado de presentación, como el scroll.
Las piezas nuevas, en un ejemplo
Piezas nuevas de esta lección:
data class EstadoMarcador(val partido: Partido, val marcador: Marcador): un dato inmutable que agrupa lo que la pantalla necesita para pintarse (data class: data class e igualdad).enum class Lado { LOCAL, VISITANTE }: un tipo con una lista cerrada de valores, aquí los dos posibles de un evento («han marcado los de aquí» o «los de allí»); más en Enum y when exhaustivo.onGol: (Lado) -> Unit: una función que recibe unLadoy no devuelve nada; es lo que sube.extras: @Composable () -> Unit = {}: un slot con valor por defecto vacío, para que el contenedor cuele contenido sin queMarcadorContentlo conozca.when (lado) { … }sobre un enum: sinelse, porque cubre los dos valores.@Preview: la vista previa que viste en Compose: composables y modifiers. Solo vale con funciones sin parámetros obligatorios, por eso las vistas previas son funciones aparte que llaman al composable con un estado de ejemplo (que es justo lo que la partición permite).
Esas piezas juntas, en una votación inventada con dos opciones (no es el código de Marcador). Léelo con calma: es el modelo del ejercicio que viene después.
data class EstadoVotacion(val pregunta: String, val si: Int, val no: Int) // lo que se pinta, inmutable
enum class Voto { SI, NO } // los dos eventos posibles
@Composable
fun VotacionContent(
estado: EstadoVotacion, // baja: qué se pinta
onVotar: (Voto) -> Unit, // sube: recibe un Voto y no devuelve nada
modifier: Modifier = Modifier,
pie: @Composable () -> Unit = {}, // slot con valor por defecto: vacío si no lo pasan
) {
Column(modifier) {
Text("${estado.pregunta}: ${estado.si} sí, ${estado.no} no")
Button(onClick = { onVotar(Voto.SI) }) { Text("Sí") } // avisa de QUÉ pasó, no cambia nada
Button(onClick = { onVotar(Voto.NO) }) { Text("No") }
pie() // dibuja lo que el dueño haya metido
}
}
@Composable
fun PantallaVotacion() { // el dueño del estado
var si by rememberSaveable { mutableIntStateOf(0) }
var no by rememberSaveable { mutableIntStateOf(0) }
VotacionContent(
estado = EstadoVotacion("¿Jugamos el sábado?", si, no),
onVotar = { voto ->
when (voto) { // sin else: el enum solo tiene esos dos valores
Voto.SI -> si++
Voto.NO -> no++
}
},
pie = { Text("Vota una vez") }, // contenido que VotacionContent no conoce
)
}Lo que se ve con PantallaVotacion: el texto ¿Jugamos el sábado?: 0 sí, 0 no, los botones «Sí» y «No» y, debajo, «Vota una vez». Tras pulsar «Sí» dos veces, el texto dice 2 sí, 0 no.
Para ver en la vista previa una votación con 5 sí y 2 no, sin pulsar nada, no sirve PantallaVotacion: crea su propio estado y siempre empieza en 0. Sirve VotacionContent, pasándole el estado que quieras y un onVotar que no hace nada:
@Preview(showBackground = true) // pide a Android Studio que la dibuje en el panel
@Composable
private fun VotacionPreview() {
MaterialTheme { // el tema, para que tenga colores y tipografía
VotacionContent(
EstadoVotacion("¿Jugamos el sábado?", 5, 2), // 5 sí y 2 no, sin pulsar nada
onVotar = {}, // los eventos no hacen nada: solo se mira
)
}
}Es lo mismo que harán las dos vistas previas de MarcadorContent.
Reto · Ejercicio: el termostato
En tu carpeta ejercicios (la que creaste en La rotación y la muerte del proceso), crea la subcarpeta termostato: clic derecho sobre com.sebastiannarvaez.marcador → New → Package → escribe .ejercicios.termostato. Después, clic derecho sobre esa carpeta → New → Kotlin Class/File → File → Termostato. La primera línea debe ser package com.sebastiannarvaez.marcador.ejercicios.termostato.
Escribe cuatro piezas en Termostato.kt:
EstadoTermostato, un dato inmutable con el nombre de la sala y los grados.Ajuste, con los dos ajustes posibles: subir y bajar.TermostatoContent, sin estado propio. Recibe el estado, qué hacer con cada ajuste, un modificador opcional y un hueco opcional para un pie. Dibuja «Salón: 20 °C» en grande (con el estiloheadlineSmalldel tema, uno más grande quetitleMedium), los botones «Bajar» y «Subir» (cada uno avisa de su ajuste y no cambia nada) y, debajo, el pie.Termostato, el dueño del estado: los grados empiezan en 20, sobreviven al giro y suben o bajan uno según el ajuste que llegue. Su pie dice «Pulsa para cambiar la temperatura».
Añade tres vistas previas: el dormitorio a 17 °C, la cocina a 26 °C con el pie «Demasiado calor», y Termostato tal cual.
Lo que debes ver: tres recuadros, «Dormitorio: 17 °C», «Cocina: 26 °C» con «Demasiado calor» debajo, y «Salón: 20 °C» con «Pulsa para cambiar la temperatura». Después, en el modo interactivo (Estado en Compose), pulsa «Subir» dos veces en la tercera y luego en la primera: el salón pasa a «22 °C» y el dormitorio no cambia, porque su vista previa recibe un evento que no hace nada. Esa es la diferencia entre la pieza sin estado y su dueño.
Pistas, todo del ejemplo de la votación: la misma forma de dato, de ajuste, de pieza sin estado y de dueño; y las tres vistas previas son funciones aparte, como VotacionPreview.
Ver solución
package com.sebastiannarvaez.marcador.ejercicios.termostato
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.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
data class EstadoTermostato(val sala: String, val grados: Int) // baja: lo que se pinta
enum class Ajuste { SUBIR, BAJAR } // sube: lo que pide el usuario
@Composable
fun TermostatoContent(
estado: EstadoTermostato,
onAjuste: (Ajuste) -> Unit,
modifier: Modifier = Modifier,
pie: @Composable () -> Unit = {},
) {
Column(modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text("${estado.sala}: ${estado.grados} °C", style = MaterialTheme.typography.headlineSmall)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onAjuste(Ajuste.BAJAR) }) { Text("Bajar") }
Button(onClick = { onAjuste(Ajuste.SUBIR) }) { Text("Subir") }
}
pie()
}
}
@Composable
fun Termostato() { // el dueño del estado
var grados by rememberSaveable { mutableIntStateOf(20) }
TermostatoContent(
estado = EstadoTermostato("Salón", grados),
onAjuste = { ajuste ->
when (ajuste) {
Ajuste.SUBIR -> grados++
Ajuste.BAJAR -> grados--
}
},
pie = { Text("Pulsa para cambiar la temperatura") },
)
}
@Preview(showBackground = true)
@Composable
private fun TermostatoFrioPreview() {
MaterialTheme { TermostatoContent(EstadoTermostato("Dormitorio", 17), onAjuste = {}) }
}
@Preview(showBackground = true)
@Composable
private fun TermostatoCalorPreview() {
MaterialTheme {
TermostatoContent(
EstadoTermostato("Cocina", 26),
onAjuste = {},
pie = { Text("Demasiado calor") },
)
}
}
@Preview(showBackground = true)
@Composable
private fun TermostatoConEstadoPreview() {
MaterialTheme { Termostato() }
}Paso · partir la pantalla en MarcadorContent y el contenedor
Objetivo. Crear MarcadorContent, que no guarda nada, y hacer que PantallaMarcador sea el dueño de los goles. Al terminar la app se ve igual que antes, pero con dos diferencias: los goles sobreviven a rotar (por el rememberSaveable, no por la partición) y el panel de previsualización muestra dos marcadores (gracias a la partición).
Crear el fichero. En la vista Android, en app → kotlin+java → com.sebastiannarvaez.marcador, clic derecho sobre feature.marcador.ui (la línea donde está PantallaMarcador.kt; es la carpeta ui, dentro de marcador, dentro de feature, que Android Studio junta en una sola línea porque cada una solo tiene una hija) → New → Kotlin Class/File, escribe MarcadorContent y elige File en la lista. Va junto a PantallaMarcador.kt porque es parte de la misma pantalla. Debe abrirse un editor vacío cuya primera línea es package com.sebastiannarvaez.marcador.feature.marcador.ui. Los ficheros de los bloques de abajo son feature → marcador → ui → MarcadorContent.kt y feature → marcador → ui → PantallaMarcador.kt.
Modificar PantallaMarcador.kt (obligatorio). Tres cambios, en este orden, sin solaparse:
- Los import: cambia
androidx.compose.runtime.rememberporandroidx.compose.runtime.saveable.rememberSaveable(se explica al final del paso). - Los goles. Las dos
var golesLocalyvar golesVisitanteque estaban dentro de laColumnse mueven: sácalas de ahí, ponlas justo debajo deval partido(antes delScaffold) y cambia surememberporrememberSaveable. Debajo de ellas añadeval estado, unEstadoMarcadorcon el partido y el marcador que se pinta. - La columna. El
Columnque había dentro delScaffoldse sustituye entero (con lo que le quedaba dentro, que ya no necesita lasvar) por una llamada aMarcadorContent. Las demos de las lecciones anteriores (ComparacionDeEstado,OrdenDeLosModifiers,InsigniaSobreEscudo) pasan al parámetroextras.MarcadorContentllama aextras()dentro de suColumn, después de los botones, y por eso las tres demos salen una debajo de otra, como antes.
Aviso: no borres el import de fillMaxSize. Con estos cambios el IDE puede marcar en gris varios import, pero InsigniaSobreEscudo sigue usando fillMaxSize, y sin él no compila (el final de este paso enseña el error).
Intenta escribirlo tú, en este orden:
- En
MarcadorContent.kt:EstadoMarcador, el dato inmutable con el partido y su marcador;Lado, con los dos lados que pueden marcar; yMarcadorContent, sin estado propio, que recibe el estado, qué hacer cuando marca un lado, un modificador opcional y un hueco opcional para contenido extra. Dibuja una columna que se desplaza con la tarjeta, una fila con los dos botones de gol (cada uno avisa de su lado y no cambia nada) y, debajo, el contenido extra. Añade dos vistas previas: Rayo contra Toros con 2-1 y Lobos contra Águilas con 7-0. - En
PantallaMarcador, el dueño del estado: los dos goles, que ahora sobreviven a la rotación, el estado que se pinta, y la llamada aMarcadorContent, que suma el gol al lado que llegue y lleva las tres demos de antes como contenido extra.
Si te atascas, abre el bloque. Debes ver la misma pantalla que antes; tras 3 goles del Rayo y 1 del Toros, rotar deja 3 - 1.
Intenta primero. Cuando termines, compara con la solución.Mostrar el código
package com.sebastiannarvaez.marcador.feature.marcador.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.sebastiannarvaez.marcador.torneo.Ejemplo
import com.sebastiannarvaez.marcador.torneo.Marcador
import com.sebastiannarvaez.marcador.torneo.Partido
// Que se pinta. Es un dato inmutable: para cambiar el marcador se crea otro.
data class EstadoMarcador(val partido: Partido, val marcador: Marcador)
// Un evento de la pantalla, sin logica: «han marcado los de aqui».
enum class Lado { LOCAL, VISITANTE }
// SIN ESTADO («stateless»): mismos parametros, misma pantalla. No hay `remember`
// aqui dentro (salvo el scroll, que es estado de presentacion, no de dominio).
@Composable
fun MarcadorContent(
estado: EstadoMarcador,
onGol: (Lado) -> Unit,
modifier: Modifier = Modifier,
extras: @Composable () -> Unit = {},
) {
Column(
modifier.fillMaxSize().verticalScroll(rememberScrollState()).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
TarjetaDePartido(estado.partido, estado.marcador)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Button(onClick = { onGol(Lado.LOCAL) }) { Text("Gol ${estado.partido.local.nombre}") }
Button(onClick = { onGol(Lado.VISITANTE) }) { Text("Gol ${estado.partido.visitante.nombre}") }
}
extras()
}
}
@Preview(showBackground = true)
@Composable
private fun MarcadorContentPreview() {
MaterialTheme {
MarcadorContent(EstadoMarcador(Ejemplo.rayoContraToros, Marcador(2, 1)), onGol = {})
}
}
@Preview(showBackground = true)
@Composable
private fun MarcadorContentGoleadaPreview() {
MaterialTheme {
MarcadorContent(EstadoMarcador(Ejemplo.lobosContraAguilas, Marcador(7, 0)), onGol = {})
}
}En PantallaMarcador.kt, el import nuevo:
import androidx.compose.runtime.saveable.rememberSaveableLos goles y el estado, debajo de val partido y antes del Scaffold:
var golesLocal by rememberSaveable { mutableIntStateOf(0) }
var golesVisitante by rememberSaveable { mutableIntStateOf(0) }
val estado = EstadoMarcador(partido, Marcador(golesLocal, golesVisitante))La llamada que sustituye al Column de dentro del Scaffold (la línea del comentario SIEMPRE se usa paddingValues se queda arriba):
MarcadorContent(
estado = estado,
onGol = { lado ->
when (lado) {
Lado.LOCAL -> golesLocal++
Lado.VISITANTE -> golesVisitante++
}
},
modifier = Modifier.padding(paddingValues),
extras = {
ComparacionDeEstado()
OrdenDeLosModifiers()
InsigniaSobreEscudo()
},
)El resto de PantallaMarcador.kt no cambia.
Fíjate en qué hace cada trozo:
| Trozo | Por qué está ahí |
|---|---|
EstadoMarcador(partido, Marcador(…)) | Se crea en cada recomposición desde los dos Int: es una foto del estado, no el estado |
onGol = { lado -> … } | El único sitio donde se decide qué significa un gol |
modifier = Modifier.padding(paddingValues) | El Scaffold reserva el hueco de sus barras; el contenido debe respetarlo |
Los imports. Option+Enter sobre cada nombre en rojo propone el import. En PantallaMarcador.kt, además del cambio de remember por rememberSaveable (el de remember queda en gris y desaparece), borra los de rememberScrollState y verticalScroll, que ya no se usan en este fichero y quedan en gris. No quites fillMaxSize: InsigniaSobreEscudo lo sigue usando.
Probarlo en el emulador. Con el emulador arrancado (se abre desde View → Tool Windows → Device Manager, como en La Activity) y la configuración app elegida arriba, pulsa Run (el triángulo verde). Pulsa 3 veces «Gol Rayo FC» y 1 vez el del Toros. Luego gira el emulador con los botones de giro de la barra pegada a su ventana, o por terminal con las órdenes de Estado en Compose: la tarjeta sigue diciendo 3 - 1.
Probarlo sin emulador. Abre MarcadorContent.kt y pulsa Split (o Design) en la esquina superior derecha del editor, como en Compose: composables y modifiers. La vista previa dibuja las dos funciones @Preview: una con 2 - 1 y otra con 7 - 0. Sin pulsar nada y sin ejecutar la app. Eso es lo que no se podía hacer con el estado dentro. (Si la vista previa dice que necesita compilar, usa Build & Refresh.)
Punto de control
- Run (con la configuración
app) compila sin errores y abre la app. - En el emulador, tras 3 goles del Rayo y 1 del Toros la tarjeta dice
3 - 1, y sigue diciéndolo después de rotar. - En la vista previa de
MarcadorContent.ktaparecen las dos previews,2 - 1y7 - 0. MarcadorContent.ktno contiene ningúnrememberde goles (compruébalo con la búsqueda del editor, Cmd+F:remembersolo aparece enrememberScrollState, que es estado de presentación, y en el comentario que lo explica).
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué es state hoisting?
2.En
MarcadorContent(estado, onGol), ¿qué dirección lleva cada parámetro?3.¿Qué ventaja concreta tiene
MarcadorContentfrente a una versión con elrememberde goles dentro?4.Cambias
rememberporrememberSaveableen los goles del contenedor. ¿Qué cambia al rotar?5.Al partir la pantalla,
PantallaMarcador.ktdeja de usarfillMaxSizeen suColumn. Si borras también suimport, fallaInsigniaSobreEscudo. ¿Por qué?