wandres.dev
ERRORES COMUNES · y cómo evitarlos

Efectos tratados como estado: el evento que vuelve al rotar

El aviso que reaparece al girar la pantalla es el fallo más reproducible del desarrollo Android y el peor clasificado: casi siempre se trata como un problema de ciclo de vida cuando es un error de categoría en el modelo. Esta lección aborda el error desde el lado del refactor. Establece un catálogo de los efectos disfrazados de campo, propone un test operativo —la reejecución de la observación— que separa hechos de órdenes sin apelar a la intuición, describe la cura mecánica campo a campo con una jerarquía sellada y un canal de entrega única, y delimita con precisión el caso contrario: los estados que parecen efectos y no lo son, como el diálogo abierto que sí debe sobrevivir a la rotación. Cierra con las tres trampas de la cura mal aplicada.

⏱ 18 min

Hay una asimetría curiosa en cómo se enseña este fallo. Todo el mundo aprende a reconocer el caso canónico —el mensaje de error guardado en el estado que vuelve a mostrarse tras rotar— y casi nadie aprende a reconocer sus catorce primos, que producen el mismo bug con nombres que no delatan nada. Un campo llamado pagoConfirmado no parece un evento; parece un hecho perfectamente respetable del dominio. Y sin embargo, si la pantalla reacciona a él lanzando una navegación, es una orden congelada y volverá a ejecutarse en cuanto alguien se resuscriba. Por eso el trabajo de nivel veinte no consiste en volver a derivar la causa del fallo, que ya está establecida, sino en adquirir un procedimiento que lo detecte sin depender de que el nombre del campo sea sospechoso. Ese procedimiento existe, cabe en una pregunta y es mecánico.

🎯 Al terminar esta lección sabrás
  • Reconocer los efectos disfrazados de campo aunque su nombre parezca describir un hecho.
  • Aplicar el test de reejecución para clasificar cualquier campo del estado sin recurrir a la intuición.
  • Ejecutar la cura completa: jerarquía sellada de efectos, canal de entrega única y consumo consciente del ciclo de vida.
  • Delimitar el caso contrario y evitar convertir en efecto algo que debía sobrevivir a la rotación.

El catálogo de los disfrazados

La versión de manual del fallo tiene un campo llamado error con un mensaje dentro, y esa versión casi nunca es la que llega a producción, porque es demasiado evidente. Las que llegan tienen otra forma y conviene tenerlas catalogadas, porque el reconocimiento por familia es mucho más rápido que el análisis caso por caso.

La primera familia es la del booleano de éxito. Campos como registroCompletado, pagoConfirmado o sesionCerrada describen algo que parece un hecho del mundo y a menudo lo es; el problema aparece cuando la pantalla los observa para hacer algo irreversible. La segunda es la del identificador de destino: un campo que guarda a dónde hay que ir, con la orden metida en su propio nombre. La tercera es la del contador de disparos, un entero que se incrementa para forzar que la interfaz reaccione, que es la confesión más clara posible de que se está usando el estado como un canal. La cuarta, la más sutil, es la de la marca temporal: guardar el instante del último evento para que la comparación con el anterior sirva de detector de cambio.

// Cuatro familias, un solo error de categoria
data class MalState(
    val pagoConfirmado: Boolean = false,      // booleano de exito
    val destinoPendiente: String? = null,     // identificador de destino
    val disparosDeAviso: Int = 0,             // contador de disparos
    val instanteUltimoError: Long? = null,    // marca temporal
)

Las cuatro comparten un rasgo que las delata mejor que cualquier nombre: todas requieren, tarde o temprano, un mecanismo de limpieza. En cuanto un estado necesita una operación cuyo único propósito es volver a poner un campo a su valor inicial para que no se vuelva a disparar, ese campo era un evento. La operación de limpieza es la confesión, y tiene además la particularidad de ser fácil de buscar automáticamente: los nombres que contienen limpiar, consumir, marcar como visto o reiniciar aparecen en el mismo sitio en casi todos los proyectos que sufren este fallo.

Hay una quinta familia que merece mención aparte porque afecta a lo que no se ve. La telemetría alojada en el estado —un campo que la pantalla observa para enviar un evento de analítica— produce el mismo fallo sin ninguna manifestación visual, de modo que nadie lo reporta y las métricas quedan infladas en un factor que depende de cuántas veces roten los usuarios. Es el único caso del catálogo en el que el bug puede sobrevivir años sin que nadie lo note, y también el único cuyo coste no se mide en experiencia de usuario sino en decisiones de producto tomadas sobre datos falsos.

