wandres.dev
NAVEGACIÓN · estado y destinos

Navigation Compose: el NavHost, las rutas tipadas y los argumentos

Antes de decidir si la navegación es estado o efecto hay que entender qué es exactamente el objeto que se navega. Esta lección desmonta Navigation Compose desde su núcleo: el NavHost como intérprete de un grafo declarado, el NavController como dueño de una pila que sobrevive al proceso, y la migración de las rutas de cadena a las rutas con tipos seguros basadas en Serializable. Se estudia por qué los argumentos viajan en un Bundle y no en memoria, qué implica ese límite para el diseño del estado, cómo toRoute convierte una entrada de la pila en un objeto de dominio, y por qué el ViewModel puede leer sus propios argumentos sin que nadie se los pase a mano.

⏱ 18 min

Durante años, navegar en Android consistió en construir cadenas de texto con interpolación y confiar en que el destino supiera desmontarlas. La ruta detalle/42/true era a la vez una dirección, un contrato y una bomba de relojería: el compilador no sabía nada de ella, el editor no la autocompletaba y el día que alguien añadió un parámetro en medio, la aplicación siguió compilando y empezó a fallar en tiempo de ejecución. Navigation Compose corrigió eso convirtiendo los destinos en tipos y las rutas en objetos serializables, y con ello cambió algo más profundo que la ergonomía: convirtió el grafo de navegación en una estructura de datos que el compilador puede revisar. Esta lección estudia esa estructura antes de discutir su relación con MVI, porque las tres lecciones siguientes son en el fondo una sola pregunta sobre quién es el dueño de ella.

🎯 Al terminar esta lección sabrás
  • Describir el reparto de responsabilidades entre el NavHost, el NavController y la pila de retroceso.
  • Declarar destinos con tipos seguros usando clases anotadas con Serializable en lugar de cadenas.
  • Explicar por qué los argumentos viajan en un Bundle y qué restricciones impone ese canal.
  • Leer los argumentos de un destino desde la entrada de la pila y desde el propio modelo de vista.

El grafo, el anfitrión y la pila

Navigation Compose separa tres piezas que la conversación cotidiana suele fundir en una. El grafo es una descripción declarativa: una lista de destinos posibles y de cómo se construye la interfaz de cada uno. El NavHost es el componible que interpreta ese grafo y pinta, en cada momento, el destino que está en la cima. Y el NavController es el objeto imperativo que posee la pila de retroceso: la secuencia de entradas que registra por dónde ha pasado el usuario.

La pila es la pieza interesante porque no es una lista cualquiera. Cada entrada, un NavBackStackEntry, es un dueño de ciclo de vida completo: tiene su propio Lifecycle, su propio ViewModelStore y su propio SavedStateHandle. Eso significa que un modelo de vista con ámbito de destino nace cuando el destino entra en la pila y muere cuando sale de ella de verdad, no cuando queda tapado por otro. Y significa también que la pila entera se serializa cuando el sistema mata el proceso, y se reconstruye al volver.

Conviene subrayar la consecuencia práctica de que cada entrada sea un dueño de ciclo de vida completo, porque desmonta un malentendido habitual. Un destino tapado por otro no está destruido: está detenido. Su modelo de vista sigue vivo, sus corrutinas atadas al ámbito del modelo siguen corriendo y su estado sigue en memoria, listo para reaparecer intacto cuando el usuario retroceda. Lo que sí se detiene es la recolección atada al ciclo de vida, que es exactamente la garantía que necesitábamos para no ejecutar efectos con la pantalla oculta.

@Serializable
data object Inicio

@Serializable
data class Detalle(val pedidoId: String, val resaltar: Boolean = false)

@Composable
fun AppNavegacion(nav: NavHostController = rememberNavController()) {
    NavHost(navController = nav, startDestination = Inicio) {
        composable<Inicio> {
            InicioRuta(alAbrirPedido = { id -> nav.navigate(Detalle(id)) })
        }
        composable<Detalle> { entrada ->
            val args = entrada.toRoute<Detalle>()
            DetalleRuta(pedidoId = args.pedidoId, alVolver = { nav.popBackStack() })
        }
    }
}

Observa que el destino inicial no es una cadena sino una instancia. Inicio es un data object y Detalle una data class con dos campos; ambos están anotados con Serializable, y esa anotación es todo el mecanismo. La librería genera a partir del tipo la ruta, los argumentos y sus valores por defecto, de modo que navigate recibe un objeto construido por el compilador en vez de una cadena construida por ti.

flowchart LR
G[Grafo declarado] --> H[NavHost interpreta]
H --> C[NavController posee la pila]
C --> E1[Entrada Inicio]
C --> E2[Entrada Detalle]
E2 --> L[Lifecycle propio]
E2 --> V[ViewModelStore propio]
E2 --> S[SavedStateHandle propio]
style C fill:#a6e3a1,color:#11111b

