wandres.dev
NAVEGACIÓN COMO DATO · el árbol de estado

Lo que desbloquea: enlaces profundos, sesión restaurada y flujos testeados

Modelar la navegación como dato no se paga con comodidad de escritura sino con capacidades nuevas, y las tres importantes son la misma capacidad vista desde tres ángulos. Si el trayecto es un valor, entonces un enlace profundo se resuelve construyendo ese valor en lugar de simular los toques que llevarían hasta él; restaurar la sesión se resuelve codificando y decodificando ese valor; y probar un flujo de cinco pantallas se resuelve comparando seis valores en sucesión, sin simulador ni esperas. Esta lección desarrolla las tres con código real y explica por qué conviene serializar una descripción del trayecto y no el estado entero.

⏱ 20 min

Hay una prueba sencilla para saber si una abstracción merece su coste: comprobar si las tareas que antes eran caras se vuelven baratas, o si solo cambian de sitio. La navegación como dato pasa esa prueba de forma poco habitual, porque no abarata una tarea sino tres a la vez, y no por casualidad. Abrir la app en una pantalla profunda, devolver al usuario donde lo dejó ayer y verificar en un test que un flujo entero se comporta como debe parecen tres problemas distintos: uno de integración con el sistema, otro de persistencia y otro de calidad. En el modelo imperativo lo son, y cada uno se resuelve con su propia coreografía frágil. En el modelo de valores los tres colapsan en la misma operación —fabricar, guardar o comparar un valor— y esa convergencia no es un beneficio adicional de la arquitectura: es la señal de que la abstracción capturó algo real.

🎯 Al terminar esta lección sabrás
  • Resolver un enlace profundo construyendo el estado de navegación en lugar de reproducir la secuencia de toques.
  • Distinguir serializar el estado completo de serializar una descripción del trayecto, y saber por qué lo segundo envejece mejor.
  • Escribir un test de flujo completo con TestStore afirmando únicamente sobre el estado de navegación.
  • Reconocer que las tres capacidades son la misma propiedad y usarlas como criterio para juzgar el diseño de tus destinos.

Un enlace profundo es un constructor, no un guion

En el modelo imperativo abrir la app en una pantalla profunda obliga a reproducir la historia: presentar la primera pantalla, esperar a que termine su animación, presentar la segunda, esperar otra vez. El resultado depende del tiempo, de si alguna pantalla intermedia decide mostrar una alerta y de que ninguna transición se solape con otra. Con la navegación como dato no hay historia que reproducir, porque el destino no es el residuo de un recorrido: es un valor que se puede escribir directamente.

extension AppRaiz.State {
  init(enlace url: URL) {
    self.init()
    guard url.scheme == "miapp" else { return }
    switch url.pathComponents.dropFirst() {
    case let partes where partes.first == "perfil":
      guard let id = partes.dropFirst().first.flatMap(UUID.init(uuidString:))
      else { return }
      self.camino.append(.perfil(Perfil.State(id: id)))
      if partes.contains("ajustes") {
        self.camino.append(.ajustes(Ajustes.State()))
      }
    default:
      return
    }
  }
}

// En el punto de entrada
WindowGroup {
  AppRaizView(store: Store(initialState: AppRaiz.State(enlace: url)) { AppRaiz() })
}

La aplicación arranca ya en el sitio correcto porque nunca estuvo en otro. No hubo dos pantallas antes que esta, no hubo animaciones encadenadas y no hubo ventana temporal en la que el estado fuese inconsistente. Y como el constructor es una función pura de URL a estado, se prueba sola: doce enlaces, doce comparaciones, sin arrancar la interfaz.

💡
El enlace también se recorre hacia atrás

La función inversa —de estado a URL— cuesta poco y paga mucho. Con ella obtienes gratis el botón de compartir esta pantalla, las rutas de analítica sin instrumentar cada vista y la posibilidad de reproducir un informe de fallo escribiendo la dirección que traía el usuario. Cuando ambas funciones existen y son inversas una de la otra, tu trayecto tiene una representación textual canónica, y eso es lo más parecido a una prueba de que el modelo de navegación está bien diseñado.

Restaurar la sesión: guarda la ruta, no el mundo

La tentación inmediata es hacer Codable el estado entero y volcarlo al disco al salir. Funciona el primer día y envejece mal, porque el estado incluye cosas que no deberían sobrevivir a un cierre: resultados de red ya caducados, banderas de carga, borradores a medias y campos que cambiarán de forma en la próxima versión. Lo que conviene persistir es una descripción mínima del trayecto, un valor pequeño y estable del que se pueda reconstruir el estado real.

// Descripción mínima del trayecto: identidad, no contenido
enum Paso: Codable, Equatable {
  case perfil(UUID)
  case publicacion(UUID)
  case ajustes
}

extension AppRaiz.State {
  var ruta: [Paso] {
    camino.compactMap { destino in
      switch destino {
      case let .perfil(estado): .perfil(estado.id)
      case let .publicacion(estado): .publicacion(estado.id)
      case .ajustes: .ajustes
      }
    }
  }

  init(ruta: [Paso]) {
    self.init()
    for paso in ruta {
      switch paso {
      case let .perfil(id): camino.append(.perfil(Perfil.State(id: id)))
      case let .publicacion(id): camino.append(.publicacion(Publicacion.State(id: id)))
      case .ajustes: camino.append(.ajustes(Ajustes.State()))
      }
    }
  }
}

