AA1.2 Eventos de un solo disparo y estado que sobrevive¶
Qué se ha ejecutado y qué no
Las partes de Kotlin puro (estado, reductores, StateFlow, eventos) se han compilado y ejecutado de verdad al construir esta web, y su salida es la real. El código de Compose y de Android (pantallas, ViewModel real, SavedStateHandle) no se ha ejecutado aquí: necesita el SDK y un emulador. Está marcado como «sin ejecutar».
¿Estado o evento?¶
No todo lo que el ViewModel le cuenta a la pantalla es un estado (algo que se mantiene). Algunas cosas son sucesos que ocurren una sola vez:
- Mostrar un aviso («Guardado»).
- Navegar a otra pantalla.
- Abrir un diálogo o pedir un permiso.
Si eso se guardara como estado normal, al girar el móvil la pantalla se recrearía, volvería a leer el estado y repetiría el aviso o la navegación. Por el contrario, si se enviara como un evento «de usar y tirar» y justo en ese instante la pantalla no está escuchando, se perdería. Es un problema clásico de Android, con tres soluciones posibles.
Las tres estrategias, ejecutadas¶
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.*
import kotlinx.coroutines.flow.*
fun main() = runBlocking {
// 1) SharedFlow sin repetición: si nadie escucha en ese momento, el evento SE PIERDE
val compartido = MutableSharedFlow<String>()
compartido.emit("Guardado") // la pantalla estaba girando: nadie escucha
val recibido1 = withTimeoutOrNull(100) { compartido.first() }
println("SharedFlow: ${recibido1 ?: "(el evento se perdió)"}")
// 2) Channel: el evento ESPERA en la cola hasta que alguien lo recoja
val canal = Channel<String>(Channel.BUFFERED)
canal.send("Guardado")
val recibido2 = withTimeoutOrNull(100) { canal.receiveAsFlow().first() }
println("Channel: ${recibido2 ?: "(perdido)"}")
// 3) Un valor dentro del estado: se queda hasta que la pantalla lo marca como mostrado
val estado = MutableStateFlow<String?>(null)
estado.value = "Guardado"
println("Estado: ${estado.value} (sigue ahí hasta que se consuma)")
estado.value = null // la pantalla avisa «ya lo mostré»
println("Tras consumir: ${estado.value}")
}
Salida:
SharedFlow: (el evento se perdió)
Channel: Guardado
Estado: Guardado (sigue ahí hasta que se consuma)
Tras consumir: null
| Estrategia | Qué pasa si la pantalla no escuchaba | Cuándo se usa |
|---|---|---|
SharedFlow sin repetición |
El evento se pierde | Avisos poco importantes; puede ser aceptable perder uno |
Channel (+ receiveAsFlow()) |
El evento espera y se entrega después | Eventos que no pueden perderse. Pero solo uno recibe cada evento |
Un campo en el UiState (mensaje: String?) |
Se queda en el estado hasta que se consume | La opción que recomienda Google hoy |
La opción recomendada: el evento como estado¶
Se guarda el aviso en el estado, la pantalla lo muestra y avisa de que ya lo ha mostrado para que el ViewModel lo borre. Así sobrevive a los giros, nunca se pierde y no se muestra dos veces.
import kotlinx.coroutines.flow.*
data class FormUiState(
val nombre: String = "",
val guardando: Boolean = false,
val mensaje: String? = null, // un aviso pendiente de mostrar
val irAtras: Boolean = false // una navegación pendiente
)
class FormModelo {
private val _estado = MutableStateFlow(FormUiState())
val estado: StateFlow<FormUiState> = _estado.asStateFlow()
fun alEscribir(texto: String) = _estado.update { it.copy(nombre = texto) }
fun guardar() {
if (_estado.value.nombre.isBlank()) {
_estado.update { it.copy(mensaje = "Escribe un nombre") }
return
}
_estado.update { it.copy(guardando = false, mensaje = "Guardado", irAtras = true) }
}
// La pantalla llama a estas dos cuando YA ha mostrado el aviso / ya ha navegado
fun mensajeMostrado() = _estado.update { it.copy(mensaje = null) }
fun navegacionHecha() = _estado.update { it.copy(irAtras = false) }
}
fun main() {
val m = FormModelo()
m.guardar()
println("1) con el nombre vacío: mensaje=${m.estado.value.mensaje}, irAtras=${m.estado.value.irAtras}")
m.mensajeMostrado()
m.alEscribir("Ana")
m.guardar()
println("2) con nombre: mensaje=${m.estado.value.mensaje}, irAtras=${m.estado.value.irAtras}")
// si ahora se gira la pantalla, el estado se conserva: el aviso sigue pendiente, no se pierde
println(" (tras girar) mensaje=${m.estado.value.mensaje}")
m.mensajeMostrado(); m.navegacionHecha()
println("3) tras consumirlos: mensaje=${m.estado.value.mensaje}, irAtras=${m.estado.value.irAtras}")
}
Salida:
1) con el nombre vacío: mensaje=Escribe un nombre, irAtras=false
2) con nombre: mensaje=Guardado, irAtras=true
(tras girar) mensaje=Guardado
3) tras consumirlos: mensaje=null, irAtras=false
Y la pantalla, con LaunchedEffect para reaccionar a cada aviso nuevo (sin ejecutar):
// Sin ejecutar: necesita Android
@Composable
fun FormRoute(vm: FormViewModel = viewModel(), alVolver: () -> Unit) {
val estado by vm.estado.collectAsStateWithLifecycle()
val snackbar = remember { SnackbarHostState() }
LaunchedEffect(estado.mensaje) {
estado.mensaje?.let {
snackbar.showSnackbar(it)
vm.mensajeMostrado() // ya lo mostré: bórralo del estado
}
}
LaunchedEffect(estado.irAtras) {
if (estado.irAtras) { alVolver(); vm.navegacionHecha() }
}
// ... Scaffold(snackbarHost = { SnackbarHost(snackbar) }) { ... }
}
Cuándo vale cada una
Para mensajes y navegación, el campo en el estado. Para algo que de verdad es un flujo de sucesos (pulsaciones, sensores, mensajes de un chat entrantes), Flow/SharedFlow es lo natural. Evita Channel para eventos de pantalla: si dos observadores lo recogen, solo uno recibe cada evento y el otro se queda sin él.
Cuando el sistema mata tu app: estado que sobrevive¶
Conviene distinguir cuatro situaciones y qué estado sobrevive a cada una:
| Qué ocurre | remember |
rememberSaveable |
ViewModel |
SavedStateHandle |
|---|---|---|---|---|
| Se recompone la pantalla | ✅ | ✅ | ✅ | ✅ |
| Giras el móvil (se recrea la actividad) | ❌ | ✅ | ✅ | ✅ |
| El sistema mata el proceso en segundo plano y vuelves | ❌ | ✅ | ❌ | ✅ |
| El usuario cierra la app (la desliza fuera) | ❌ | ❌ | ❌ | ❌ |
Dos consecuencias importantes:
- El
ViewModelno sobrevive a que el sistema mate el proceso: tuUiStatese pierde. Para lo imprescindible (el texto que el usuario está escribiendo, el elemento seleccionado, un identificador), guárdalo en elSavedStateHandle, que sí se restaura. - No metas datos grandes (listas enteras) en el estado guardado: solo caben cosas pequeñas (texto, números, identificadores). El resto se vuelve a pedir al repositorio a partir de ese identificador.
// Sin ejecutar: necesita Android
class BusquedaViewModel(private val guardado: SavedStateHandle) : ViewModel() {
// Sobrevive incluso a que el sistema mate el proceso
val consulta: StateFlow<String> = guardado.getStateFlow("consulta", "")
fun alEscribir(texto: String) { guardado["consulta"] = texto }
}
Estado de pantalla frente a estado de la interfaz¶
Una distinción fina, pero que ordena el código:
| Tipo | Ejemplo | Dónde vive |
|---|---|---|
| Estado de la lógica (datos, reglas) | Lista de tareas, usuario conectado, resultado de una búsqueda | ViewModel |
| Estado de la interfaz pura | Si un menú está desplegado, el scroll, el foco, un texto aún sin enviar | El composable (remember) |
Si algo solo afecta a cómo se ve y ninguna regla de negocio depende de ello, no hace falta subirlo al ViewModel.
Errores frecuentes¶
| Error | Cómo evitarlo |
|---|---|
Navegar o mostrar un aviso desde un StateFlow sin consumirlo |
Limpia el campo al consumirlo (mensajeMostrado()) |
SharedFlow para un aviso importante |
Un campo en el estado, para que no se pierda |
Esperar que el ViewModel sobreviva al cierre del proceso |
SavedStateHandle para lo mínimo imprescindible |
Guardar listas grandes en rememberSaveable |
Guarda un identificador y recarga el resto |
Subir al ViewModel hasta el scroll |
Estado de interfaz pura, en el composable |
Para practicar¶
Los ejercicios AA1.5 y AA1.6 de AA1 · Ejercicios practican el aviso consumible y un historial con deshacer.