Devolver resultados entre pantallas sin romper el modelo
Una pantalla que abre un selector y espera su respuesta es el caso donde más arquitecturas se rompen, porque la solución intuitiva —pasar una lambda al destino— muere con el proceso y no se puede serializar. Esta lección explica por qué el canal correcto es el estado guardado de la entrada anterior de la pila, cómo escribir y leer ese canal con tipos, y por qué su valor retenido obliga a consumirlo explícitamente para no repetir el efecto al volver del segundo plano. Se compara con las alternativas —repositorio compartido, contenedor de ámbito de flujo, resultado como argumento de vuelta— y se establece el criterio para elegir entre ellas según quién sea el dueño del dato.
El día que una pantalla necesita preguntarle algo a otra, la arquitectura enseña los dientes. La solución que primero se le ocurre a todo el mundo es pasar una función de vuelta al destino, y funciona en el emulador durante toda la tarde: el selector recibe la lambda, el usuario elige, la lambda se ejecuta y el filtro se aplica. Luego el usuario deja el selector abierto, se va a responder un mensaje, el sistema mata el proceso, vuelve diez minutos después y elige un valor que nadie recibirá jamás, porque la lambda vivía en un montón de memoria que ya no existe. El problema de devolver resultados no es de ergonomía sino de supervivencia: el canal de vuelta tiene que atravesar la muerte del proceso, y eso descarta de golpe todo lo que no se puede serializar.
- Explicar por qué una función de vuelta o una referencia directa no sirven como canal de resultado.
- Usar el estado guardado de la entrada anterior de la pila para devolver un valor tipado.
- Consumir ese resultado exactamente una vez, evitando el efecto repetido al reanudar.
- Elegir entre estado guardado, repositorio compartido y contenedor de ámbito de flujo según el dueño del dato.
Por qué la lambda no vale
La navegación en Android no es una llamada a función. Cuando abres un selector no estás invocando código que devolverá un valor: estás apilando un destino que el sistema puede destruir, restaurar y volver a crear sin avisarte. Entre la pregunta y la respuesta puede caber una rotación, un cambio de tema, una salida al segundo plano y la aniquilación completa del proceso. Cualquier canal que dependa de que un objeto siga vivo en memoria durante ese intervalo es, por construcción, un canal roto.
De ahí se sigue la exigencia mínima del canal de vuelta: debe sobrevivir a la muerte del proceso y por tanto debe ser serializable. Eso descarta las lambdas, las referencias a modelos de vista, los objetos de dominio con estado interno y cualquier registro estático de escuchas. Y deja exactamente el mismo tipo de canal que ya usábamos para los argumentos de ida: un contenedor de valores serializables asociado a una entrada de la pila.
sequenceDiagram participant A as Pantalla Lista participant P as Pila de retroceso participant B as Pantalla Selector A->>P: navigate al selector P->>B: crea la entrada Note over P: el sistema mata el proceso P->>B: restaura la entrada desde el Bundle B->>P: escribe el resultado en la entrada anterior B->>P: popBackStack P->>A: la entrada anterior vuelve a ser la cima A->>A: lee el resultado y lo consume
Hay una variante del mismo error que sobrevive más tiempo y por eso es más peligrosa: el registro estático de escuchas, ese objeto singleton donde la pantalla que pregunta se apunta y la que responde publica. Sobrevive a la rotación, lo cual da la falsa impresión de que el problema está resuelto, pero no sobrevive al proceso y además introduce una fuga silenciosa cuando alguien se apunta y nunca se borra. Que un canal aguante la rotación no dice nada sobre si aguanta lo que de verdad importa.
Fíjate en que el resultado se escribe en la entrada anterior, no en la propia. Esa asimetría es deliberada y resuelve el problema de la vida útil: la entrada del selector va a destruirse en cuanto se cierre, así que dejar ahí el valor sería tirarlo. La entrada del llamante, en cambio, sigue en la pila durante todo el intervalo y va a ser la cima justo después.
El canal correcto y su forma tipada
Cada NavBackStackEntry posee su propio SavedStateHandle, el mismo que un modelo de vista de ese destino recibe por inyección. Ese contenedor se serializa con la pila y se restaura con ella, y sirve igual de bien para lo que la pantalla guarda de sí misma que para lo que otra pantalla le deja.
object ClavesResultado {
const val FILTRO = "resultado_filtro"
}
// En el destino que responde
@Composable
fun SelectorRuta(nav: NavHostController) {
SelectorPantalla(
alElegir = { filtro ->
nav.previousBackStackEntry
?.savedStateHandle
?.set(ClavesResultado.FILTRO, filtro.name)
nav.popBackStack()
},
)
}
Dos detalles del fragmento importan más de lo que parecen. El primero es que se escribe antes de sacar el destino de la pila: al revés, la entrada anterior ya sería la cima y la escritura llegaría tarde para quien esté observando en el orden equivocado. El segundo es que lo que viaja es el nombre del enumerado, no el enumerado: el canal admite tipos serializables, y reducir el valor a una cadena o a un identificador evita sorpresas cuando el tipo evoluciona.
Las claves de texto repartidas por dos ficheros son exactamente el problema que las rutas tipadas vinieron a resolver, así que no las reintroduzcas por la puerta de atrás. Céntralas en un objeto de constantes o, mejor, envuelve el par de operaciones —escribir y consumir— en dos funciones de extensión con el tipo de resultado como parámetro. El coste es una docena de líneas y el beneficio es que el compilador vuelve a saber qué se está devolviendo y a quién.
Consumir exactamente una vez
Aquí aparece el matiz que convierte este tema en una lección de MVI y no en una receta. El estado guardado es, por diseño, un almacén retentivo: su lectura observable entrega el último valor a cada nuevo suscriptor, igual que el flujo conflado del estado. Y un resultado de navegación no es un hecho permanente sino un suceso: aplicar el filtro elegido es una operación que debe ocurrir una vez, no cada vez que la pantalla vuelve del segundo plano.
Sin esa precaución reaparece intacto el bug del evento fosilizado, con un disfraz nuevo: el usuario elige un filtro, la lista lo aplica, el usuario se va a otra aplicación y al volver la lista aplica el filtro otra vez, deshaciendo cualquier cambio que hubiera hecho después. La corrección es borrar el valor en el mismo lugar donde se consume.
@Composable
fun ListaRuta(
vm: ListaViewModel = hiltViewModel(),
entrada: NavBackStackEntry,
alAbrirSelector: () -> Unit,
) {
val estado by vm.collectAsState()
LaunchedEffect(entrada) {
entrada.savedStateHandle
.getStateFlow<String?>(ClavesResultado.FILTRO, null)
.filterNotNull()
.collect { nombre ->
vm.aplicarFiltro(Filtro.valueOf(nombre))
entrada.savedStateHandle.remove<String>(ClavesResultado.FILTRO)
}
}
ListaPantalla(estado, onFiltrar = alAbrirSelector)
}
Que la clave del efecto sea la entrada de la pila, y no el modelo de vista ni un valor recreado, es lo que impide que la colección se reinicie sin motivo en cada recomposición. La entrada es estable mientras el destino siga en la pila, que es exactamente la vida útil que queremos para este buzón: nace cuando la pantalla entra, se restaura con ella tras la muerte del proceso y desaparece cuando el destino se desapila de verdad.
La secuencia importa: se consume el valor traduciéndolo a un intent del contenedor, y solo después se retira del almacén. Retirarlo primero abriría una ventana en la que una recreación perdería el resultado; retirarlo nunca lo convierte en un fantasma que resucita. Y traducirlo a un intent, en vez de tocar el estado directamente, mantiene la regla de oro del modelo: la lógica entra por una sola puerta y el resultado de navegación no es una excepción a esa puerta.
Es tentador dejar el valor en el estado guardado y leerlo como si fuera un campo más del modelo, pero eso reproduce el error de categoría de la lección segunda con los papeles cambiados. El filtro elegido sí es estado —una vez aplicado, vive en el estado de la lista y debe repintarse cuantas veces haga falta—, pero la llegada del filtro es un suceso. El almacén de resultado es un buzón, no una propiedad: lo correcto es vaciarlo al leerlo y dejar que el dato viva a partir de ahí donde le corresponde.
Cuándo el resultado no debe viajar por ahí
El canal del estado guardado es correcto para su caso, y su caso es concreto: un dato pequeño, serializable, que una pantalla le devuelve a la que la abrió, en una relación de dos. Fuera de ese perfil, hay opciones mejores y conviene reconocerlas.
Repositorio compartido
Si el resultado modifica el dominio —se creó un pedido, se editó un perfil— no lo devuelvas: escríbelo en el repositorio y deja que la pantalla anterior lo reciba por su flujo. Es más simple y funciona con cualquier número de observadores.
Contenedor de ámbito de flujo
Para un asistente de varios pasos que construye un objeto entre todos, el resultado no es un valor de vuelta sino un estado compartido. Un contenedor con el ámbito del subgrafo nace al entrar en el flujo y muere al salir, sin fugas.
Resultado como argumento de ida
A veces lo correcto no es volver con un dato sino navegar hacia adelante con él. Si tras elegir hay que ir a una pantalla nueva, pásalo como argumento tipado del destino siguiente en lugar de devolverlo y reenviarlo.
Pila como estado
Si adoptaste el enfoque de la lección anterior, el resultado es simplemente una reducción más: el selector emite un intent que actualiza el estado y recorta la pila en la misma operación atómica.
Hay un caso frontera que conviene resolver de antemano porque genera discusiones largas: el resultado que consiste en no haber elegido nada. Si el usuario cierra el selector sin decidir, la ausencia de escritura es indistinguible de una cancelación, y hay pantallas donde esa diferencia importa —la que debe revertir un cambio provisional, por ejemplo—. La solución no es inventar un tercer canal sino modelar la cancelación como un valor más del tipo de resultado, con su propio caso explícito. Un resultado que solo sabe expresar el éxito obliga a deducir el fracaso del silencio, y deducir del silencio es siempre el principio de un fallo intermitente.
La regla que ordena las cuatro es preguntar quién es el dueño del dato. Si el dueño es el dominio, el canal es el repositorio y no hay nada que devolver. Si el dueño es el flujo, el canal es un contenedor compartido con el ámbito adecuado. Si el dueño es la pantalla que preguntó, y solo entonces, el canal es su estado guardado. Y si tu arquitectura ya posee la pila, la pregunta ni siquiera se plantea porque todo es una reducción.
Lo que hace este tema desproporcionadamente difícil respecto a su aparente trivialidad es que estamos intentando expresar una llamada a función sobre un sustrato que no tiene pila de llamadas. Cuando escribes una función que llama a otra y espera su valor, el lenguaje guarda por ti una continuación: una dirección de retorno, un marco con las variables locales y la promesa de que la ejecución seguirá exactamente donde se quedó. Toda esa maquinaria vive en memoria y presupone que el proceso sobrevive al intervalo, que es justo lo que en Android no se puede presuponer. Navegar es, en este sentido, una llamada cuya continuación puede ser destruida por un tercero en cualquier instante y reconstruida más tarde desde un montón de bytes; y una continuación reconstruible desde bytes ya no es una continuación, es un dato. De ahí sale toda la incomodidad del patrón: no estamos programando una llamada con retorno, estamos programando un intercambio de mensajes entre dos entidades que pueden morir y renacer independientemente, y ese es un modelo mucho más cercano a la mensajería distribuida que a la invocación local. Verlo así reordena las decisiones de golpe. Explica por qué la lambda falla —es una continuación en memoria—, por qué el canal debe ser serializable —es un mensaje que atraviesa una frontera de proceso— y por qué hay que consumirlo explícitamente —es un buzón, y los buzones se vacían, porque un mensaje leído dos veces es un mensaje duplicado—. La lección general es que en cuanto una frontera del sistema puede matar a uno de los interlocutores, la abstracción de la llamada deja de sostenerse y hay que bajar al nivel del mensaje. Las arquitecturas que envejecen bien son las que aceptan esa realidad desde el principio en vez de simular llamadas sobre un terreno que no las soporta.
- Localiza en tu proyecto un caso donde una pantalla devuelve algo a otra y determina si el canal actual sobrevive a la muerte del proceso.
- Reescríbelo con el estado guardado de la entrada anterior, centralizando la clave y escribiendo antes de sacar el destino de la pila.
- Añade el consumo explícito y demuestra, dejando la aplicación en segundo plano y volviendo, que el efecto no se repite.
- Traduce el resultado a un intent del contenedor en vez de tocar el estado directamente, y explica qué garantía se preserva con ello.
- Aplica la pregunta del dueño del dato a otros dos intercambios de tu aplicación y decide cuál de las cuatro alternativas les corresponde.