La asimetría es deliberada: al guardar se descarta todo lo reconstruible y se conserva solo la identidad de cada escalón; al restaurar, cada pantalla vuelve a nacer en su estado inicial y vuelve a pedir sus datos como si el usuario acabase de llegar. El resultado es que la sesión restaurada nunca muestra información obsoleta y que un cambio en el estado interno de una feature no invalida lo que había en disco. La ruta es un contrato pequeño y por eso se puede versionar.

Qué se persiste Estado completo Descripción del trayecto
Tamaño en disco crece con toda la app proporcional a la profundidad
Datos caducados al volver se restauran tal cual no existen, se vuelven a pedir
Estabilidad entre versiones cualquier campo nuevo rompe el formato solo cambia si cambian los destinos
Trabajo de escritura ninguno si todo es Codable una función de ida y otra de vuelta
Riesgo de restaurar un estado imposible alto, se guarda lo transitorio bajo, cada pantalla nace limpia

Testear un flujo entero sin tocar una vista

La tercera capacidad es la que convierte este nivel en algo más que ergonomía. Un flujo de navegación es una secuencia de estados, y afirmar sobre él es comparar valores, así que el TestStore sirve exactamente igual para probar un formulario que para probar cuatro pantallas encadenadas.

@Test
func altaDeItemDesdeElFormulario() async {
  let store = await TestStore(initialState: Inventario.State()) {
    Inventario()
  } withDependencies: {
    $0.uuid = .incrementing
  }

  await store.send(.anadirTocado) {
    $0.destino = .anadir(FormularioItem.State(item: Item(id: UUID(0))))
  }

  await store.send(.destino(.presented(.anadir(.set(\.item.nombre, "Tornillo"))))) {
    $0.destino?.anadir?.item.nombre = "Tornillo"
  }

  await store.send(.destino(.presented(.anadir(.guardarTocado))))

  await store.receive(\.destino.anadir.delegate.guardado) {
    $0.items.append(Item(id: UUID(0), nombre: "Tornillo"))
    $0.destino = nil
  }
}

Lee lo que afirma la última aserción: que tras guardar, el item aparece en la lista y el destino vuelve a ser nulo, es decir, que la hoja se cerró. Comprobar que una pantalla se cerró se ha convertido en comparar un opcional con nulo. No hay expectativas asíncronas, ni esperas por animación, ni identificadores de accesibilidad, ni un simulador arrancado: hay un valor antes, una acción y un valor después, que es la única forma de aserción que no se vuelve intermitente con el tiempo.

flowchart LR
U[URL de enlace profundo] --> C[Constructor de State]
D[Ruta guardada en disco] --> C
T[Acciones de un test] --> C
C --> S[Valor de navegacion]
S --> V[Vista]
S --> A[Asercion de igualdad]
S --> W[Codificacion a disco]
style S fill:#89b4fa,color:#11111b
style A fill:#a6e3a1,color:#11111b
Las tres capacidades no son tres: son la reversibilidad del trayecto, mirada desde tres sitios

Conviene resistir la lectura fácil de esta lección, que sería apuntar tres ventajas en una lista y seguir adelante. Las tres son la misma, y verlo cambia el criterio con que se diseñan los destinos. Lo que ocurrió al convertir la navegación en dato es que el trayecto dejó de ser un proceso y pasó a ser un objeto, y todo objeto admite las tres operaciones que un proceso niega: se puede construir sin haberlo recorrido, se puede copiar sin haberlo repetido y se puede comparar sin haberlo observado. El enlace profundo es la primera —construir el objeto directamente— y por eso deja de importar el orden en que ocurrirían las cosas, porque ya no ocurre nada, simplemente es. La restauración es la segunda —copiar el objeto a un medio duradero y traerlo de vuelta— y por eso deja de importar cuánto tiempo pase entre una sesión y otra. El test es la tercera —comparar el objeto contra otro escrito a mano— y por eso deja de importar el tiempo real, las animaciones y la máquina donde corra. Un proceso solo se conoce ejecutándolo, y esa es la razón profunda de que probar la navegación imperativa sea tan caro y tan poco fiable: no es que falten herramientas, es que no hay nada que inspeccionar salvo su ejecución. Un valor se conoce mirándolo. De ahí sale además el criterio de diseño más útil del nivel, que es una especie de prueba de honestidad: si no puedes construir a mano el estado de navegación de una pantalla profunda sin pasar por las anteriores, tu navegación todavía no es un dato, aunque uses StackState y PresentationAction. Significa que hay información necesaria para estar ahí que solo se produce durante el recorrido y que no se conserva en el valor, y esa información es exactamente la deuda que reaparecerá como un enlace profundo que no funciona, una sesión que se restaura a medias o un test que hay que escribir enviando ocho acciones para llegar al punto que se quería probar. Cuando el constructor directo funciona, las tres capacidades aparecen juntas, porque nunca fueron tres.

⚔️ Prueba las tres caras del mismo valor
  1. Elige la pantalla más profunda de tu app y escribe a mano, en un test, el valor de estado que corresponde a estar ahí. Si necesitas datos que solo existen tras recorrer el camino, anótalos: son tu deuda.
  2. Escribe el constructor de URL a estado que produce ese mismo valor y pruébalo con cinco direcciones, incluidas dos malformadas que deben devolver la raíz.
  3. Escribe la función inversa, de estado a URL, y añade un test que compruebe que aplicar ida y vuelta devuelve el valor original.
  4. Define un tipo Paso con la descripción mínima de cada escalón, persiste la ruta al pasar a segundo plano y restaura al arrancar. Comprueba que ningún dato caducado sobrevive al cierre.
  5. Escribe un test de flujo que atraviese al menos tres pantallas y termine afirmando que el destino volvió a nulo. Mide cuánto tarda: si son milisegundos, ya no tienes tests de interfaz, tienes tests de navegación.