Deep links: de una URL a una pantalla concreta
Un deep link es la promesa de que una dirección escrita en cualquier lugar del mundo abra exactamente la pantalla correcta de una aplicación, con el estado correcto y con una pila de retroceso que tenga sentido para quien llega desde fuera. Esta lección distingue el esquema propio del enlace HTTP y explica por qué el primero es cómodo y frágil mientras el segundo es el único que sobrevive a un dispositivo sin la aplicación instalada. Detalla el filtro que hay que declarar, la relación entre el modo de lanzamiento y la recepción de intents en una actividad ya viva, y la integración con Navigation Compose mediante patrones de URI, argumentos tipados y pila sintética, además del problema de la pantalla huérfana al que casi todas las implementaciones llegan tarde.
Hasta ahora los intents circulaban entre aplicaciones que estaban en el mismo dispositivo y que hablaban un vocabulario común. Un deep link rompe esa frontera: convierte una cadena de texto —un enlace en un correo, un botón de una página web, un mensaje de notificación, un código impreso en un cartel— en una entrada directa al interior de tu aplicación, saltándose la pantalla principal y todo el camino que normalmente lleva hasta allí. Ese salto es la razón de su enorme valor comercial y también la fuente de todos sus problemas, porque una pantalla a la que se llega desde fuera no puede dar por supuesto nada de lo que las pantallas interiores dan por supuesto: ni que el usuario esté autenticado, ni que haya datos cargados en memoria, ni que exista una pila de retroceso detrás, ni siquiera que el identificador que viene en la dirección corresponda a algo que siga existiendo.
- Diferenciar el esquema propio del enlace HTTP y elegir con criterio cuál sostiene cada caso de uso.
- Declarar el filtro necesario y entender el papel de
BROWSABLEy del modo de lanzamiento. - Registrar patrones de URI en Navigation Compose y extraer argumentos tipados del destino.
- Resolver el problema de la pantalla huérfana con una pila de retroceso sintética coherente.
Esquema propio frente a enlace HTTP
La forma más antigua de deep link inventa un esquema, algo como miapp seguido de dos puntos, y declara un filtro que lo atienda. Funciona en un solo paso, no requiere servidor ni verificación, y es la vía natural para comunicaciones internas y para flujos de retorno de sistemas de autenticación. Su defecto es estructural y no tiene arreglo: si la aplicación no está instalada, la dirección no significa nada para nadie. El navegador muestra un error, el cliente de correo no la convierte en enlace y el usuario se queda mirando una cadena rota.
El enlace HTTP resuelve exactamente eso. Como la dirección es una dirección web real, siempre hay un destino: con la aplicación instalada abre la pantalla, y sin ella abre la página equivalente en el navegador. Esa continuidad es la razón por la que todo enlace que vaya a salir de tu control —correos, redes sociales, mensajes, publicidad— debe ser HTTP, y por la que los esquemas propios deberían quedar reservados a lo que nunca abandona el dispositivo.
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop">
<!-- esquema propio: rapido, interno, inutil sin la app instalada -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="miapp" android:host="articulo" />
</intent-filter>
<!-- enlace http: siempre resuelve, con app o sin ella -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="ejemplo.com"
android:pathPrefix="/articulo" />
</intent-filter>
</activity>
La categoría BROWSABLE es imprescindible y a la vez es la señal de alarma más útil del manifiesto. Significa literalmente que el intent puede haber sido originado por contenido que tú no escribiste, y por tanto que la pantalla receptora está expuesta a direcciones fabricadas por cualquiera. Todo lo dicho en la lección anterior sobre validación de entrada se aplica aquí multiplicado, porque el atacante ni siquiera necesita instalar nada: le basta con conseguir que el usuario pulse un enlace.
El comportamiento por defecto ante un intent entrante es crear una instancia de la actividad. Si el usuario ya está dentro de la aplicación y pulsa otro enlace, aparece una segunda instancia apilada sobre la primera, con su propio estado y su propia navegación, y el botón de retroceso empieza a comportarse de forma incomprensible. Con singleTop la instancia existente recibe el intent por onNewIntent en lugar de duplicarse, pero entonces la responsabilidad se traslada a tu código: hay que atender esa llamada y reenviar el intent al controlador de navegación, porque nadie lo hará por ti y el síntoma —el enlace no hace nada cuando la aplicación ya está abierta— es de los que tardan semanas en reportarse.
Navigation Compose: patrones, argumentos y reenvío
Navigation Compose asocia a cada destino una lista de patrones de URI, y cuando llega un intent con una dirección que casa con uno de ellos, el controlador navega a ese destino extrayendo los argumentos del propio patrón. El mecanismo es el mismo que el de las rutas internas, lo que significa que un destino puede ser alcanzable desde dentro y desde fuera sin duplicar nada.
NavHost(navController, startDestination = "inicio") {
composable(route = "inicio") { Inicio(navController) }
composable(
route = "articulo/{id}",
arguments = listOf(navArgument("id") { type = NavType.StringType }),
deepLinks = listOf(
navDeepLink { uriPattern = "miapp://articulo/{id}" },
navDeepLink { uriPattern = "https://ejemplo.com/articulo/{id}" },
),
) { entrada ->
val id = entrada.arguments?.getString("id")
ArticuloPantalla(id = id)
}
}
El reenvío del intent cuando la actividad ya estaba viva es la pieza que falta y que ninguna configuración declarativa cubre. El controlador de navegación sabe interpretar un intent, pero solo se le entrega el que había en el momento de crearse la actividad; los que llegan después hay que dárselos a mano.
class MainActivity : ComponentActivity() {
private var pendiente by mutableStateOf<Intent?>(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val nav = rememberNavController()
LaunchedEffect(pendiente) {
pendiente?.let { nav.handleDeepLink(it); pendiente = null }
}
Grafo(nav)
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
pendiente = intent // la instancia ya existia: hay que reenviarlo
}
}
flowchart TD
A[Usuario pulsa un enlace] --> B{La actividad ya existe}
B -->|no| C[onCreate con el intent inicial]
B -->|si con singleTop| D[onNewIntent]
C --> E[El NavController interpreta la URI]
D --> F[Reenvio manual a handleDeepLink]
F --> E
E --> G{Casa con algun patron}
G -->|si| H[Navega al destino con argumentos]
G -->|no| I[Cae en el destino inicial]
H --> J{Que hay debajo en la pila}La pantalla huérfana y la pila sintética
Aquí llega el problema que separa una implementación aceptable de una buena. Cuando el usuario entra por un enlace directo al detalle de un artículo, esa pantalla es la única de la pila. Al pulsar retroceso sale de la aplicación, cosa que resulta desconcertante porque acaba de aterrizar en un sitio del que no ha llegado a ver el contexto. Navigation Compose construye por defecto una pila sintética que va desde el destino inicial del grafo hasta el destino del enlace, lo que en la mayoría de los grafos produce exactamente el comportamiento deseado sin escribir nada.
Cuando el grafo tiene jerarquías anidadas o el camino natural hacia una pantalla no coincide con la cadena de destinos iniciales, hay que tomar el control con NavDeepLinkBuilder, que permite describir explícitamente la pila que debe existir al llegar. Es también la herramienta correcta para los deep links que se emiten desde notificaciones propias, donde la ruta de retorno forma parte de la experiencia y no puede dejarse al azar.
Queda una decisión de producto que suele resolverse mal por omisión. Un enlace hacia una pantalla que exige sesión iniciada puede rechazarse, puede llevar al inicio de sesión y olvidar el destino, o puede llevar al inicio de sesión y continuar hacia el destino al terminar. Solo la tercera respeta la intención del usuario, y exige conservar el destino pendiente durante todo el flujo de autenticación, incluida la posible muerte del proceso mientras el usuario está en otra aplicación recuperando su contraseña.
HTTP hacia fuera
Todo enlace que salga de tu control debe resolver también sin la aplicación instalada.
Un destino, dos puertas
El mismo composable sirve a la navegación interna y al enlace externo sin duplicar pantalla.
Pila sintética
Quien llega de fuera necesita un retroceso que tenga sentido, no una salida inmediata.
Destino pendiente
Un enlace interrumpido por autenticación debe reanudarse, incluso tras la muerte del proceso.
La propiedad más incómoda de un deep link es que su emisor y su receptor están separados por el tiempo, por el espacio y por el control. La dirección que abre una pantalla de tu aplicación puede haberse escrito en un correo enviado hace dos años, en un cartel impreso que sigue colgado en una estación, en una publicación que alguien copió y pegó, o en el mensaje automático de un sistema que nadie mantiene desde hace tres versiones. Ninguno de esos emisores va a actualizarse porque tú hayas reorganizado tu grafo de navegación, y ninguno te va a avisar de que lo está usando. Eso convierte el conjunto de patrones de URI que declaras en algo mucho más parecido a un esquema de base de datos en producción que a una configuración de la aplicación: se puede ampliar, se puede migrar con redirecciones, pero no se puede simplemente cambiar. La consecuencia de diseño es que las direcciones públicas deben construirse sobre conceptos estables del dominio y jamás sobre la estructura actual de la interfaz. Un enlace que apunta al artículo con cierto identificador seguirá teniendo sentido cuando rediseñes la aplicación entera, porque los artículos seguirán existiendo; un enlace que apunta a la tercera pestaña de la sección de novedades muere en el primer rediseño y se lleva por delante campañas enteras. Y hay una segunda consecuencia, más sutil y más profunda, que tiene que ver con la naturaleza de la pantalla de destino. Una pantalla alcanzable por enlace deja de ser un nodo intermedio de un flujo y pasa a ser un punto de entrada de pleno derecho, lo que significa que debe ser capaz de construirse desde cero con la única información que cabe en una dirección. Si necesita datos que solo existen porque la pantalla anterior los dejó en memoria, no es una pantalla profunda: es una pantalla frágil que casualmente funciona cuando se llega por el camino previsto. Diseñar bien para deep links obliga, por esa vía indirecta, a una higiene arquitectónica que beneficia a toda la aplicación: cada destino identifica lo que muestra mediante un identificador estable, lo carga por sí mismo desde su fuente de verdad y trata la ausencia o la invalidez de ese identificador como un caso normal y no como un imposible. Quien cumple esa disciplina descubre que los enlaces profundos dejan de ser una funcionalidad que hay que añadir y pasan a ser una propiedad que la aplicación ya tenía.
- Declara los dos filtros, esquema propio y HTTP, y lanza ambos con
adb shell am start -a android.intent.action.VIEW -d "..."desde la aplicación cerrada y abierta. - Retira
singleTop, reproduce la duplicación de actividades pulsando dos enlaces seguidos y describe qué hace el botón de retroceso. - Elimina el reenvío desde
onNewIntenty comprueba el síntoma exacto: el enlace no hace nada con la aplicación ya abierta. - Entra por enlace a una pantalla profunda, pulsa retroceso y decide si la pila resultante es la que quieres. Constrúyela explícitamente si no lo es.
- Protege un destino con autenticación y consigue que el enlace se reanude después de iniciar sesión, incluso matando el proceso desde el gestor de dispositivos a mitad del flujo.