⚠️
El disfraz mejora cuando el campo es legítimo a medias

Los casos difíciles no son los campos que solo sirven para disparar algo, sino los que sirven para dos cosas a la vez. sesionIniciada es un hecho impecable: describe el mundo, se puede releer mil veces y no ordena nada. Se convierte en un efecto disfrazado en el momento exacto en que alguien escribe, del lado de la vista, una reacción que navega cuando pasa a verdadero. El campo no cambió; cambió la relación que la interfaz estableció con él. Por eso la clasificación no puede hacerse leyendo solo la declaración del estado: hay que mirar quién lo lee y qué hace al leerlo. Un mismo campo puede ser hecho en una pantalla y orden en otra, y esa dependencia del consumidor es lo que hace que el catálogo de nombres sospechosos sea útil pero nunca suficiente.

El test de reejecución

Como la clasificación depende del consumidor, hace falta un criterio que hable de consumidores y no de nombres. El test es este: si la interfaz volviera a observar este mismo estado desde cero, sin que nada haya cambiado en el mundo, ¿el resultado sería idéntico? Si la respuesta es sí, el campo es un hecho y pertenece al estado. Si la respuesta es no —porque la reobservación produciría una segunda navegación, un segundo cobro, un segundo aviso o un segundo evento de analítica—, entonces no es un hecho sino una orden, y ninguna cantidad de disciplina en el consumidor la convertirá en otra cosa.

El test es formalmente una pregunta de idempotencia sobre la función que va del estado a las acciones observables. El estado, por definición, se relee cada vez que hay un suscriptor nuevo, y el número de suscriptores nuevos a lo largo de una sesión no está acotado ni es predecible. Por tanto, todo lo que dependa del estado debe tolerar ejecución repetida. Un campo cuya lectura provoca un efecto no idempotente viola esa condición estructural, y el fallo es solo cuestión de tiempo.

flowchart TB
P[Campo del estado bajo sospecha]
P --> Q{La reobservacion produce el mismo mundo}
Q -- Si --> H[Es un hecho y se queda en el estado]
Q -- No --> O[Es una orden y sale a un canal de efectos]
O --> R[Jerarquia sellada de efectos]
R --> C[Canal de entrega unica]
C --> V[Consumo consciente del ciclo de vida]
style H fill:#a6e3a1,color:#11111b
style O fill:#f38ba8,color:#11111b

Hay una variante del test más rápida de aplicar y casi igual de fiable, útil en revisión de código cuando no hay tiempo para el análisis completo: preguntarse qué pasaría si el estado se guardara en disco y se restaurase mañana. Un nombre de usuario restaurado mañana sigue siendo correcto. Una lista restaurada mañana sigue siendo correcta, aunque esté desactualizada. Una orden de navegar restaurada mañana lleva al usuario a una pantalla que pidió ayer, y una orden de mostrar un aviso restaurada mañana le enseña el error de una sesión que ya olvidó. La restauración diferida amplifica hasta lo grotesco la diferencia que la rotación solo insinúa, y por eso es un detector más sensible.

Conviene aplicarlo también en dirección contraria, porque la sobrecorrección es un riesgo real en equipos que acaban de aprender esta lección. Un diálogo visible es un hecho: si el usuario gira el teléfono con un diálogo de confirmación abierto, el diálogo debe seguir abierto, y por tanto la propiedad que lo describe pertenece al estado y debe sobrevivir a la muerte del proceso. Lo mismo vale para una hoja modal desplegada, para un elemento seleccionado o para el paso actual de un asistente. Enviar cualquiera de estos por un canal de efectos produce el bug simétrico y menos conocido: la pantalla que pierde su diálogo al rotar, que en un flujo de pago puede costar bastante más que un aviso duplicado.

🚨

Orden disfrazada

Navegar, cobrar, enviar telemetría, mostrar un aviso efímero, pedir un permiso. La reobservación duplicaría el efecto. Sale del estado sin excepciones.

🧊

Hecho que parece orden

Diálogo abierto, hoja modal desplegada, elemento seleccionado, paso del asistente. La reobservación reconstruye la misma pantalla. Se queda y además se persiste.

🔍

Caso mixto

Un hecho legítimo del que cuelga una reacción no idempotente. El campo se queda; lo que sale es la reacción, convertida en un efecto emitido por el reductor que produjo el hecho.

La cura y sus tres trampas