De cadenas a tipos: qué se gana exactamente

La ganancia obvia es que los errores de dedo desaparecen. Si renombras un campo de Detalle, el proyecto deja de compilar en todos los sitios que lo construían, y no en el instante en que un usuario pulsa un botón. Si olvidas un argumento obligatorio, el constructor te lo exige. Si cambias el orden de dos parámetros, nada se rompe, porque ya no hay orden posicional en una cadena sino nombres de propiedad.

La ganancia menos obvia, y más importante para nuestro tema, es que el destino se convierte en un valor. Un objeto Detalle con su identificador dentro se puede guardar en una lista, comparar por igualdad estructural, pasar por un flujo, reducir dentro de un estado o probar en un test sin ningún emulador. Esta propiedad es la que hace posible, en la tercera lección, tratar la pila entera como un dato del modelo; con rutas de cadena, esa idea sería aritmética de texto.

💡
Los grafos anidados también son tipos

Para agrupar varios destinos bajo un flujo, la API ofrece un anidamiento igualmente tipado. Un data object que represente el flujo entero sirve de ruta del subgrafo, y dentro se declaran sus destinos con la misma sintaxis. El beneficio no es cosmético: el subgrafo tiene su propia entrada en la pila y por tanto su propio ViewModelStore, lo que permite un modelo de vista compartido por todo el flujo que nace al entrar y muere al salir, sin necesidad de elevarlo al ámbito de la actividad ni de inventar limpiezas manuales.

Hay una tercera ganancia que solo se aprecia en proyectos grandes y que tiene que ver con la documentación. Un grafo escrito con cadenas obliga a leer el código para saber qué argumentos admite cada destino y cuáles son opcionales; un grafo escrito con tipos publica esa información en la firma de cada clase, donde la ve el editor, la ve quien revisa y la ve cualquiera que integre un módulo ajeno. El contrato de navegación deja de estar en la cabeza del autor y pasa a estar en el código.

Conviene retener un matiz sobre la identidad de los destinos. Dos instancias de Detalle con el mismo pedidoId producen la misma ruta, y eso importa cuando se combinan con popUpTo o con la marca de destino único, porque la librería decide reutilizar o apilar según esa ruta generada. La igualdad estructural de tu data class es, en la práctica, la identidad de tu destino.

Los argumentos viajan por un canal estrecho

Aquí está la restricción que más diseños arruina cuando se descubre tarde. Los argumentos de navegación no se guardan en memoria: se serializan y viajan en un Bundle, el mismo canal que atraviesa la muerte del proceso. De ahí se derivan tres consecuencias que hay que dar por sabidas.

📦

Solo tipos serializables

Primitivos, cadenas, enumerados y tipos con soporte declarado. Un objeto de dominio con referencias vivas, un flujo o una lambda no caben en un Bundle y no deben intentar viajar por ahí.

📏

Presupuesto limitado

El transporte comparte el presupuesto de la transacción de Binder. Pasar una lista larga o un texto enorme como argumento es la vía rápida a una excepción de transacción demasiado grande en dispositivos reales.

🔑

Identificadores, no entidades

La regla práctica es pasar la semilla mínima que permite reconstruir: el identificador del pedido, no el pedido. El destino recupera el resto desde su repositorio, que es la única fuente de verdad.

Esa última tarjeta es la que conecta con todo lo estudiado sobre estado guardado. El argumento de navegación y el SavedStateHandle son el mismo canal visto desde dos alturas: uno lo llena el llamante, el otro lo llena la pantalla, pero ambos sobreviven a la aniquilación del proceso y ambos están sometidos al mismo criterio de la semilla mínima. Pasar un objeto grande porque es cómodo funciona en el emulador y falla en producción.

class DetalleViewModel(
    savedStateHandle: SavedStateHandle,
    private val repo: PedidoRepo,
) : ContainerHost<DetalleState, DetalleEfecto>, ViewModel() {

    private val args: Detalle = savedStateHandle.toRoute()

    override val container = container<DetalleState, DetalleEfecto>(
        DetalleState(pedidoId = args.pedidoId, resaltado = args.resaltar)
    ) {
        cargar()
    }

    private fun cargar() = intent {
        reduce { state.copy(cargando = true) }
        val pedido = repo.obtener(state.pedidoId)
        reduce { state.copy(cargando = false, pedido = pedido) }
    }
}

