Deep linking: construir la pila entera desde una URL al arrancar
El pago de haber modelado la navegación como datos se cobra aquí. Esta lección convierte el enlace profundo en lo que debería haber sido siempre: una función pura de URL a `StackState`, probada sin interfaz, aplicada como estado inicial del store en el arranque o como sustitución de la pila cuando el enlace llega en caliente. Incluye la restauración de sesión por serialización, la degradación elegante ante rutas inválidas, y los dos matices prácticos —animación y datos no cargados— que separan una demostración de una implementación de producción.
El enlace profundo es la prueba de fuego de cualquier arquitectura de navegación, y es donde el modelo imperativo enseña sin remedio su costura. Allí, abrir la aplicación en una pantalla honda significa reproducir la secuencia de empujes que habría hecho un usuario: empuja el catálogo, espera a que cargue, empuja el producto, espera, empuja la reseña. Es una coreografía dependiente del reloj, plagada de comprobaciones defensivas, imposible de probar sin simulador y con una tendencia notoria a aterrizar en sitios distintos según lo rápido que responda la red. Con la navegación modelada como datos, el enlace profundo pierde toda esa complejidad de golpe, porque deja de ser una secuencia que hay que ejecutar y pasa a ser un valor que hay que construir. La aplicación no viaja hasta el destino: nace allí.
- Escribir la traducción de una URL a una pila como función pura, probable sin interfaz ni simulador.
- Arrancar el store con la pila ya poblada para que la aplicación abra directamente en la pantalla profunda.
- Atender enlaces que llegan con la aplicación en marcha sustituyendo la pila desde el reducer.
- Serializar y restaurar la navegación de una sesión, y degradar con elegancia ante rutas inválidas.
La ruta como función pura
El primer paso no toca ni el store ni la vista: es una función que recibe una URL y devuelve una colección de estados. Al no depender de nada más, se prueba con una tabla de casos y se corrige sin abrir la aplicación.
extension Tienda.State {
static func camino(desde url: URL) -> StackState<Tienda.Camino.State> {
guard let partes = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return StackState()
}
switch partes.path.split(separator: "/").map(String.init) {
case ["producto", let pid]:
return StackState([.producto(Producto.State(id: pid))])
case ["producto", let pid, "resena", let rid]:
return StackState([
.producto(Producto.State(id: pid)),
.resena(Resena.State(id: rid)),
])
default:
return StackState()
}
}
}
Fíjate en la degradación: una ruta desconocida devuelve una pila vacía, es decir, la raíz. No hay excepción, ni pantalla de error, ni estado a medio construir; el peor caso posible de un enlace corrupto es que el usuario abra la aplicación por su portada. Esa robustez no es mérito del código sino del tipo de retorno: cuando la respuesta obligatoria es una pila válida, no existe la posibilidad de devolver media navegación.
Esta función se afirma con una prueba unitaria corriente que compara dos valores, sin TestStore, sin dependencias y sin vista. Escríbela primero y tendrás cubierto el enlace profundo entero antes de que exista una sola pantalla, porque todo lo que viene después —arrancar con ese valor, sustituirlo en caliente— es mecánico. La parte que históricamente concentraba los fallos se ha convertido en la parte más barata de verificar.
Arrancar con la pila ya construida
Con la traducción resuelta, el arranque consiste en pasarle al store un estado inicial que ya contiene el camino. La aplicación se dibuja una sola vez, directamente en su forma final.
@main
struct TiendaApp: App {
let store: StoreOf<Tienda>
init() {
let camino = URL.enlaceDeLanzamiento.map(Tienda.State.camino(desde:)) ?? StackState()
self.store = Store(initialState: Tienda.State(camino: camino)) {
Tienda()
}
}
var body: some Scene {
WindowGroup {
TiendaView(store: store)
.onOpenURL { url in store.send(.enlaceRecibido(url)) }
}
}
}
No hay espera, ni encadenamiento, ni comprobación de que la pantalla anterior terminó de presentarse. El NavigationStack recibe un camino de dos elementos en su primer dibujo y monta las dos pantallas de una vez, cada una con su store derivado y su reducer corriendo. Y como cada destino se construyó con su State completo, ninguna necesita descubrir quién es a partir de un identificador suelto.
| Vía de entrada | Momento | Operación sobre la pila |
|---|---|---|
| Enlace de lanzamiento | al crear el store | estado inicial con el camino poblado |
| Enlace universal | notificación en caliente | aplicación en marcha | sustitución desde el reducer |
| Restauración de sesión | al arrancar | deserializar el camino guardado |
En caliente, y de vuelta desde el disco
Cuando el enlace llega con la aplicación funcionando, la operación es la misma con otro nombre: en vez de construir el estado inicial, se reemplaza el campo.
case let .enlaceRecibido(url):
state.camino = Tienda.State.camino(desde: url)
return .none
Una línea sustituye la navegación entera, venga el usuario de donde venga. Lo que había en la pila se descarta, y con ello se cancelan automáticamente todos los efectos en vuelo de las pantallas retiradas: la garantía de forEach cubre también las sustituciones masivas, no solo los retrocesos de uno en uno.
La restauración de sesión es literalmente el mismo mecanismo con otro origen. Si los estados de los destinos son codificables, StackState lo es, y guardar dónde estaba el usuario se reduce a serializar un valor al salir y deserializarlo al volver.
// Al pasar a segundo plano
try? Data(JSONEncoder().encode(store.camino)).write(to: .sesion)
// Al arrancar
let camino = (try? JSONDecoder().decode(
StackState<Tienda.Camino.State>.self, from: Data(contentsOf: .sesion)
)) ?? StackState()
URL a valor
Traducir un enlace es una función pura que devuelve una pila, probable sin interfaz.
Nacer en destino
El estado inicial ya contiene el camino: la aplicación se dibuja una vez, en su forma final.
Sustitución en caliente
Un enlace recibido en marcha reemplaza el campo y cancela los efectos de lo que desaparece.
Sesión serializable
Si los destinos son codificables, la navegación entera se guarda y se recupera como un dato.
flowchart LR U[URL entrante] --> F[Funcion pura de traduccion] F --> P[StackState construido] P --> I[Estado inicial del store] P --> H[Sustitucion en caliente] D[Sesion en disco] --> P I --> V[La vista monta la pila entera] H --> V style F fill:#89b4fa,color:#11111b style V fill:#a6e3a1,color:#11111b
Quedan dos matices que separan la demostración de la producción. El primero es la animación: al asignar varias pantallas de una vez con la aplicación en marcha, SwiftUI intentará animar la diferencia completa, lo que produce una transición atropellada; si el salto es largo, conviene aplicarlo sin animación y dejar visible solo el resultado. El segundo es la carga de datos: una pantalla que solo pide sus datos al aparecer los pedirá también al llegar por enlace profundo, así que si te importa que no parpadee, construye su State con lo que ya sabes en lugar de dejarla vacía a la espera.
Vale la pena reconocer que el enlace profundo funciona como diagnóstico, no como característica. Cualquier aplicación puede añadirlo a fuerza de voluntad, y el modo en que le cuesta hacerlo mide exactamente cuánta de su navegación vivía fuera de su estado. Si abrir una pantalla honda obliga a encadenar empujes con esperas, es que la navegación era una secuencia de órdenes cuyo efecto residía en el sistema de vistas. Si obliga a que cada pantalla sepa reconstruirse desde un identificador, es que el estado de los destinos estaba disperso en lugar de ser propiedad de quien los presenta. Y si el resultado depende de lo rápido que responda la red, es que el orden de las pantallas era una consecuencia del reloj y no una propiedad declarada. Las tres patologías son la misma vista desde ángulos distintos, y las tres desaparecen no porque TCA traiga una herramienta de enlaces profundos —no la trae, y todo lo de esta lección son cuatro líneas de Swift corriente—, sino porque la pila ya era un valor antes de que apareciera la primera URL. Lo verdaderamente notable es lo que ese mismo hecho concede gratis, sin una línea adicional: la restauración de sesión es serializar ese valor, el destino de una notificación es asignarlo, una previsualización de la quinta pantalla es construirlo a mano, una prueba de un flujo entero es afirmarlo, y un informe de fallo puede adjuntar la navegación exacta del usuario porque cabe en un JSON. Cinco capacidades que en el modelo imperativo son cinco proyectos independientes con sus propias fuentes de error resultan aquí ser la misma operación —leer o escribir una colección— vista con distinta iluminación. Ese es el rendimiento acumulado de la decisión que se tomó en el Nivel 3 y que este nivel ha desplegado hasta el final: cuando decides que estar en una pantalla es tener un valor en vez de haber ejecutado un comando, dejas de programar la navegación y pasas a describirla.
- Escribe la función pura que traduce tus URLs a una pila y cúbrela con una prueba unitaria que incluya, como mínimo, una ruta profunda válida, una parcial y una corrupta.
- Comprueba que la ruta corrupta devuelve la raíz y no un estado a medias. Si tu función puede fallar de otra forma, cambia el tipo de retorno hasta que no pueda.
- Cablea el arranque para que el store nazca con ese camino y verifica que la aplicación abre directamente en la pantalla honda, sin transiciones intermedias visibles.
- Añade la vía en caliente con una sola línea que sustituya el campo, y confirma que los efectos en vuelo de las pantallas descartadas se detienen sin que escribas ninguna limpieza.
- Serializa la pila al pasar a segundo plano y restáurala al volver. Después mata la aplicación desde el conmutador y ábrela de nuevo: si aparece donde la dejaste, tu navegación era un dato desde el principio.