La cura tiene tres piezas y ninguna es opcional. La primera es una jerarquía sellada que enumera los efectos de la pantalla: al ser cerrada, el consumidor está obligado a tratarlos todos y añadir uno nuevo rompe la compilación en el sitio exacto donde hay que decidir qué hacer con él. La segunda es un transporte de entrega única, que en Orbit es postSideEffect y a mano es un Channel convertido en flujo con receiveAsFlow. La tercera es el consumo consciente del ciclo de vida, que garantiza que el efecto no se entrega mientras la pantalla no está en primer plano.

sealed interface PerfilEfecto {
    data class Aviso(val texto: TextoUI) : PerfilEfecto
    data class IrADetalle(val id: String) : PerfilEfecto
    data object PedirPermisoCamara : PerfilEfecto
}

fun confirmar() = intent {
    val r = repo.confirmar()
    reduce { state.copy(confirmado = r.exito) }   // el hecho se queda
    if (r.exito) postSideEffect(PerfilEfecto.IrADetalle(r.id))  // la orden se va
}

Conviene subrayar el detalle del ejemplo anterior porque concentra toda la lección en dos líneas: el mismo suceso produce a la vez un hecho y una orden, y cada uno va por su vía. El hecho —el pago quedó confirmado— se queda en el estado porque describe el mundo y debe sobrevivir a la rotación, a la muerte del proceso y a cualquier reconstrucción de la pantalla. La orden —lleva al usuario al detalle— sale por el canal porque su reejecución produciría una segunda navegación. Quien intenta elegir entre ambas cosas está planteando mal el problema: no compiten, son dos proyecciones distintas de un mismo suceso y ambas son necesarias.

// Consumo correcto: solo mientras la pantalla esta en primer plano
LaunchedEffect(Unit) {
    lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
        vm.container.sideEffectFlow.collect { efecto ->
            when (efecto) {
                is PerfilEfecto.Aviso -> mostrarAviso(efecto.texto)
                is PerfilEfecto.IrADetalle -> navegar(efecto.id)
                PerfilEfecto.PedirPermisoCamara -> pedirPermiso()
            }
        }
    }
}

La primera trampa es la del transporte que pierde. Un SharedFlow con repetición cero descarta la emisión si en ese instante no hay ningún colector, y hay un instante así en cada rotación. El Channel no pierde porque suspende al productor hasta que alguien recoge, pero eso tiene su propia consecuencia: si nadie recoge nunca, el intent que emitió queda suspendido indefinidamente. La elección entre ambos no es de gusto sino de qué fallo prefieres tener, y para efectos de interfaz la respuesta correcta es casi siempre el canal con capacidad acotada.

La segunda trampa es la del consumo sin conciencia del ciclo de vida. Recoger el flujo de efectos en un LaunchedEffect sin repeatOnLifecycle significa que la pantalla puede recibir una orden de navegación mientras está en segundo plano, y el resultado es una transición que ocurre fuera de la vista del usuario o directamente una excepción del controlador de navegación por estado inválido. La tercera es la más silenciosa: emitir efectos desde el reductor. El reductor debe seguir siendo una función pura del estado anterior al siguiente; si además emite, deja de ser reproducible y la única herramienta que teníamos para razonar sobre el sistema se pierde.

Efectos que no se pueden perder

La cura anterior es correcta para los efectos de presentación, que son la inmensa mayoría, y es insuficiente para una minoría que conviene identificar porque su fallo es el más caro del catálogo. Un canal de efectos vive en memoria y muere con el proceso; si el sistema mata la aplicación mientras hay un efecto pendiente, ese efecto desaparece sin dejar rastro. Para un aviso perdido eso es aceptable e incluso deseable. Para una confirmación de cobro, para el registro de una operación de auditoría o para el disparo de una sincronización que el usuario cree hecha, no lo es.

La distinción útil no es entre efectos importantes y accesorios, que es demasiado vaga para decidir nada, sino entre efectos cuyo destinatario es la pantalla y efectos cuyo destinatario es el mundo. Un aviso, una navegación o una petición de permiso solo tienen sentido si hay alguien mirando, y perderlos cuando no hay nadie es la conducta correcta. Un cobro, un envío o una escritura en un sistema externo tienen sentido con o sin pantalla, y esos no son efectos de interfaz aunque los haya disparado un botón: son trabajo de dominio que se coló en la capa equivocada porque el botón estaba cerca.

// Efecto de presentacion: si nadie mira, perderlo es correcto
postSideEffect(PagoEfecto.MostrarRecibo(id))

// Trabajo de dominio: debe sobrevivir al proceso, luego no es un efecto de UI
sincronizaciones.encolar(TareaSincronizar(pedidoId = id))

