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

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ámetroQué esDirección
estadoEl 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: (…) -> UnitUna 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í.
ejemplo mínimo: con estado y sin estado (con comentarios)
// 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.

con el bug: el estado vive dentro
@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") }
}
corregido: el estado sube y el evento sube con él
@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 un Lado y 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 que MarcadorContent lo conozca.
  • when (lado) { … } sobre un enum: sin else, 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.

ejemplo: estado en una data class, evento con enum y un slot (con comentarios)
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:

una vista previa de la votación (con comentarios)
@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:

  1. EstadoTermostato, un dato inmutable con el nombre de la sala y los grados.
  2. Ajuste, con los dos ajustes posibles: subir y bajar.
  3. 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 estilo headlineSmall del tema, uno más grande que titleMedium), los botones «Bajar» y «Subir» (cada uno avisa de su ajuste y no cambia nada) y, debajo, el pie.
  4. 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
app/src/main/java/com/sebastiannarvaez/marcador/ejercicios/termostato/Termostato.kt
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).

app
kotlin+java → com.sebastiannarvaez.marcador
feature
marcador
ui
MarcadorContent.ktse crea
PantallaMarcador.ktse modifica
Un fichero nuevo y uno que cambia.

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:

  1. Los import: cambia androidx.compose.runtime.remember por androidx.compose.runtime.saveable.rememberSaveable (se explica al final del paso).
  2. Los goles. Las dos var golesLocal y var golesVisitante que estaban dentro de la Column se mueven: sácalas de ahí, ponlas justo debajo de val partido (antes del Scaffold) y cambia su remember por rememberSaveable. Debajo de ellas añade val estado, un EstadoMarcador con el partido y el marcador que se pinta.
  3. La columna. El Column que había dentro del Scaffold se sustituye entero (con lo que le quedaba dentro, que ya no necesita las var) por una llamada a MarcadorContent. Las demos de las lecciones anteriores (ComparacionDeEstado, OrdenDeLosModifiers, InsigniaSobreEscudo) pasan al parámetro extras. MarcadorContent llama a extras() dentro de su Column, 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:

  1. En MarcadorContent.kt: EstadoMarcador, el dato inmutable con el partido y su marcador; Lado, con los dos lados que pueden marcar; y MarcadorContent, 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.
  2. 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 a MarcadorContent, 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
app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/MarcadorContent.kt
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:

app/src/main/java/com/sebastiannarvaez/marcador/feature/marcador/ui/PantallaMarcador.kt
import androidx.compose.runtime.saveable.rememberSaveable

Los goles y el estado, debajo de val partido y antes del Scaffold:

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

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

TrozoPor 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.kt aparecen las dos previews, 2 - 1 y 7 - 0.
  • MarcadorContent.kt no contiene ningún remember de goles (compruébalo con la búsqueda del editor, Cmd+F: remember solo aparece en rememberScrollState, 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. 1.¿Qué es state hoisting?

  2. 2.En MarcadorContent(estado, onGol), ¿qué dirección lleva cada parámetro?

  3. 3.¿Qué ventaja concreta tiene MarcadorContent frente a una versión con el remember de goles dentro?

  4. 4.Cambias remember por rememberSaveable en los goles del contenedor. ¿Qué cambia al rotar?

  5. 5.Al partir la pantalla, PantallaMarcador.kt deja de usar fillMaxSize en su Column. Si borras también su import, falla InsigniaSobreEscudo. ¿Por qué?

Faltan 5 de 5