wandres.dev
NAVEGACIÓN · rutas con tipos seguros

Navigation 3 y el futuro: la pila como lista

El modelo clásico declaraba un grafo y guardaba la pila dentro del controlador, lo que resolvía bien el caso de una pantalla a la vez y encajaba mal con todo lo demás: pantallas grandes con dos paneles simultáneos, transiciones que dependen de qué había antes, estado de navegación que quiere vivir en la misma capa que el resto del estado. Esta lección explica qué rompe ese modelo, cómo la nueva generación entrega la pila al programador como lista observable que `NavDisplay` se limita a dibujar, qué papel juegan el proveedor de entradas, las escenas y los decoradores, y cómo se planifica una migración que puede durar meses sin bloquear el desarrollo.

⏱ 24 min

Cada abstracción resuelve bien la forma de problema para la que se diseñó y se resiste con obstinación a las demás, y por eso la mejor manera de entender una biblioteca nueva es preguntar qué supuesto de la anterior dejó de cumplirse. El modelo clásico de navegación en Compose asumía dos cosas razonables en 2021 y discutibles hoy: que la pila es un asunto interno del sistema de navegación, y que en cada instante se ve exactamente un destino. El primer supuesto convierte el estado de navegación en una excepción dentro de una arquitectura que por lo demás lo mantiene todo declarado y observable; el segundo se rompe en cuanto una tableta o un plegable muestra lista y detalle a la vez, y también en cada transición donde lo que se dibuja depende de la entrada saliente además de la entrante. La nueva generación no añade funciones a lo anterior: cambia de dueño. La pila deja de estar dentro del controlador y pasa a ser una lista que posees, mutas y observas como cualquier otro estado, y lo que antes era un anfitrión que navegaba se convierte en una función que dibuja lo que la lista dice.

🎯 Al terminar esta lección sabrás
  • Enunciar los dos supuestos del modelo clásico y explicar qué situaciones los rompen.
  • Describir el nuevo modelo en el que la pila es una lista observable de claves.
  • Situar el papel del proveedor de entradas, de las escenas y de los decoradores.
  • Planificar una migración por fases sin detener el desarrollo del producto.

Qué supuesto dejó de valer

El modelo clásico funciona notablemente bien mientras la aplicación consista en pantallas que se suceden. Cuando deja de consistir en eso, aparecen tres tensiones que no son defectos de implementación sino consecuencias del diseño.

La primera es la opacidad de la pila. El estado de navegación vive dentro del controlador y se manipula con llamadas que expresan intenciones, mientras el resto del estado de la aplicación se declara y se observa. Es la única parte del sistema donde la respuesta a dónde está el usuario se obtiene preguntando en lugar de leyendo.

La segunda es la suposición de destino único. Un diseño de lista y detalle en pantalla ancha muestra dos entradas simultáneas que además deben colapsar en una sola al estrecharse, y el modelo clásico solo puede expresarlo tratando el par como un tercer destino distinto, con lo que la misma pantalla de detalle existe dos veces con dos identidades.

Este segundo punto es el que empuja el rediseño más que ningún otro, porque no admite arreglo parcial: mientras la unidad de dibujado sea la cima de la pila, cualquier disposición con dos paneles obliga a inventar destinos que no corresponden a lugares del producto, y esos destinos artificiales contaminan después los enlaces profundos, el estado guardado y la analítica.

La tercera es la extensibilidad. Cualquier comportamiento nuevo —una transición que dependa de la entrada saliente, un ámbito de estado que abarque varias entradas, un registro de analítica por cambio de pila— exige que la biblioteca lo haya previsto, porque no hay ningún punto donde intervenir.

flowchart LR
subgraph Clasico
  C1[Grafo declarado] --> C2[NavController posee la pila]
  C2 --> C3[NavHost dibuja la cima]
end
subgraph Nuevo
  N1[Lista observable de claves] --> N2[NavDisplay lee la lista]
  N2 --> N3[Puede dibujar una o varias entradas]
  style N1 fill:#a6e3a1,color:#11111b
end

La pila la tienes tú

En el nuevo modelo no hay grafo que declarar por adelantado. Hay una lista de claves que representa la pila, una función que dice qué dibujar para cada clave, y un componible que las junta.

@Serializable data object Catalogo : NavKey
@Serializable data class Detalle(val id: String) : NavKey

@Composable
fun AppRoot() {
    val pila = rememberNavBackStack(Catalogo)

    NavDisplay(
        backStack = pila,
        onBack = { veces -> repeat(veces) { pila.removeLastOrNull() } },
        entryProvider = entryProvider {
            entry<Catalogo> {
                PantallaCatalogo(alPulsar = { id -> pila.add(Detalle(id)) })
            }
            entry<Detalle> { clave ->
                PantallaDetalle(id = clave.id)
            }
        },
    )
}