La consecuencia práctica es que el refactor de esta lección tiene dos destinos y no uno. Todo lo que salga del estado por no ser idempotente se dirige al canal de efectos si su destinatario es la pantalla, y baja a la capa de dominio con persistencia si su destinatario es el mundo. Mezclarlos produce dos fallos simétricos igual de reales: perder trabajo que había que hacer, cuando se pone en el canal lo que debía persistir; y ejecutar dos veces una acción visible, cuando se persiste lo que debía morir con la pantalla.

ℹ️
El efecto que en realidad era una transición del dominio

Antes de mover un efecto a una cola persistente conviene comprobar si el problema no era otro. Muchas veces lo que se estaba modelando como efecto —enviar el pedido, confirmar la reserva— es en realidad una transición de estado de una entidad del dominio, y lo que hace falta no es una cola de órdenes sino que la entidad tenga un estado pendiente que un proceso de reconciliación resuelva. La diferencia es grande: una cola de órdenes hay que ejecutarla exactamente una vez, que es un problema difícil, mientras que una entidad con estado pendiente se puede reconciliar tantas veces como haga falta, que es un problema fácil. Cuando puedas elegir, elige la segunda.

💡
Cómo hacer la migración sin apagar la pantalla

El refactor puede hacerse sin ventana de riesgo si se hace en tres pasos y en este orden. Primero se añade el canal de efectos y se emite el efecto nuevo además de escribir el campo antiguo, dejando la vista todavía conectada al campo. Segundo, se mueve la reacción de la vista del campo al canal, y en ese momento el bug desaparece aunque el campo siga existiendo. Tercero, se elimina el campo del estado y con él las banderas de limpieza que hubiera. Cada paso deja el proyecto funcionando y el segundo es el único que cambia el comportamiento observable, lo que convierte cualquier regresión en algo trivialmente atribuible.

La categoría no la decide el dato: la decide lo que el consumidor hace con él

La formulación habitual de esta lección dice que los eventos no van en el estado, y aunque la conclusión es correcta, la formulación oculta lo más interesante. Un dato no es un evento ni un hecho por naturaleza propia; un booleano es un booleano. Lo que determina su categoría es la relación que se establece entre él y el mundo a través del consumidor, y esa relación se puede enunciar con precisión: un dato es un hecho si la función que lo lleva de valor a consecuencia observable es idempotente, y es un evento si no lo es. Esta reformulación tiene tres virtudes que la versión intuitiva no tiene. La primera es que convierte una discusión de nomenclatura en una comprobación mecánica, aplicable por alguien que no conozca el dominio y automatizable en parte con una revisión de qué campos se leen dentro de bloques que producen efectos. La segunda es que explica por qué el mismo campo puede estar bien clasificado en una pantalla y mal en otra sin que haya ninguna contradicción, algo que la versión intuitiva no puede explicar y que confunde a todo el que se topa con ello. Y la tercera, la más importante, es que enseña dónde está de verdad la palanca de diseño: como la categoría depende del consumidor, muchas veces la reparación correcta no es sacar el campo del estado sino hacer idempotente la reacción que cuelga de él. Navegar a un destino puede hacerse idempotente si la navegación comprueba que no está ya allí; enviar telemetría puede hacerse idempotente si el evento lleva un identificador de deduplicación. Cuando eso es posible, el campo se queda donde estaba y el sistema gana una propiedad que va mucho más allá del bug que se quería arreglar, porque la idempotencia es exactamente lo que permite reintentar sin miedo, reconstruir desde cero y tolerar la duplicación que cualquier sistema distribuido acaba produciendo. Sacar el evento a un canal es la cura correcta cuando la idempotencia es imposible. Cuando es posible, es la segunda mejor.

⚔️ Clasifica, migra y rompe la cura
  1. Recorre el estado de una pantalla real y aplica el test de reejecución a cada campo, anotando para cada uno quién lo lee y qué hace al leerlo.
  2. Encuentra al menos un campo del catálogo de disfrazados cuyo nombre no delate nada y explica por qué el nombre no bastaba.
  3. Identifica un campo que parezca efecto y sea hecho, y describe qué fallo produciría enviarlo por un canal.
  4. Migra un efecto con los tres pasos de la caja de migración y comprueba en cuál de ellos cambia el comportamiento observable.
  5. Rompe deliberadamente tu propia cura de tres maneras: usa un transporte que pierda, consume sin conciencia del ciclo de vida y emite desde el reductor. Describe el fallo concreto de cada una.