wandres.dev
NAVEGACIÓN · rutas con tipos seguros

NavHost y destinos: el grafo como estado

Un grafo de navegación no es un menú de pantallas ni una tabla de rutas: es una descripción declarativa de qué destinos existen y una pila observable que describe dónde está el usuario ahora mismo. Esta lección desmonta las tres piezas del sistema, el controlador que posee la pila, el anfitrión que la dibuja y los destinos que la pueblan; explica por qué llamar a `navigate` no es dar una orden sino mutar un estado que provoca recomposición; y estudia qué convierte a cada entrada de la pila en una frontera real de ciclo de vida, de estado guardado y de ámbito para los `ViewModel`.

⏱ 20 min

Durante una década la navegación en Android se pensó como una secuencia de órdenes: abre esta pantalla, cierra aquella, lanza esta otra esperando un resultado. Cada llamada era un acto irreversible sobre un sistema que guardaba su propia memoria en un lugar al que el programador no tenía acceso directo, y por eso las preguntas difíciles nunca tenían buena respuesta: ¿dónde estoy exactamente?, ¿qué pasa si el proceso muere aquí?, ¿cómo escribo una prueba de esto? Compose cambia el planteamiento por completo, y el cambio es más profundo que el de sustituir fragmentos por funciones componibles. La navegación deja de ser una secuencia de órdenes y pasa a ser un estado: una lista de entradas que alguien posee, que se puede leer, que se puede serializar y que, al mutar, provoca que la interfaz se redibuje sola. Entender esa inversión —que no navegas hacia una pantalla, sino que cambias un estado del que se deriva qué pantalla se ve— es lo que separa a quien usa la biblioteca de quien la comprende, y lo que hace que todo lo demás del nivel encaje sin esfuerzo.

🎯 Al terminar esta lección sabrás
  • Distinguir los papeles del controlador, del anfitrión y del grafo, y quién posee qué en cada uno.
  • Explicar por qué la navegación es estado observable y qué consecuencias tiene esa afirmación.
  • Reconocer cada entrada de la pila como frontera de ciclo de vida, de estado guardado y de ámbito.
  • Decidir dónde vive el controlador y por qué no debe repartirse por el árbol de composición.

Las tres piezas y quién posee qué

El sistema se reparte en tres responsabilidades que conviene no mezclar nunca. El controlador es el objeto que posee la pila: sabe qué entradas hay, en qué orden y cuál está en la cima. El grafo es la declaración de qué destinos existen y cómo se identifican; se construye una vez y no cambia durante la ejecución. Y el anfitrión, NavHost, es la función componible que observa la cima de la pila y dibuja el contenido correspondiente.

@Composable
fun AppRoot() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = "catalogo",
    ) {
        composable("catalogo") { PantallaCatalogo() }
        composable("ajustes") { PantallaAjustes() }
    }
}

El bloque que sigue a NavHost no es contenido componible: es un constructor de grafo. Se ejecuta una sola vez para registrar los destinos, y las lambdas que se pasan a composable son las que se invocarán más tarde, cuando esa entrada esté en la cima. Confundir ambos momentos es el origen del primer error clásico: escribir lógica dentro del constructor esperando que se ejecute en cada navegación.

Dentro de ese constructor existen tres registros distintos y conviene no mezclarlos. composable declara un destino que ocupa la pantalla entera. dialog declara uno que se dibuja superpuesto, dejando visible lo que hay debajo, pero que sigue siendo una entrada de pleno derecho en la pila y por tanto se cierra con el gesto de volver sin que nadie lo programe. Y navigation declara un subgrafo, que es el tema de la cuarta lección. Los tres comparten la misma naturaleza: describen qué existe, no qué se ve ahora.

Esa distinción entre lo declarado y lo actual explica una propiedad que sorprende al principio: el grafo es estático. No se añaden ni se quitan destinos en tiempo de ejecución en función del estado. Si un destino solo debe existir para cierto tipo de usuario, lo que cambia no es el grafo sino qué grafo se monta, o bien la comprobación ocurre dentro del propio destino. Un grafo que se reconstruye a sí mismo mientras el usuario navega es la manera más rápida de perder la pila entera en una recomposición.

