El bug del evento fosilizado: el aviso que vuelve al rotar
Guardar un evento en el estado produce el bug más reproducible y peor entendido del desarrollo Android: el snackbar que reaparece al girar la pantalla, al volver del segundo plano o al recomponer. Esta lección reconstruye el mecanismo exacto —la conflación del flujo de estado, la reemisión al nuevo suscriptor y la supervivencia del ViewModel a la destrucción de la vista— y demuestra por qué los parches históricos, desde la bandera booleana hasta SingleLiveEvent, no eliminan la causa sino que la desplazan. El bug no es un descuido: es la consecuencia lógica de pedirle a una estructura de datos una semántica que no tiene.
Todo desarrollador de Android ha visto este bug y casi ninguno ha visto su causa. La secuencia es siempre la misma: el usuario introduce mal la contraseña, aparece un aviso de error, el usuario gira el teléfono para verlo mejor y el aviso vuelve a aparecer, ahora sin que nadie haya intentado nada. La reacción típica es tratarlo como una rareza del ciclo de vida, poner un parche que borre el mensaje después de mostrarlo y seguir adelante. Pero no es una rareza ni un accidente de Android: es el comportamiento exactamente correcto de un flujo conflado al que se le ha pedido que transporte algo que no es un valor sino un acontecimiento. El aviso vuelve porque el sistema hace justo lo que prometió hacer. Entender la mecánica —paso a paso, sin metáforas— es lo que convierte este bug en una lección de diseño de tipos en vez de en una anécdota del framework.
- Reconstruir la secuencia exacta que hace reaparecer un aviso guardado en el estado.
- Explicar la conflación y la reemisión al nuevo suscriptor como causa mecánica, no como fallo.
- Identificar los tres disparadores del bug: rotación, vuelta del segundo plano y recomposición.
- Demostrar por qué las banderas, los envoltorios y
SingleLiveEventdesplazan la causa sin eliminarla.
La reconstrucción del crimen
El código acusado es inocente a primera vista y es el que casi todo el mundo escribe la primera vez.
data class LoginState(
val cargando: Boolean = false,
val error: String? = null,
)
fun entrar(usuario: String, clave: String) = intent {
reduce { state.copy(cargando = true, error = null) }
when (val r = repo.login(usuario, clave)) {
is Ok -> reduce { state.copy(cargando = false) }
is Fallo -> reduce { state.copy(cargando = false, error = r.mensaje) }
}
}
Nótese que el código incluso hace algo aparentemente cuidadoso: limpia el campo de error al empezar un intento nuevo. Ese detalle es el que da falsa confianza, porque cubre el único caso que el autor probó —dos intentos seguidos— y deja intacto el que rompe, que no depende de intentos sino del ciclo de vida.
Del lado de la vista, alguien observa state.error y, cuando no es nulo, lanza el aviso. Funciona la primera vez, y esa es justamente la trampa: el bug no se manifiesta hasta que interviene el ciclo de vida. Los actores son tres y conviene nombrarlos con precisión. El ViewModel sobrevive al cambio de configuración: es su razón de existir. Su flujo de estado es un StateFlow, es decir, un flujo conflado que mantiene siempre un valor actual y lo entrega inmediatamente a cualquier suscriptor nuevo. La vista, en cambio, se destruye y se vuelve a crear.
Merece la pena subrayar que ninguno de los tres actúa mal. El ViewModel sobrevive porque para eso se inventó, y si no sobreviviera perderíamos la petición en vuelo al girar. El flujo reemite porque su contrato es que quien se suscriba obtenga inmediatamente el valor vigente, y si no lo hiciera la pantalla nueva aparecería vacía tras la rotación. La vista muestra el aviso porque encuentra un campo no nulo, que es la única información de la que dispone. Tres comportamientos correctos componen un fallo: esa es la firma inconfundible de un error de modelado y no de implementación.
Ahora la secuencia. El login falla y el estado pasa a contener error con el mensaje. La vista lo observa y muestra el aviso. El usuario gira el teléfono: la vista muere, el ViewModel no. La vista nueva se suscribe al mismo StateFlow y este, cumpliendo su contrato, le entrega de inmediato el último valor —que sigue conteniendo el mensaje de error, porque nadie lo ha cambiado—. La vista nueva ejecuta la misma lógica que la vieja: el campo no es nulo, luego muestra el aviso. Segundo aviso, sin segundo intento de login.
sequenceDiagram participant VM as ViewModel que sobrevive participant SF as StateFlow conflado participant V1 as Vista original participant V2 as Vista tras rotar VM->>SF: reduce con error igual a sin conexion SF->>V1: emite estado con error V1->>V1: muestra el aviso una vez V1--xV1: la rotacion destruye la vista V2->>SF: se suscribe de nuevo SF->>V2: reemite el ultimo estado con el mismo error V2->>V2: muestra el aviso otra vez sin causa
Tres disparadores, una sola causa
La rotación es el caso didáctico, pero no el único ni el más frecuente en producción. Hay tres puertas por las que entra el mismo fallo, y todas comparten mecanismo: alguien vuelve a suscribirse a un flujo que retiene el pasado.
Cambio de configuración
Rotar, cambiar de tema claro a oscuro, redimensionar en pantalla partida, cambiar el idioma. La vista se recrea y se resuscribe; el estado retenido vuelve entero.
Vuelta del segundo plano
Con recolección consciente del ciclo de vida, la vista deja de coleccionar al pasar a segundo plano y vuelve a coleccionar al regresar. Esa reanudación es una nueva suscripción.
Recomposición
En Compose, una recomposición puede reevaluar el bloque que dispara el aviso. Si la condición depende de un campo del estado que sigue puesto, el aviso se vuelve a disparar.
Hay una cuarta puerta más rara y más dañina, que aparece cuando dos pantallas observan el mismo ViewModel compartido —típico en flujos con panel lateral, en pantallas grandes con lista y detalle, o en una barra inferior con estado común—. Ahí el evento fosilizado no se muestra dos veces en el tiempo sino dos veces en el espacio: ambas vistas leen el mismo campo y ambas ejecutan la orden, de modo que el usuario recibe dos avisos simultáneos o dos navegaciones apiladas. Ninguna cantidad de borrado posterior arregla algo que ya se ejecutó por duplicado.
La causa común es una sola y merece enunciarse como teorema: un valor retenido en un flujo conflado se entrega tantas veces como suscripciones haya. No es un fallo del flujo; es su contrato, y es exactamente lo que queremos para el estado real. El nombre del usuario debe volver tras rotar. El indicador de carga debe volver. El problema no está en la reemisión sino en haber metido dentro de la fotografía algo que era un disparo.
Este fallo casi nunca aparece en el desarrollo: rara vez rotas la pantalla justo después de un error. Aparece en producción, en informes imposibles de reproducir —usuarios que ven el mismo aviso tres veces, navegaciones duplicadas que apilan dos pantallas idénticas, un diálogo de pago que reaparece tras volver del gestor de tarjetas—. Y empeora con la muerte del proceso: si el estado se persiste en un SavedStateHandle, el evento fosilizado sobrevive incluso al reinicio de la app y el usuario recibe el aviso de una sesión anterior. Un evento guardado en estado persistente es un fantasma con permiso de residencia.
Por qué los parches históricos no bastan
La industria intentó resolverlo sin tocar la categoría del dato, y el resultado es un museo de epiciclos que conviene conocer para no reinventarlos.
Todos comparten una forma y conviene reconocerla antes de estudiarlos uno a uno: aceptan que el evento viva en el estado y añaden un mecanismo auxiliar que simule el olvido. Es exactamente la estrategia de los epiciclos en astronomía —salvar el modelo añadiendo correcciones en vez de cambiar la premisa— y tiene su mismo destino: cada parche cubre el caso que motivó su invención y falla en el siguiente.
La bandera con borrado manual es el primero: después de mostrar el aviso, la vista llama a un método que pone el campo a nulo. Funciona en el caso feliz y falla en todos los demás. Introduce una escritura de la vista sobre el estado, invierte el flujo unidireccional que la arquitectura existía para garantizar, y abre una carrera: si la vista se destruye entre mostrar y borrar, el evento sobrevive. Con dos observadores simultáneos, el primero en borrar deja al segundo sin evento.
// Epiciclo tipico: envoltorio con marca de consumido
class Evento<out T>(private val contenido: T) {
private var consumido = false
fun tomarSiNoConsumido(): T? =
if (consumido) null else { consumido = true; contenido }
}
El envoltorio con marca de consumido es más sofisticado y no cambia la naturaleza del problema: mete estado mutable dentro de un objeto que viaja por un flujo de valores inmutables, rompe la comparación por igualdad que hace eficiente al modelo declarativo, y sigue sin definir qué ocurre con varios coleccionistas. SingleLiveEvent hizo lo mismo a nivel de contenedor observable y arrastró su propia limitación conocida: solo entrega al primer observador y pierde silenciosamente el evento si no hay ninguno activo.
Hay un tercer parche, más moderno y más engañoso, que consiste en comparar el estado nuevo con el anterior y disparar el aviso solo cuando el campo cambia. Falla por dos sitios. Primero, tras una rotación no hay estado anterior con el que comparar: la vista es nueva y su primera lectura es siempre un cambio. Segundo, dos errores idénticos consecutivos —el mismo mensaje dos veces— no producen ningún cambio de valor, así que el segundo aviso no se muestra. El parche de la comparación falla en las dos direcciones a la vez: muestra de más cuando no debe y de menos cuando debe.
El mismo bug fuera de Android
Ayuda mucho comprobar que esto no es una peculiaridad de una plataforma sino un patrón que reaparece en cualquier sistema con un modelo observable y una vista que se reconstruye.
En React, un useEffect que dispara una navegación al ver un campo del estado se ejecuta de nuevo cuando el componente se remonta, y la solución idiomática acaba siendo limpiar ese campo. En Redux, guardar un mensaje en el store obliga a despachar una acción de limpieza que todo el mundo olvida. En Elm, la arquitectura resolvió el problema desde el diseño: los comandos son un valor devuelto por la actualización y jamás forman parte del modelo, de modo que el bug es literalmente inexpresable. En SwiftUI, un onChange sobre una propiedad publicada reintroduce el mismo fantasma que en Compose. La conclusión es incómoda para quien culpa al framework: el bug pertenece al modelo, no a la plataforma.
Que el mismo error aparezca en cuatro ecosistemas con paradigmas distintos, escritos por comunidades que no se leen entre sí, es la evidencia más fuerte de que la causa no está en ninguno de ellos. Lo que comparten los cuatro no es una librería sino una premisa: hay un modelo observable y hay una vista que puede reconstruirse a partir de él. En cuanto se acepta esa premisa —y hay que aceptarla, porque es lo que hace manejable una interfaz moderna—, meter una orden dentro del modelo produce el fantasma inevitablemente.
La comparación con Elm es la más instructiva porque enseña que el problema tiene una solución de diseño y no solo de fontanería. Allí la función de actualización devuelve un par: el modelo nuevo y una descripción del trabajo a realizar. El trabajo no cabe dentro del modelo porque son cosas de tipos distintos, y por eso ningún programador de Elm ha visto nunca un aviso que reaparece al redimensionar la ventana. La arquitectura no le pide disciplina: le impide expresar el error.
// La forma del error, independiente del framework
// El campo describe una ORDEN, no un HECHO, y por eso reaparece
data class Pantalla(val debeNavegarAHome: Boolean)
// La forma correcta: el hecho se queda, la orden se va por otro sitio
data class Pantalla2(val sesionIniciada: Boolean)
Las dos declaraciones parecen equivalentes y no lo son. debeNavegarAHome es una orden congelada: nombra lo que la vista tiene que hacer, y como el estado se relee, la orden se vuelve a leer y a cumplir. sesionIniciada es un hecho: describe el mundo, se puede releer mil veces y no ordena nada. El nombre del campo es, casi siempre, el mejor detector del bug: si un campo del estado se llama debe, mostrar, ir o disparar, ya está mal clasificado antes de que exista la lógica que lo lee.
Merece la pena detenerse en lo que este bug revela, porque revela mucho más que un descuido. Un StateFlow conflado es, en términos precisos, una celda que guarda el último valor y lo replica a cada observador; su garantía es convergencia —todo el que mire acabará viendo lo mismo— y esa garantía es lo que hace posible el modelo declarativo, porque permite repintar sin coordinación y recuperarse de cualquier destrucción de la vista sin perder información. Un canal de eventos ofrece la garantía contraria: entrega única —cada elemento llega a exactamente un consumidor y desaparece— y eso es lo que hace posible ejecutar operaciones no idempotentes con seguridad. Las dos garantías no son variantes de una misma idea sino opuestas exactas: la primera existe para que el pasado sobreviva, la segunda para que el pasado no vuelva. Cuando guardas un evento dentro del estado, estás pidiéndole a una estructura diseñada para conservar el pasado que lo olvide después de un uso, y ninguna cantidad de banderas, envoltorios ni clases auxiliares puede darte esa propiedad, porque no es un detalle de implementación que falte sino una garantía contradictoria con la que la estructura ya provee. De ahí que cada parche histórico tenga la misma forma —añadir mutabilidad oculta para simular el olvido— y el mismo destino: funcionar en el caso feliz y romperse en cuanto hay dos observadores, una carrera o una destrucción a destiempo. La lección general trasciende Android por completo: cuando un bug reaparece con formas distintas y todas sus soluciones parecen parches, es casi siempre señal de que hay dos conceptos ocupando una sola estructura, y la única salida real no es un parche mejor sino separar los conceptos. El aviso que vuelve al rotar no es un fallo del sistema; es el sistema diciéndote, con toda la claridad de la que dispone, que ese dato estaba en la caja equivocada.
- Escribe una pantalla mínima que guarde un mensaje de error en el estado, provoca el error y gira el dispositivo. Describe qué ves y por qué el flujo actuó correctamente.
- Reproduce el mismo fallo por las otras dos puertas: vuelta desde el segundo plano y recomposición. Explica qué tienen en común las tres.
- Implementa la bandera con borrado manual y luego rompe tu propia solución: destruye la vista entre mostrar y borrar, o añade un segundo observador.
- Analiza el envoltorio con marca de consumido y enumera tres propiedades que rompe, incluida la comparación por igualdad del estado.
- Explica en tus palabras por qué convergencia y entrega única son garantías incompatibles, y qué operación de tu app depende de cada una.