Grafos anidados y flujos: agrupar y devolver
Un grafo plano con treinta destinos es una lista, no una estructura, y no permite expresar la única cosa que de verdad importa a esa escala: que ciertos destinos forman una unidad con principio, final y estado propio. Esta lección estudia el subgrafo como frontera de modularidad y de ámbito, muestra cómo un flujo entero comparte un `ViewModel` sin recurrir al ámbito de la actividad, compara las dos arquitecturas posibles para pestañas con historial independiente, y examina por qué devolver un resultado con `savedStateHandle` funciona pero rara vez es la solución que deberías querer.
Un grafo de navegación crece igual que crece un archivo de configuración: sin que nadie lo decida y sin que ningún paso concreto parezca un error. Se empieza con cinco destinos y una estructura obvia, y se termina con cuarenta declaraciones seguidas en las que ya no se distingue qué pertenece a qué, quién puede llegar a dónde ni qué pasa si alguien entra por la mitad de un proceso de compra. El problema no es la longitud del archivo, que sería cosmético, sino que un grafo plano carece de vocabulario para expresar el hecho más importante de una aplicación mediana: que sus pantallas no son todas iguales ni independientes, sino que se agrupan en flujos con principio, final, estado compartido y una condición de entrada. Anidar grafos no es organizar el código en carpetas. Es dotar a la navegación de las mismas fronteras que ya existen en el producto, y descubrir que en cuanto esas fronteras se declaran, tres problemas que parecían inconexos —el ámbito de las vistas-modelo, el historial de las pestañas y la modularización por características— resultan ser el mismo problema.
- Declarar subgrafos y justificar dónde poner cada frontera con criterios de producto.
- Compartir estado en el ámbito de un flujo sin recurrir al ámbito de la actividad.
- Comparar las dos arquitecturas posibles para pestañas con historial independiente.
- Devolver resultados entre destinos y reconocer cuándo esa técnica es la equivocada.
El subgrafo como unidad
Un subgrafo es un grupo de destinos con su propio destino inicial y una identidad propia con la que se navega hacia él. Desde fuera se comporta como un único destino; desde dentro es un grafo completo.
@Serializable object FlujoCompra
@Serializable object Carrito
@Serializable object Envio
@Serializable object Pago
@Serializable object Confirmacion
NavHost(navController, startDestination = Catalogo) {
composable<Catalogo> { PantallaCatalogo() }
navigation<FlujoCompra>(startDestination = Carrito) {
composable<Carrito> { PantallaCarrito() }
composable<Envio> { PantallaEnvio() }
composable<Pago> { PantallaPago() }
composable<Confirmacion> { PantallaConfirmacion() }
}
}
Navegar hacia FlujoCompra lleva al carrito, porque un subgrafo se resuelve a su destino inicial. Y eso ya aporta algo que el grafo plano no tenía: quien está fuera del flujo no necesita saber por dónde empieza, así que el flujo puede reordenar sus pasos sin que nadie más cambie.
flowchart TD RAIZ[Grafo raiz] --> CAT[Catalogo] RAIZ --> FLUJO[Subgrafo de compra] subgraph Compra CARRO[Carrito] --> ENVIO[Envio] ENVIO --> PAGO[Pago] PAGO --> CONF[Confirmacion] end FLUJO --> CARRO CONF -.vuelve al catalogo.-> CAT style FLUJO fill:#a6e3a1,color:#11111b
La frontera correcta casi nunca es técnica. Un subgrafo debe corresponder a algo que en el producto tenga nombre propio y una condición de terminación: el proceso de compra, la incorporación de un usuario nuevo, la configuración de una cuenta. Si un grupo de pantallas no se puede describir con una frase que diga qué significa haberlo completado, probablemente no es un flujo sino una colección, y anidarlo solo añade una capa sin ganar nada.
Ámbito de flujo: el estado que dura lo que dura el proceso
Aquí aparece el beneficio que convierte al subgrafo en algo más que orden. Un proceso de varios pasos necesita estado compartido —el carrito, la dirección elegida, el método de pago— y las dos soluciones habituales son malas por motivos opuestos. Pasarlo como argumentos de destino en destino acopla cada paso al siguiente y hace imposible reordenarlos. Ponerlo en el ámbito de la actividad lo deja vivo para siempre, con lo que un usuario que abandone la compra y vuelva a entrar encuentra su estado anterior sin que nadie lo haya decidido.
El subgrafo ofrece la tercera opción, que es la correcta: un ámbito que nace al entrar en el flujo y muere al salir de él.
@Composable
fun rememberEntradaDelFlujo(
navController: NavController,
entrada: NavBackStackEntry,
): NavBackStackEntry = remember(entrada) {
navController.getBackStackEntry(FlujoCompra::class)
}
composable<Pago> { entrada ->
val padre = rememberEntradaDelFlujo(navController, entrada)
val viewModel: CompraViewModel = viewModel(padre)
PantallaPago(viewModel)
}
Los tres pasos obtienen el mismo ViewModel porque lo piden a la entrada del subgrafo y no a la suya propia. Y como esa entrada desaparece de la pila cuando el flujo entero se desapila, el estado se limpia exactamente en el momento en que el proceso termina, sin que nadie tenga que acordarse de vaciarlo.
Antes de elegir dónde vive un dato, formula cuánto debe durar. Lo que solo importa mientras se ve una pantalla va en el ámbito de esa entrada. Lo que dura un proceso de varios pasos va en el ámbito del subgrafo. Lo que sobrevive a todo va en el almacén persistente, no en un ámbito más largo. El ámbito de la actividad no es un lugar donde poner cosas, es lo que queda cuando nadie decidió, y casi todos los estados fantasma que aparecen al reabrir una aplicación vienen de ahí.
Pestañas: una pila o varias
Cuando cada pestaña debe recordar su propio recorrido, hay dos arquitecturas posibles y la elección tiene consecuencias que van mucho más allá del código.
Una pila con ramas archivadas
Un solo anfitrión y un subgrafo por pestaña. Al cambiar se desapila hasta el inicio archivando estado y se restaura al volver. Hay una única historia y el gesto atrás siempre acaba saliendo.
Un anfitrión por pestaña
Cada pestaña tiene su propio controlador y su propia pila, todas vivas a la vez. El historial de cada una es literal y el gesto atrás debe resolverse a mano en la pestaña activa.
La primera arquitectura es la recomendada y conviene entender por qué, porque el motivo no es la sencillez. Con una única pila existe una sola respuesta a la pregunta de dónde está el usuario, y de esa respuesta dependen el enlace profundo, el estado restaurado tras la muerte del proceso y el comportamiento del gesto atrás del sistema. Con varias pilas simultáneas esa pregunta pasa a tener varias respuestas parciales, y todo lo que dependía de ella hay que reconstruirlo a mano: qué pestaña estaba activa, qué había dentro de cada una, qué significa volver.
La segunda arquitectura solo compensa cuando las pestañas deben permanecer literalmente vivas en paralelo, con reproducción en curso o conexiones abiertas, y aun entonces suele ser preferible mantener ese estado fuera de la navegación y no duplicar la pila.
Hay además una decisión de producto que precede a la técnica y que conviene tomar antes de escribir nada: qué significa el gesto de volver dentro de una pestaña que no es la inicial. Las tres respuestas defendibles son salir de la aplicación, regresar a la pestaña inicial o retroceder por el historial de pestañas visitadas. Las tres se pueden implementar, ninguna es evidentemente superior, y la peor situación es la de un equipo que nunca eligió y descubre la respuesta observando qué hizo el código por accidente.
Un subgrafo no restringe el acceso a sus destinos: cualquiera puede navegar directamente al paso de pago si conoce su tipo. La agrupación expresa pertenencia, no protección. Si un paso exige que los anteriores se hayan completado, esa condición es estado de la aplicación y hay que comprobarla, no confiar en que el grafo la imponga. Los enlaces profundos entran justamente por ahí, y son la vía por la que un usuario acaba en el paso tres de un proceso que nunca empezó.
Devolver un resultado, y por qué casi nunca es lo que quieres
Un destino que selecciona algo debe comunicárselo a quien lo abrió. El mecanismo disponible usa el contenedor de estado guardado de la entrada anterior.
// En el selector, antes de desapilar.
navController.previousBackStackEntry
?.savedStateHandle
?.set("categoriaElegida", categoria.id)
navController.popBackStack()
// En el destino que espera, observando el valor.
val resultado by entrada.savedStateHandle
.getStateFlow<String?>("categoriaElegida", null)
.collectAsStateWithLifecycle()
Funciona, sobrevive a la muerte del proceso y es la vía oficial. También arrastra tres defectos que conviene tener presentes: la clave es una cadena sin verificación, el valor debe ser guardable, y el destino que espera queda acoplado a que alguien le escriba en un sitio concreto.
Por eso la pregunta previa es si el resultado debe viajar por la navegación. Si el dato pertenece al modelo —una dirección elegida, un filtro aplicado, un archivo adjuntado—, su sitio natural es el estado compartido del flujo, y el destino que lo espera simplemente observa esa fuente y descubre el cambio sin que nadie le devuelva nada. El canal de resultados queda entonces reservado para lo que de verdad es efímero y local: la respuesta de un diálogo de confirmación, la elección puntual de un selector que no tiene otro sitio donde vivir.
La diferencia entre ambos enfoques se nota en cuanto aparece un tercer interesado. Con el canal de resultados, cada nuevo consumidor exige que el selector le escriba también a él, así que el emisor acumula conocimiento de sus destinatarios. Con el estado compartido, el selector se limita a modificar el modelo y cualquier pantalla que lo observe se entera sin que nadie la haya previsto. Es la misma asimetría entre notificar y publicar que gobierna el resto de la arquitectura, y no hay razón para que la navegación sea la excepción.
Queda un matiz temporal que conviene retener: el valor devuelto persiste en el contenedor hasta que alguien lo retira. Si el destino que espera vuelve a visitarse más tarde sin que se haya producido una selección nueva, encontrará el valor anterior y actuará como si acabara de llegar. Consumir el resultado y limpiarlo en el mismo acto no es una precaución opcional, es parte del contrato.
Vale la pena reconocer que las tres cuestiones de esta lección son la misma vista desde ángulos distintos, porque esa unificación es lo que hace que el diseño de un grafo deje de ser una cuestión de gusto. Agrupar destinos, decidir cuánto vive un estado y decidir cómo se comunican dos pantallas parecen tres problemas de naturaleza diferente, y en un grafo plano lo son, porque no hay ninguna estructura que los relacione. En cuanto existen subgrafos, los tres se resuelven con la misma decisión: dónde está la frontera. La frontera define qué pantallas forman una unidad, define cuánto dura el estado que esa unidad comparte y define qué comunicaciones son internas y cuáles cruzan hacia fuera. Y esa frontera no la puede determinar ningún criterio técnico, porque no describe una propiedad del código sino una propiedad del producto: la existencia de un proceso que se puede empezar, abandonar o completar. Cuando alguien pregunta si conviene anidar un grupo de pantallas, la respuesta útil nunca sale de contar cuántas son ni de mirar el árbol de paquetes, sino de responder qué significa haber terminado. Si esa pregunta tiene respuesta, hay un flujo y su estado debe morir al terminar. Si no la tiene, hay una colección de destinos que casualmente se parecen, y agruparlos crearía un ámbito sin condición de terminación, es decir, precisamente el estado fantasma que se pretendía evitar. Por eso los grafos bien anidados se leen como un mapa del producto y no como un mapa del código, y por eso alguien ajeno al equipo puede mirar uno y entender qué hace la aplicación. Un grafo cuya estructura no se puede explicar en términos de lo que el usuario está intentando conseguir no está mal organizado: está describiendo una aplicación que nadie ha terminado de diseñar.
- Identifica en tu aplicación un grupo de destinos que tenga condición de terminación y agrúpalo en un subgrafo con su destino inicial.
- Mueve el estado compartido de ese proceso al ámbito del subgrafo y registra el instante exacto en que se limpia al salir.
- Compara ese instante con el que tendrías usando el ámbito de la actividad y describe qué se ve distinto al reabrir la aplicación.
- Implementa pestañas con historial independiente sobre una única pila y comprueba qué ocurre tras matar el proceso desde una rama profunda.
- Devuelve un resultado con el contenedor de estado guardado, y después reescribe el mismo caso observando el estado del flujo. Argumenta cuál corresponde a cada situación.