flowchart TD
UI[Un gesto del usuario] --> CALL[Llamada a navigate con una ruta]
CALL --> CTRL[El controlador muta la pila]
CTRL --> STATE[La pila es estado observable]
STATE --> HOST[NavHost recompone]
HOST --> DEST[Se dibuja el destino de la cima]
style STATE fill:#a6e3a1,color:#11111b

El diagrama fija el punto esencial y el orden causal correcto. No hay ninguna flecha directa entre la llamada y el dibujado. Entre una y otro se interpone un estado, y ese estado es la única fuente de verdad sobre dónde está el usuario.

La consecuencia inmediata es que la navegación deja de ser observable como suceso y pasa a serlo como situación. No hay ningún momento en el que se pueda decir estoy navegando, del mismo modo que en una interfaz declarativa no hay ningún momento en el que se pueda decir estoy actualizando el texto: hay un valor antes, un valor después y una recomposición que hace coincidir la pantalla con el segundo. Quien busca en la biblioteca un punto donde intervenir durante la transición está buscando una etapa que el modelo eliminó a propósito.

La navegación es estado, no una orden

La afirmación suena abstracta hasta que se enumeran sus consecuencias prácticas, que son cinco y todas verificables.

Conviene enunciarlas de golpe porque juntas explican casi todo lo que en el modelo anterior había que resolver a mano, y porque cada una elimina una categoría entera de código que antes alguien tenía que escribir y mantener.

La primera es que la navegación es idempotente frente a la recomposición. El anfitrión puede recomponerse cien veces sin que ocurra nada raro, porque no está ejecutando transiciones sino derivando contenido de un estado. La segunda es que la pila es inspeccionable: se puede leer la entrada actual, comprobar en una prueba que después de un gesto la ruta esperada está en la cima, y hacerlo sin capturar pantallas ni esperar animaciones. La tercera es que es serializable: al morir el proceso, lo que se guarda no es una promesa de reabrir pantallas, sino la lista de rutas y sus argumentos, que basta para reconstruir la situación exacta. Esta propiedad es la que hace que la restauración sea fiel por construcción y no por esfuerzo, y también la que impone la restricción de que todo argumento de navegación deba poder guardarse.

La cuarta consecuencia es que la interfaz puede reaccionar a la posición actual sin que nadie se lo notifique explícitamente, porque la posición es un estado más que se puede observar.

val entradaActual by navController.currentBackStackEntryAsState()
val rutaActual = entradaActual?.destination?.route

Scaffold(
    bottomBar = {
        if (rutaActual in rutasConBarra) BarraInferior(rutaActual)
    },
) { padding -> Contenido(padding) }

Este fragmento merece una lectura atenta porque contradice un hábito muy arraigado. La barra inferior no se muestra porque alguien la haya activado al navegar: se muestra porque la ruta actual pertenece a un conjunto. Nadie notifica nada, nadie recuerda encenderla ni apagarla, y por tanto no existe el estado inconsistente en el que la barra sobrevive a una pantalla que no debía tenerla. Es el mismo razonamiento que se aplica a cualquier otro estado derivado, solo que ahora también vale para la posición del usuario.

La quinta consecuencia, y la más importante para el diseño, es que la navegación se vuelve una función del estado de la aplicación en lugar de un efecto secundario disperso. Si el usuario cierra sesión, no hay que recordar desde cuántos sitios distintos podría estar viéndose la aplicación para cerrarlos uno a uno: basta con que el estado de sesión determine qué grafo se muestra.

Hay una consecuencia negativa que conviene anticipar, porque es el error de principiante más habitual con este modelo. Como navegar se siente declarativo, es tentador escribirlo dentro del cuerpo de un componible, condicionado a algún estado. Eso produce navegaciones duplicadas en cuanto haya dos recomposiciones antes de que la pila se actualice, y navegaciones perdidas si el componible abandona la composición a mitad. Mutar la pila es un efecto, y como todo efecto debe ocurrir en una lambda de evento o dentro de un efecto lanzado con clave estable, nunca como parte de la evaluación del contenido.