Conviene medir bien la magnitud del cambio, porque la sintaxis se parece lo suficiente como para engañar. Navegar ya no es llamar a un método que muta un estado ajeno: es añadir un elemento a una lista tuya. Volver es quitar el último. Y todo lo que la lección anterior expresaba mediante opciones de navegación se convierte en operaciones sobre una colección, que es un vocabulario que ya conoces y que el compilador entiende.

// Salir de un vestibulo: reemplazar en lugar de apilar.
pila.clear(); pila.add(Inicio)

// Evitar duplicados en la cima.
if (pila.lastOrNull() != destino) pila.add(destino)

// Cerrar un flujo de varios pasos.
pila.subList(indiceDelCarrito, pila.size).clear(); pila.add(Confirmacion)

Desaparece la necesidad de aprender un conjunto de opciones con un orden de aplicación no evidente, porque la forma de la pila se escribe directamente. A cambio, desaparece también la red de seguridad: nadie te impide dejar la lista vacía o en un estado que ningún recorrido de usuario podría producir.

Un detalle de la firma resume bien el cambio de filosofía. La función que atiende el retroceso no recibe una señal sin datos, sino cuántas entradas hay que quitar, porque una escena que muestra dos destinos a la vez debe poder retroceder los dos de golpe. En el modelo clásico esa decisión pertenecía a la biblioteca y no había forma de intervenir; aquí es una línea de código tuya, y por tanto también una responsabilidad tuya.

ℹ️
Las claves son valores serializables, igual que los destinos tipados

La continuidad con la lección de tipos seguros es total: una clave es una clase u objeto serializable que implementa una interfaz marcadora, y sus campos son los argumentos. Por eso la lista se puede archivar y restaurar tras la muerte del proceso, y por eso quien ya migró a destinos tipados tiene la mitad del trabajo hecho. Lo que cambia es quién guarda la lista, no cómo se representa cada elemento.

Escenas y decoradores: por dónde se extiende

Si el anfitrión solo dibujara la cima, el modelo nuevo sería el anterior con otra sintaxis. La diferencia está en que una escena puede consumir varias entradas de la cima y decidir cómo se disponen.

🖥️

Escenas

Una estrategia de escena examina el final de la pila y puede reclamar más de una entrada para mostrarlas juntas, por ejemplo lista y detalle en dos paneles. Al estrechar la ventana la estrategia deja de aplicarse y se vuelve a una entrada, sin que la pila cambie.

🎁

Decoradores

Envuelven el contenido de cada entrada para aportar comportamiento transversal: ámbito de vista-modelo, estado guardado, elementos compartidos entre transiciones o registro de analítica. Son el punto de extensión que el modelo anterior no tenía.

La consecuencia de esta separación merece enunciarse despacio, porque es la razón de ser de todo el rediseño. La pila describe dónde está el usuario; la escena describe cómo se muestra eso en esta ventana concreta. Al girar una tableta o desplegar un dispositivo no ocurre ninguna navegación: la lista de claves es idéntica antes y después, y lo único que cambia es la estrategia que decide cuántas entradas caben en pantalla. Ese desacoplamiento es exactamente lo que el modelo clásico no podía expresar, y es también el que hace que el gesto atrás siga significando lo mismo en ambas configuraciones.

Los decoradores resuelven la otra mitad. Ámbitos de estado que abarcan varias entradas, transiciones con elementos compartidos, comportamientos propios del equipo: todo eso pasa a escribirse como un envoltorio en lugar de esperar a que la biblioteca lo incorpore.

El cambio de fondo es de política a mecanismo. El modelo anterior ofrecía un conjunto cerrado de comportamientos bien elegidos, y quien necesitaba uno que no estuviera en la lista no tenía dónde escribirlo. El nuevo ofrece piezas componibles con las que esos mismos comportamientos se construyen, incluido el ámbito de vista-modelo, que deja de ser una propiedad implícita de la entrada para pasar a ser un decorador que se aplica. Se gana capacidad y se pierde el suelo de garantías: olvidar un decorador no produce un error de compilación, produce una aplicación que no recuerda nada tras la muerte del proceso.

⚠️
Más control implica más responsabilidad

Con la pila en tus manos, los invariantes que antes garantizaba la biblioteca pasan a ser tuyos. Nada impide vaciarla del todo, apilar cien copias del mismo destino o dejarla en una combinación que ningún gesto real podría producir. Conviene encapsular las mutaciones en funciones con nombre que expresen intenciones del producto, en lugar de llamar a las operaciones de lista desde cualquier punto, y probar esas funciones directamente: ahora son código propio y comprobable sin interfaz.

Cómo se migra