Este fragmento merece atención porque elimina una capa entera de fontanería que muchos proyectos siguen escribiendo. El modelo de vista lee sus propios argumentos desde el SavedStateHandle del destino y los convierte en su tipo con toRoute. Nadie tiene que pasárselos desde el componible, nadie escribe una fábrica a mano y nadie repite la clave del argumento en dos ficheros. El estado inicial se construye con la semilla, y el bloque de inicialización del contenedor dispara la carga.

⚠️
El argumento es la semilla, no el estado

Un error frecuente es tomar el argumento como si fuera la primera versión del estado y dejar de distinguirlos. No son lo mismo: el argumento es inmutable y describe qué se pidió ver, mientras que el estado es mutable y describe cómo están las cosas ahora. Si el usuario edita el pedido dentro de la pantalla, el estado cambia y el argumento no, y esa asimetría es correcta. El día que se confunden aparece el bug de la pantalla que se resetea al recrearse, porque alguien recalcula el estado desde el argumento en un sitio donde debería haberlo leído del estado guardado.

Dónde encaja el contenedor

Con las piezas sobre la mesa, el reparto con MVI se dibuja solo. El grafo pertenece a la capa de navegación y no sabe nada de contenedores. Cada destino tiene un componible de ruta que conoce el modelo de vista, lee el estado, consume los efectos y traduce las intenciones de salida en lambdas. Y el componible de pantalla, puro, no conoce ni el NavController ni el modelo de vista: recibe estado y devuelve interacciones.

El ámbito del modelo de vista se deriva de esa misma estructura sin que haya que declararlo. Si lo obtienes en el componible de ruta sin más, queda atado a la entrada del destino y muere con ella; si lo quieres compartido por todo un flujo, lo obtienes con el ámbito de la entrada del subgrafo y vivirá exactamente mientras el flujo esté en la pila. Elegir mal ese ámbito produce dos fallos opuestos y ambos desconcertantes: un estado que se pierde al retroceder un paso, o un estado sucio que reaparece la segunda vez que el usuario entra al flujo.

Esa frontera —la ruta conoce el grafo, la pantalla no— es la que hace posible previsualizar y probar la interfaz sin emulador, y es también la que deja abierta la pregunta de las tres lecciones siguientes: si la ruta es quien llama a navigate, ¿de dónde viene la orden? De un efecto de una sola vez, según la lección segunda; de un estado que describe la pila, según la tercera. Las dos respuestas son defendibles y ninguna es gratis.

El grafo es un lenguaje, y las rutas tipadas son su sistema de tipos

Merece la pena ver lo que ocurrió aquí con perspectiva, porque no es una mejora de API sino la repetición de un movimiento que el software ha hecho una y otra vez: pasar de un lenguaje sin tipos a uno con tipos. Las rutas de cadena eran un lenguaje interno completo —tenían sintaxis, argumentos, valores por defecto y opcionalidad— pero era un lenguaje que solo se interpretaba en tiempo de ejecución, sin comprobación estática, exactamente como la reflexión, las consultas construidas por concatenación o las claves mágicas de un mapa. Todo lenguaje interno sin tipos acaba pagando el mismo precio: los errores se descubren tarde, las refactorizaciones son manuales y la herramienta no puede ayudarte porque no entiende lo que escribes. Al convertir cada destino en una clase serializable, Navigation Compose no hizo más cómodo el mismo lenguaje: lo tipó, y con ello movió una familia entera de fallos desde el tiempo de ejecución hasta el tiempo de compilación. Pero la consecuencia más profunda para nosotros no es la seguridad sino la cosificación. Un destino que es un valor puede compararse, almacenarse, transmitirse y reducirse; una cadena interpretada por un intérprete opaco, no. Y precisamente porque ahora es un valor, la navegación se vuelve elegible para el tratamiento que MVI da a todo lo demás: puede ser el contenido de un efecto, el campo de un estado o el elemento de una lista que describe la pila entera. La lección de fondo es que un dato solo puede participar en una arquitectura si el sistema de tipos lo reconoce como dato; mientras la navegación fue texto, estuvo condenada a vivir fuera del modelo.

⚔️ Tipa un grafo real
  1. Toma un grafo tuyo con rutas de cadena y reescribe dos destinos como clases anotadas con Serializable, uno sin argumentos y otro con dos.
  2. Sustituye la lectura manual de argumentos por toRoute en la entrada de la pila y comprueba que desaparecen todas las claves de texto repetidas.
  3. Mueve esa lectura al modelo de vista usando el SavedStateHandle del destino y elimina los parámetros que antes le pasabas desde el componible.
  4. Busca en tu código algún argumento que transporte un objeto grande y redúcelo a un identificador. Explica de dónde saldrá el resto del dato.
  5. Dibuja la frontera entre tu componible de ruta y tu componible de pantalla, y verifica que el segundo no menciona el NavController en ninguna línea.