💡
Los eventos van hacia arriba, la navegación se queda arriba

Un destino no debe recibir el controlador como parámetro. Debe recibir funciones sin retorno que describan lo que ocurrió —se pulsó un producto, se completó el formulario— y dejar que quien construyó el grafo decida a dónde lleva eso. La regla no es estética: un componible que no conoce el controlador se puede previsualizar, probar y reutilizar en otro grafo sin tocarlo, y la topología de la aplicación queda escrita en un solo archivo en vez de repartida por veinte pantallas.

Cada entrada es una frontera real

La pila no guarda cadenas de texto. Cada elemento es una entrada viva que implementa tres contratos del sistema a la vez, y de ahí sale casi todo el comportamiento que a primera vista parece magia. Merece la pena aprenderlos juntos, porque casi ninguna duda sobre qué sobrevive a qué necesita más información que estas tres líneas.

🔄

Dueña de ciclo de vida

La entrada de la cima llega a estado activo; las de debajo bajan a detenido sin destruirse. Un efecto lanzado dentro de un destino se cancela al abandonarlo y no sigue trabajando bajo la pantalla siguiente.

🧠

Dueña de almacén de vista-modelo

Un ViewModel obtenido dentro de un destino vive exactamente lo que vive esa entrada, y se limpia cuando la entrada sale de la pila para no volver. Ni antes ni después.

💾

Dueña de estado guardado

Cada entrada tiene su propio contenedor de estado guardado, que sobrevive a la muerte del proceso y que también sirve como canal para devolver resultados a la entrada anterior.

Esta tríada explica de golpe varias observaciones que suelen memorizarse por separado. Explica por qué el estado recordado de una pantalla sigue ahí al volver atrás, pero se pierde si esa pantalla salió definitivamente de la pila. Explica por qué un ViewModel de detalle no se comparte entre dos detalles distintos aunque sean del mismo tipo: son entradas distintas. Y explica por qué el ámbito por defecto es casi siempre el correcto y por qué compartir uno a nivel de actividad debe ser una decisión deliberada y no un descuido.

También conviene fijar la diferencia entre dos formas de desaparecer que se confunden constantemente. Una entrada tapada por otra sigue en la pila: baja de estado de ciclo de vida, sus efectos se cancelan, pero su vista-modelo y su estado guardado permanecen intactos esperando el regreso. Una entrada desapilada deja de existir: se limpia su almacén, se descarta su estado y volver a esa ruta creará una entrada nueva sin memoria de la anterior. La primera situación es la que hace que volver atrás se sienta instantáneo; la segunda es la que hace que un formulario superado no reaparezca a medio rellenar. Confundirlas lleva a atribuir a un fallo lo que es una decisión de forma de la pila, que es justamente el asunto de la tercera lección.

Del mismo modo, conviene decidir con criterio qué no pertenece a una entrada. Todo lo que deba sobrevivir a que el usuario abandone una pantalla y vuelva más tarde —una descarga en curso, una sesión, una caché— no puede vivir en el ámbito de una entrada, porque ese ámbito está diseñado precisamente para morir. Elevarlo a la actividad tampoco es la respuesta por defecto: lo que dura más que una pantalla suele pertenecer a la capa de datos, no a la de navegación.

⚠️
Un solo controlador, creado donde el grafo empieza

El controlador debe crearse una única vez, en el componible más alto que contiene al NavHost, y descender solo como parámetro hacia quien construye el grafo. Crear uno con rememberNavController dentro de un destino produce un segundo controlador, con su propia pila, ignorado por el botón atrás del sistema y por el anfitrión real. El síntoma es desconcertante: la aplicación parece navegar bien pero el gesto de volver la saca de golpe, porque la pila que el sistema conoce nunca creció.

Entrar por el medio

Si la pila es un estado reconstruible, nada obliga a que se construya siempre desde el principio. Un enlace profundo, una notificación o un acceso directo pueden depositar al usuario en un destino interior, y el sistema no tiene entonces ninguna historia previa que restaurar: hay que decidir qué pila sintética se fabrica para que volver atrás tenga sentido.

