Deep links: entrar por la mitad y reconstruir lo necesario
Un deep link rompe el supuesto silencioso sobre el que descansa casi toda aplicación: que el usuario llega a cada pantalla habiendo pasado por las anteriores. Esta lección estudia qué ocurre cuando ese supuesto cae, cómo se declaran enlaces con rutas tipadas, por qué el argumento que llega de fuera es entrada no confiable que obliga a modelar los estados de no encontrado y sin permiso, y cómo se reconstruye una pila sintética coherente para que el gesto de retroceso no expulse al usuario. Se conecta el arranque en frío con la lectura de argumentos desde el estado guardado y se compara el tratamiento del enlace bajo los dos enfoques de navegación estudiados.
Toda aplicación se escribe, sin decirlo, sobre una suposición muy fuerte: que el usuario llega a la pantalla de detalle desde la lista, que llega a la lista desde el inicio y que al inicio llegó tras autenticarse. Esa cadena de precondiciones no está escrita en ninguna parte, pero está incrustada en cada línea que da por hecho un dato cargado antes, un identificador validado antes o una sesión comprobada antes. Un deep link demuele la suposición entera en un instante: alguien pulsa un enlace en un mensaje y aterriza directamente en el detalle del pedido siete, con el proceso recién arrancado, sin sesión comprobada, sin lista debajo y con un identificador que escribió un desconocido. La pantalla que funcionaba perfectamente desde dentro tiene ahora que funcionar desde fuera, y todo lo que daba por supuesto se convierte en algo que debe reconstruir o rechazar.
- Enumerar las precondiciones implícitas que un deep link rompe y traducirlas en requisitos explícitos.
- Declarar enlaces sobre destinos tipados y validar el argumento como entrada no confiable.
- Reconstruir una pila sintética coherente para que el gesto de retroceso no expulse al usuario.
- Modelar como estados legítimos los casos de no encontrado, sin permiso y sin sesión.
Lo que un enlace externo rompe
Conviene inventariar las suposiciones antes de escribir código, porque cada una es un fallo esperando su turno. Al entrar por un enlace no hay pila debajo, de modo que el retroceso saca al usuario de la aplicación en vez de llevarlo a la lista. No hay proceso previo, de modo que ninguna caché en memoria está poblada y ningún modelo de vista compartido existe. No hay sesión comprobada, porque el usuario puede no haber iniciado nunca la aplicación. Y sobre todo, el identificador no lo produjo tu código: lo escribió alguien fuera de tu control.
Esa última es la más grave y la que menos se trata. Dentro de la aplicación, un identificador de pedido procede de una lista que el servidor devolvió para este usuario, así que existe y le pertenece. Desde un enlace, el identificador es texto arbitrario: puede no existir, puede pertenecer a otra persona, puede tener un formato inválido o puede ser un intento deliberado de acceder a algo ajeno. Un deep link es, en términos de seguridad, una entrada de red.
flowchart TD
L[Enlace externo] --> P{El formato es valido}
P -->|No| E1[Estado enlace invalido]
P -->|Si| S{Hay sesion}
S -->|No| E2[Guardar destino y pedir login]
S -->|Si| A{Existe y es accesible}
A -->|No| E3[Estado no encontrado o sin permiso]
A -->|Si| OK[Pantalla poblada con pila sintetica]
style OK fill:#a6e3a1,color:#11111b
style E1 fill:#f38ba8,color:#11111b
style E3 fill:#f9e2af,color:#11111bLos tres finales de error del diagrama no son excepciones ni pantallas genéricas: son estados de tu modelo, tan legítimos como el de carga. Que el pedido no exista es un hecho sobre el mundo, y una pantalla que solo sabe representar el caso feliz porque desde dentro era imposible fallar se queda muda justo cuando más falta hace un mensaje.
Declarar el enlace sobre un destino tipado
Con rutas tipadas, la declaración del enlace deja de ser una plantilla de texto con marcadores y pasa a derivarse del propio tipo del destino. Se indica un camino base y la librería genera la correspondencia entre los segmentos o parámetros de consulta y las propiedades de la clase, con sus valores por defecto y su opcionalidad.
composable<Detalle>(
deepLinks = listOf(
navDeepLink<Detalle>(basePath = "https://tienda.example.com/pedido"),
),
) { entrada ->
val args = entrada.toRoute<Detalle>()
DetalleRuta(pedidoId = args.pedidoId)
}
Que la correspondencia se derive del tipo tiene una consecuencia que evita una clase entera de fallos: no puedes declarar un enlace que aporte menos argumentos de los que el destino exige sin que el proyecto deje de compilar. Con plantillas de texto, ese desajuste era invisible hasta que alguien pulsaba el enlace y la aplicación se abría sin el identificador, o directamente no se abría. Los enlaces dejan de ser una configuración paralela al código y pasan a estar acoplados a él por el compilador.
El otro extremo del enlace vive en el manifiesto, con un filtro de intención que declara el esquema y el dominio, y en el fichero de verificación que el dominio publica para que el sistema abra la aplicación sin preguntar. Ese trabajo es de configuración y está bien documentado; lo interesante para nosotros empieza después, cuando el destino ya se resolvió y hay que decidir qué hacer con lo que llegó.
class DetalleViewModel(
savedStateHandle: SavedStateHandle,
private val repo: PedidoRepo,
private val sesion: SesionRepo,
) : ContainerHost<DetalleState, DetalleEfecto>, ViewModel() {
private val args: Detalle = savedStateHandle.toRoute()
override val container = container<DetalleState, DetalleEfecto>(
DetalleState(pedidoId = args.pedidoId)
) { cargar() }
private fun cargar() = intent {
if (!sesion.hayCuenta()) {
postSideEffect(DetalleEfecto.PedirLogin(volverA = args))
return@intent
}
reduce { state.copy(fase = Fase.Cargando) }
val fase = when (val r = repo.obtener(args.pedidoId)) {
is Ok -> Fase.Listo(r.valor)
is NoExiste -> Fase.NoEncontrado
is SinPermiso -> Fase.SinPermiso
is Fallo -> Fase.ErrorDeRed(r.mensaje)
}
reduce { state.copy(fase = fase) }
}
}
Observa que el modelo de vista no distingue si llegó desde la lista o desde un enlace, y eso es exactamente lo que se busca. Al leer sus argumentos desde el estado guardado, el camino de entrada se vuelve irrelevante: la misma semilla produce la misma carga venga de donde venga. Un destino que necesita saber cómo lo abrieron es un destino con una precondición sin resolver.
Trata el identificador que llega por un enlace igual que tratarías un parámetro de una petición pública: valida el formato antes de usarlo y deja que el servidor decida la autorización. Nunca supongas que un identificador bien formado implica permiso, y nunca reveles en el mensaje de error la diferencia entre no existe y no es tuyo si esa diferencia filtra información. El fallo típico aquí no es una excepción sino un acceso silencioso a un recurso ajeno que solo se descubre en una auditoría.
La pila sintética y el retroceso
Resuelto el contenido, queda la forma. Si el usuario aterriza en el detalle sin nada debajo, su primer gesto de retroceso lo expulsa de la aplicación, y esa experiencia se percibe como un fallo aunque técnicamente sea correcta. Lo esperable es que retroceder lo lleve a la lista, es decir, que exista una pila sintética: una que el usuario no recorrió pero que refleja la jerarquía lógica de la aplicación.
Jerarquía del grafo
La librería coloca bajo el destino profundo el destino inicial del grafo que lo contiene. Anidar bien tus subgrafos es, por tanto, la manera más barata de obtener una pila sintética razonable sin escribir nada.
Pila explícita
Cuando la jerarquía no basta —el detalle debería tener debajo la lista filtrada por categoría— se construye la pila destino a destino antes de mostrar la pantalla. Es más trabajo y es la única forma de acertar en flujos no triviales.
Enlace diferido
Si falta la sesión, se guarda el destino pretendido, se lleva al usuario al login y se retoma el enlace al terminar. El destino pendiente debe guardarse en un canal serializable, porque el login puede tardar más que el proceso.
Aplicación ya abierta
Un enlace que llega con la aplicación viva no debe duplicar destinos ni reiniciar el flujo. Recortar hasta el destino existente si ya está en la pila evita las cadenas de detalles repetidos que el usuario nunca podrá desapilar.
Hay una decisión previa que condiciona las cuatro y que suele tomarse sin pensar: cuántas pantallas merecen enlace. La respuesta razonable no es todas ni una, sino aquellas que un usuario podría querer compartir o recibir compartidas —un pedido, un producto, una conversación— y que por tanto tienen sentido como unidad autónoma. Un enlace a una pantalla que solo existe como paso intermedio de un asistente es una promesa que tu aplicación no puede cumplir, porque ese paso carece de significado sin los anteriores.
La tercera tarjeta merece un comentario porque es donde más implementaciones fallan. Guardar el destino pendiente en una variable del modelo de vista de login no sirve: si el usuario tarda en autenticarse y el sistema recicla el proceso, el destino se pierde y el enlace acaba en el inicio, sin explicación. El destino pretendido es exactamente el tipo de dato que debe viajar por el estado guardado, y como ahora las rutas son valores serializables, guardarlo es trivial.
Una pantalla que funciona desde un deep link es una pantalla sin precondiciones ocultas, y eso la hace mejor por razones que no tienen nada que ver con los enlaces. Es la misma propiedad que necesitas para restaurarla tras la muerte del proceso, para previsualizarla aislada y para probarla sin recorrer el flujo entero. Abrir cada destino directamente desde la línea de comandos es, en la práctica, la auditoría más rápida que existe para descubrir qué pantallas dependen en secreto de algo que ocurrió antes.
El enlace bajo los dos enfoques
Aquí se ve con claridad la diferencia que la lección tercera anticipaba. Con la navegación como efecto, el enlace externo entra por una puerta que no pasa por tu contenedor: lo resuelve el grafo, se crea el destino y solo entonces empieza a existir tu lógica. Eso obliga a mantener dos caminos coherentes hacia el mismo sitio —el interno, hecho de efectos, y el externo, hecho de configuración del grafo— y a duplicar en ambos la comprobación de sesión y la construcción de la pila.
La duplicación no es solo de código sino de razonamiento, y ahí está su verdadero coste. Cada vez que alguien añade una regla —este flujo exige perfil completo, aquel exige verificación de correo— tiene que acordarse de aplicarla en los dos caminos, y la única señal de que olvidó uno será un informe de fallo desde un dispositivo que llegó por un enlace. Las reglas que viven en dos sitios divergen; es cuestión de tiempo.
Con la pila como estado, el enlace se convierte en un intent más: una función que recibe la dirección, la traduce a una lista de destinos y la reduce. La comprobación de sesión, el recorte de duplicados y la pila sintética son entonces la misma reducción que ya escribiste para la navegación interna, con la única diferencia de por dónde entró el dato. Que ambos caminos converjan en una sola función es la ventaja más tangible del enfoque, y es la razón por la que las aplicaciones con deep linking serio tienden a acabar en él aunque no hubieran empezado ahí.
Lo que un enlace externo hace, más allá de abrir una pantalla, es someter tu aplicación a una prueba de composicionalidad: comprobar si cada parte puede existir por sí misma o si solo funciona como continuación de un recorrido concreto. Y casi siempre suspende la primera vez, porque el desarrollo natural de una aplicación va acumulando dependencias temporales invisibles —esta pantalla supone que aquella cargó el catálogo, esta otra supone que el usuario aceptó los términos hace tres pantallas— que nunca se declaran en ninguna firma y que el flujo habitual satisface por accidente. El deep link no introduce esos defectos: los revela, del mismo modo que la muerte del proceso revela el estado que solo vivía en memoria y que la rotación revela los efectos guardados en el estado. Los tres son manifestaciones del mismo principio, y por eso arreglarlos suele ser el mismo trabajo: hacer explícito lo que era implícito, convertir en semilla serializable lo que era contexto acumulado y en estado modelado lo que era caso imposible. Hay aquí una definición práctica de arquitectura que merece la pena guardar: una arquitectura es buena en la medida en que cada componente declara todo lo que necesita para existir y no depende de nada que no aparezca en su interfaz. Bajo esa definición, la capacidad de entrar directamente a cualquier pantalla no es una característica del producto sino un test de la arquitectura, y el hecho de que casi siempre haya que trabajar para conseguirla mide exactamente cuánta dependencia oculta se había acumulado. Cuando una aplicación soporta deep links a todos sus destinos sin trampas, lo que ha logrado no es una lista de enlaces: es que cada una de sus pantallas sea un programa completo.
- Elige tres destinos profundos y ábrelos directamente desde la línea de comandos con el proceso apagado. Anota qué falla en cada uno.
- Declara el enlace sobre un destino tipado y comprueba que el modelo de vista lee sus argumentos sin saber por dónde entró el usuario.
- Modela como estados explícitos los casos de identificador inválido, recurso no encontrado y acceso sin permiso, y pinta cada uno.
- Implementa el enlace diferido por falta de sesión guardando el destino pretendido en un canal serializable. Mata el proceso durante el login y verifica que se retoma.
- Compara por escrito cuántas funciones tendrías que tocar para añadir un enlace nuevo bajo cada uno de los dos enfoques de navegación.