La migración no es una operación de un día ni conviene plantearla así. El camino practicable tiene cuatro fases y cada una aporta valor por sí sola.

La primera es adoptar destinos tipados en el modelo clásico, si aún no se hizo. Es la fase más rentable y la que menos riesgo tiene, porque las clases de destino se convierten después en claves casi sin tocarlas y porque el beneficio se cobra desde el primer día, con independencia de lo que se decida después.

La segunda es retirar el controlador de los destinos, dejando que cada pantalla reciba funciones sin retorno. Un componible que no conoce al controlador es indiferente al modelo de navegación que haya debajo, y esta fase por sí sola ya mejora la capacidad de previsualizar y probar.

La tercera es aislar la topología en un único lugar que decida a dónde lleva cada acción. Cuando toda la lógica de navegación está en un archivo, cambiar el motor es reescribir ese archivo.

La cuarta es convertir un subgrafo aislado y vivir con él un tiempo antes de continuar. Las dos generaciones pueden coexistir mientras dure la transición, y empezar por un flujo de riesgo bajo permite descubrir en pequeño lo que de otro modo se descubriría en grande.

Vale la pena observar que las tres primeras fases no mencionan la biblioteca nueva. No es casualidad: describen cómo debería estar escrito un grafo clásico bien hecho, y su valor no depende de que la migración llegue a producirse. Esa es la propiedad que hay que exigir a cualquier plan de migración honesto, y la que distingue una preparación de una apuesta.

📝
La madurez importa tanto como el diseño

Una biblioteca de navegación es infraestructura, y la infraestructura se juzga también por su edad. El modelo clásico acumula años de casos límite resueltos en producción; el nuevo es más joven, y su superficie de extensión implica que parte del trabajo que antes venía hecho ahora lo aporta el ecosistema. Adoptarlo hoy en un producto nuevo o en un flujo acotado es razonable; reescribir una aplicación grande y estable solo por estar al día rara vez lo es.

Quién posee el estado es la decisión que define una arquitectura

Conviene reconocer que esta evolución no es un episodio local de una biblioteca de Android, sino la aplicación tardía a la navegación de la misma idea que ya había reorganizado todo lo demás de Compose. Durante años la interfaz se construyó con objetos que guardaban su propio estado y notificaban cambios, y el paso a la composición declarativa consistió en invertir esa relación: el estado sale de los componentes, se eleva a quien tiene contexto para decidir, y los componentes se convierten en funciones que lo dibujan. Ese cambio no hizo la interfaz más bonita, la hizo razonable: una función de estado a píxeles se puede probar, previsualizar, memorizar y restaurar, y una jerarquía de objetos con memoria propia no. La navegación fue la última pieza que se quedó del lado antiguo, y se quedó por una razón comprensible, que es que su estado parecía pertenecer al sistema y no a la aplicación. Pero no pertenece al sistema: la pregunta de dónde está el usuario es una pregunta sobre tu producto, con las mismas propiedades que cualquier otra parte de tu estado, y mantenerla encerrada dentro de un controlador significaba que la aplicación tenía dos clases de estado con dos reglas distintas y dos formas de razonar. Todo lo que resultaba incómodo en el modelo anterior se sigue de esa dualidad: que las pantallas grandes exigieran destinos artificiales para representar lo que solo era una disposición distinta del mismo lugar; que extender el comportamiento requiriera esperar a que alguien lo previera; que probar la navegación fuera un ejercicio de instrumentación en lugar de una comprobación sobre una lista. Cuando la pila pasa a ser una lista observable que posees, no ganas principalmente comodidad: ganas que el estado de tu aplicación vuelva a ser uno solo, con una única regla, y que la ventana quede reducida a lo que siempre debió ser, un detalle de presentación que no altera dónde está nadie. Por eso este cambio se puede leer como el final de una transición larga más que como una biblioteca nueva, y por eso el criterio para juzgar la siguiente generación de cualquier herramienta que uses es siempre el mismo: preguntar quién posee el estado, y desconfiar de toda respuesta que no seas tú.

⚔️ Traduce y compara los dos modelos
  1. Reescribe con el nuevo modelo un grafo clásico de cuatro destinos y compara el número de conceptos que necesitas para explicar cada versión.
  2. Traduce a operaciones de lista los tres patrones de pila de la lección anterior y comprueba que producen la misma forma final.
  3. Encapsula esas mutaciones en funciones con nombre de producto y escribe pruebas de la pila sin levantar interfaz alguna.
  4. Aplica una estrategia de escena de dos paneles y verifica que la lista de claves es idéntica en ancho y en estrecho.
  5. Planifica por escrito la migración de tu aplicación en las cuatro fases y señala cuál puedes empezar hoy sin afectar al desarrollo en curso.