composable<Detalle>(
    deepLinks = listOf(navDeepLink { uriPattern = "app://tienda/producto/{id}" }),
) { entrada -> PantallaDetalle(entrada) }

El comportamiento por defecto construye la pila que llevaría desde el destino inicial hasta el destino enlazado, lo que suele ser lo correcto: quien llega desde una notificación puede volver atrás y encontrarse en el catálogo, con la aplicación en un estado plausible en lugar de cerrarse de golpe. Cuando ese recorrido sintético no es el adecuado, se declara explícitamente cuál debe ser.

Este caso vale como confirmación de la tesis de la lección, y por eso cierra la exposición en lugar de abrirla. Un enlace profundo no es una operación de navegación: es un estado inicial distinto del habitual. Solo puede existir porque la posición del usuario se representa como un valor construible, y sería sencillamente inexpresable si navegar consistiera en una sucesión de órdenes irrepetibles.

📝
Probar la navegación es comparar listas

Como la pila es estado legible, una prueba de navegación no necesita capturar pantallas ni esperar animaciones: ejecuta el gesto y comprueba que la ruta de la cima es la esperada. Esa comprobación es rápida, determinista y sobrevive a cualquier rediseño visual, porque no observa píxeles sino la estructura que los produce.

Declarar la topología es declarar qué es tu aplicación

Merece la pena detenerse en lo que este cambio de modelo significa fuera del ámbito estricto de Android, porque es la misma transición que la web hizo hace veinte años y por las mismas razones. Cuando la navegación es una secuencia de órdenes, la estructura de la aplicación no existe en ningún sitio: está implícita en el conjunto de todas las llamadas repartidas por el código, y para saber cómo se llega a una pantalla hay que buscar quién la abre y, recursivamente, quién abre a ese. La pregunta cuáles son los estados posibles de mi aplicación se vuelve indecidible en la práctica, y con ella se vuelven indecidibles todas las que dependen de ella: qué enlaces profundos son válidos, qué pantallas hay que probar, qué ocurre si el sistema mata el proceso justo aquí. Cuando la navegación es un estado derivado de un grafo declarado, la estructura pasa a existir como objeto de primera clase: se puede leer, dibujar, recorrer, validar y comparar contra un diseño. Y ese objeto tiene una propiedad que ninguna secuencia de órdenes tiene, que es la reversibilidad. Una lista de rutas se puede escribir en disco, enviar por un enlace, reconstruir en otro dispositivo y restaurar tras una muerte del proceso, porque describe un lugar y no un recorrido; una historia de llamadas no se puede hacer nada de eso, porque describe cómo se llegó y no dónde se está. Por eso la frase la navegación es estado no es un eslogan de la biblioteca ni una preferencia de estilo: es la condición necesaria para que existan enlaces profundos honestos, restauración fiel y pruebas deterministas. Todo lo que verás en las cuatro lecciones siguientes —tipos seguros, control de la pila, grafos anidados, la nueva generación— son formas cada vez más precisas de manipular esa misma lista, y ninguna de ellas tendría sentido si el modelo por debajo siguiera siendo una sucesión de órdenes irreversibles.

⚔️ Haz visible el estado de la navegación
  1. Monta un grafo mínimo con tres destinos y muestra en pantalla la ruta actual leyéndola del estado de la entrada actual, no de una variable propia.
  2. Guarda un texto en un campo del primer destino, navega, vuelve y explica por qué sigue ahí; después sal de él definitivamente y explica por qué desaparece.
  3. Obtén un ViewModel en cada destino, registra su creación y su limpieza, y describe el momento exacto en que ocurre cada una respecto a la pila.
  4. Crea a propósito un segundo controlador dentro de un destino, navega con él y razona por qué el gesto de volver hace lo que hace.
  5. Reescribe un destino para que no reciba el controlador sino funciones sin retorno, y comprueba que puedes previsualizarlo sin grafo alguno.