Errores clásicos: Context, estado mutable y lógica de UI
Los tres fallos que arruinan un ViewModel no son accidentes de novato: son consecuencias lógicas de olvidar lo que las cuatro lecciones anteriores establecieron. Filtrar el Context es olvidar que el ViewModel vive más que cualquier vista, y convierte cada rotación en una jerarquía de vistas retenida en memoria. Exponer estado mutable es olvidar que la fuente de verdad debe tener un solo autor, y abre una puerta trasera por la que cualquier colector puede escribir sin pasar por el reductor; su variante silenciosa, la colección mutable dentro de una data class, ni siquiera emite. Y meter lógica de interfaz dentro del ViewModel es olvidar la asimetría de la frontera, atando la decisión de qué ocurre al cómo se ve. Esta lección diagnostica los tres, muestra su corrección y ofrece la regla que los previene juntos.
Los tres errores que arruinan un ViewModel comparten una virtud pedagógica: ninguno es arbitrario. Cada uno es la consecuencia exacta de olvidar una de las propiedades que estableciste en las lecciones anteriores. Si olvidas que vive más que la vista, filtrarás el Context. Si olvidas que el estado es una fuente de verdad con un solo autor, lo expondrás mutable. Si olvidas que la frontera es asimétrica, meterás dentro decisiones que pertenecían a la pantalla. Por eso conviene estudiarlos juntos y al final: no son una lista de trampas que memorizar, sino la comprobación de que has entendido de verdad qué clase de objeto es este. Y los tres tienen la misma cualidad traicionera: ninguno falla al compilar, ninguno falla en tu dispositivo de desarrollo y todos aparecen semanas después en manos ajenas.
- Diagnosticar la fuga de
Contexty saber por qué elApplicationtampoco es la solución general. - Blindar el estado para que tenga un único autor y detectar la mutación silenciosa.
- Distinguir lo que decide el
ViewModelde lo que decide la vista, y devolver cada cosa a su sitio. - Aplicar una sola regla que previene los tres a la vez.
Filtrar el Context
El primer error se sigue directamente de la lección uno. El ViewModel sobrevive a la Activity; por tanto, cualquier referencia que guarde hacia ella impide que el recolector la libere. Y una Activity retenida no es un objeto pequeño: arrastra su jerarquía de vistas completa, sus recursos, sus adaptadores y sus mapas de bits. Como el ciclo se repite en cada rotación, el usuario que gira el teléfono diez veces deja diez pantallas muertas en memoria, y el fallo se manifiesta como un consumo que crece hasta que la app se cierra sin explicación.
// Fuga: cada una de estas referencias retiene la Activity entera
class MalViewModel(
private val context: Context, // era una Activity
private val binding: PerfilBinding, // apunta a la vista
private val navController: NavController,
) : ViewModel()
La corrección tentadora es cambiar el Context de la Activity por el de la aplicación, con AndroidViewModel o inyectándolo. Elimina la fuga, sí, pero introduce un problema más silencioso: el Context de aplicación no se recrea al cambiar la configuración, así que los recursos que resuelvas con él pueden quedarse anclados a la configuración anterior. Una cadena traducida que leas ahí puede seguir en el idioma viejo tras cambiar el del sistema, y un color puede ignorar el paso a tema oscuro. Es decir: has cambiado una fuga por un error de corrección.
Conviene medir la magnitud para no tratarlo como un detalle. Una Activity retenida sujeta su ventana, su árbol de vistas completo, los mapas de bits ya decodificados de cada imagen visible, los adaptadores con sus datos y, a través de ellos, buena parte de lo que la pantalla había cargado. Hablamos de megabytes por instancia. Diez rotaciones bastan para agotar el presupuesto de memoria de una app modesta, y el fallo no aparecerá donde está la causa sino en la siguiente asignación grande que el sistema no pueda satisfacer.
La solución sana consiste en no resolver recursos en el ViewModel en absoluto. El estado no lleva textos ya traducidos ni colores ya calculados, sino la identidad de lo que hay que mostrar: un identificador de recurso con sus argumentos, o un tipo propio que represente un texto de interfaz. La vista, que sí se recrea con la configuración correcta, hace la traducción en el último momento.
// El estado nombra el mensaje; la vista lo traduce con su configuracion actual
sealed interface TextoUI {
data class Recurso(val id: Int, val args: List<Any> = emptyList()) : TextoUI
data class Plano(val valor: String) : TextoUI
}
Queda un caso legítimo que no conviene demonizar: a veces el ViewModel necesita de verdad un recurso de la plataforma —leer un fichero, consultar preferencias, comprobar la conectividad—. La respuesta no es inyectar el Context sino inyectar una interfaz propia que describa esa capacidad en el lenguaje de tu dominio, implementada fuera con lo que haga falta. Ganas dos cosas de una vez: el ViewModel deja de saber que existe Android y las pruebas pueden sustituir esa capacidad por una versión falsa sin ningún andamiaje.
No basta con revisar los campos de tu clase. Una fuga igual de común ocurre cuando un objeto de larga vida guarda una referencia hacia el ViewModel: un oyente registrado en un gestor global, una devolución de llamada entregada a un repositorio singleton, un observador que nunca se retira. Aunque tú no guardes nada de la vista, el ViewModel no podrá recolectarse tras onCleared y seguirá reteniendo su estado. La regla completa es doble: no mires solo lo que tu objeto guarda, mira también quién lo guarda a él.
Exponer estado mutable
El segundo error se sigue de la definición misma de fuente de verdad. Si el estado es único y su historia debe ser reconstruible, entonces debe tener un solo autor. Publicar el contenedor mutable rompe esa condición en el acto: cualquiera que reciba la referencia puede escribir en él sin pasar por el reductor, sin quedar registrado y sin respetar la serialización de las actualizaciones. Es una puerta trasera abierta en la única pieza que no debía tener puertas traseras.
// Mal: cualquiera puede escribir
val estado = MutableStateFlow(PerfilState())
// Bien: la escritura es privada, la lectura es publica y de solo lectura
private val _estado = MutableStateFlow(PerfilState())
val estado: StateFlow<PerfilState> = _estado.asStateFlow()
Conviene notar que asStateFlow no es cosmética: devuelve una envoltura de solo lectura, de modo que un consumidor no puede recuperar el contenedor mutable con una conversión de tipo. Y conviene notar también que Orbit hace innecesaria toda esta ceremonia, porque el container no expone ningún contenedor mutable: la única forma de cambiar el estado es un reduce dentro de un intent. Lo que arriba se consigue con disciplina, allí lo garantiza el tipo.
Existe una segunda variante, mucho más difícil de ver, que sobrevive incluso a la encapsulación correcta: la mutabilidad dentro del estado. Si tu data class contiene una lista mutable y alguien la modifica en su sitio, el objeto de estado sigue siendo el mismo, su equals sigue dando igual y el flujo, que confla por igualdad, no emite nada. La interfaz no se entera de que los datos cambiaron y aparece el bug más frustrante del catálogo: el estado está actualizado y la pantalla no lo muestra.
// Silencioso: la lista se muta en su sitio, el estado sigue siendo igual a si mismo
data class MalState(val items: MutableList<Item> = mutableListOf())
_estado.value.items.add(nuevo) // no emite: equals no ha cambiado
// Correcto: colección de solo lectura y sustitución del objeto entero
data class BienState(val items: List<Item> = emptyList())
_estado.update { it.copy(items = it.items + nuevo) }
Tipos de solo lectura
Declara las colecciones del estado como List, Set o Map, nunca sus variantes mutables, y todas las propiedades con val. La actualización se hace siempre con copy.
Estructuras profundas
Un objeto anidado mutable rompe la igualdad igual que una lista. La inmutabilidad tiene que llegar hasta la hoja del árbol, no quedarse en el primer nivel.
Un solo autor
Ninguna otra clase debe poder escribir el estado. Con Orbit se cumple por construcción; a mano, con un contenedor privado y una vista pública de solo lectura.
Lógica de UI dentro del ViewModel
El tercer error es el más discutido porque su frontera parece borrosa, y sin embargo hay un criterio nítido: el ViewModel decide qué ocurre, la vista decide cómo se ve. Todo lo que sea traducción de un hecho a una apariencia pertenece a la pantalla. Guardar en el estado un color hexadecimal, una cantidad en píxeles independientes de densidad, una constante de visibilidad de vista, una fecha ya formateada según la configuración regional o el texto exacto de un botón es cruzar esa línea, y el precio no es estético: ese estado deja de ser comprobable sin plataforma y deja de reaccionar a los cambios de configuración.
// Mal: el estado ya decidio la apariencia
data class MalState(val colorSaldo: String, val saldoFormateado: String, val visibilidad: Int)
// Bien: el estado describe hechos y la vista decide como pintarlos
data class BienState(val saldo: Long, val enNumerosRojos: Boolean, val cargando: Boolean)
flowchart LR H[Hecho de dominio: saldo negativo] --> VM[ViewModel decide QUE] VM --> ST[Estado: enNumerosRojos verdadero] ST --> V[Vista decide COMO] V --> C[Color rojo en tema claro] V --> D[Color ambar en tema oscuro] V --> A[Etiqueta accesible en el idioma actual] style VM fill:#a6e3a1,color:#11111b style V fill:#89b4fa,color:#11111b style ST fill:#f9e2af,color:#11111b
El diagrama enseña por qué la separación no es purismo: un único hecho alimenta tres presentaciones distintas que dependen de condiciones que el ViewModel no puede conocer sin volverse dependiente de la plataforma. En el momento en que el estado guardara el color, esas tres ramas colapsarían en una y el tema oscuro dejaría de funcionar sin tocar la lógica.
El caso hermano es la navegación. Que el ViewModel sostenga un NavController y llame a navegar es a la vez una fuga y una inversión de la frontera: la lógica pasa a saber qué pantallas existen y cómo se llega a ellas. Lo correcto es emitir un side effect que nombre la intención —el registro terminó con éxito— y dejar que la capa de navegación decida a dónde lleva eso, que puede ser un destino en el teléfono y otro distinto en una tableta con panel doble.
// Mal: la logica ya eligio el destino y retiene el controlador
navController.navigate("detalle/$id")
// Bien: la logica nombra el hecho, la pantalla decide a donde lleva
postSideEffect(PerfilEfecto.RegistroCompletado(usuarioId = id))
Y el más traicionero de todos: colocar un mensaje de una sola vez dentro del estado. Como el estado se reemite íntegro a cada nuevo suscriptor, ese aviso reaparecerá en la siguiente rotación, obligándote a inventar banderas de ya mostrado que ensucian el estado y nunca terminan de funcionar. Es exactamente el bug que el sideEffectFlow existía para eliminar, reintroducido a mano por confundir un hecho con una fotografía.
No hace falta esperar al fallo para detectar los tres errores. En una revisión, sospecha en cuanto veas un tipo de la plataforma en el constructor del ViewModel, una propiedad pública cuyo tipo sea mutable, o un nombre de campo de estado que contenga la palabra color, texto formateado, visibilidad o tamaño. Ninguna de las tres señales es prueba concluyente, pero las tres aciertan lo suficiente como para justificar la pregunta.
Formatear parece inofensivo y por eso se cuela. La prueba para decidir es preguntarse si el resultado depende de algo que solo la vista conoce: la configuración regional vigente, el ancho disponible, el tema, la accesibilidad. Una fecha, una moneda o un plural dependen de todo eso, así que el estado debe llevar el instante, la cantidad y el número, y la pantalla debe darles forma. En cambio, decidir si un saldo está en números rojos es una regla de negocio y pertenece al ViewModel, aunque su consecuencia visual sea un color.
Los tres errores son el mismo error contado tres veces, y verlo así vale más que memorizarlos por separado. Filtrar el Context es saber demasiado sobre dónde se está pintando. Exponer estado mutable es permitir que otros sepan demasiado sobre cómo se guarda. Meter lógica de interfaz es saber demasiado sobre cómo se verá el resultado. En los tres casos el objeto ha adquirido conocimiento que no le correspondía, y el castigo llega siempre por el mismo mecanismo: cada cosa que sabe de más es una cosa que lo ata a un contexto concreto y le impide sobrevivir a los cambios de ese contexto. Un ViewModel que conoce la Activity no sobrevive a que la Activity muera, que es lo único que se le pedía. Un estado que se puede mutar desde fuera no sobrevive a que el equipo crezca, porque tarde o temprano alguien escribirá en él desde donde no debía y el bug será irrastreable. Un estado que ya decidió el color no sobrevive a que el usuario active el tema oscuro. La ingeniería tiene una vieja intuición según la cual el poder de un componente está en lo que hace; en las fronteras de un sistema ocurre lo contrario, y su poder está en la lista de cosas que se niega a saber. Esa lista es lo que hace que el mismo objeto sirva sin tocarlo para Compose y para vistas clásicas, para el teléfono y para la tableta, para el emulador y para un test unitario que corre en milisegundos, y hoy incluso para iOS. Cuando dudes de si algo va dentro, no te preguntes si cabe: casi todo cabe. Pregúntate qué tendrías que reescribir si mañana la pantalla se pintara de otra forma. Todo lo que tendrías que tocar es, por definición, lo que nunca debió entrar.
- Busca en un proyecto real una referencia a
Context, a una vista o a un controlador de navegación dentro de unViewModel, y explica cuántas pantallas retiene cada rotación. - Convierte un mensaje ya traducido dentro del estado en una representación de texto de interfaz, y explica qué bug de configuración acabas de arreglar.
- Escribe un caso donde una colección mutable dentro del estado impida que el flujo emita, y razona por qué la igualdad es la culpable.
- Recorre una
data classde estado y elimina de ella todo lo que sea apariencia, dejando solo hechos. Justifica cada eliminación. - Sustituye una llamada de navegación directa por un side effect que nombre la intención, y describe cómo esa versión soporta un diseño de panel doble sin cambiar la lógica.