Deep links en iOS
Al terminar: Recibir el mismo enlace en SwiftUI y saber qué cuesta pasar a Universal Links.
El mismo enlace, la otra app. La diferencia es un solo modificador: donde Android necesita dos puntos de entrada y un estado observable para no perderse ninguno, SwiftUI tiene un modificador que cubre los dos casos sin distinguirlos.
A cambio, iOS cobra un peaje que Android no cobra: al abrir un esquema propio sale un diálogo pidiendo permiso. Es una de esas diferencias que no salen en ninguna tabla comparativa y que cambian cómo se siente la app.
Lo nuevo, con un ejemplo ajeno al proyecto
Lo nuevo de verdad es .onOpenURL: un modificador que SwiftUI llama cada vez que le llega una URL a la app. Un ejemplo con una app de biblioteca, para verlo sin Pokémon por medio. El TabView con selection, el enum y .tag son los de Marcador; lo único nuevo está en .onOpenURL:
import SwiftUI
// Las dos pestañas de una app de biblioteca, como valores que el TabView elige.
private enum Seccion: Hashable {
case estante, prestamos
}
struct BibliotecaView: View {
@State private var seccion = Seccion.estante // la pestaña visible
@State private var libroAbierto: [Int] = [] // la pila del estante: números de libro
var body: some View {
TabView(selection: $seccion) {
NavigationStack(path: $libroAbierto) {
Text("Estante")
.navigationDestination(for: Int.self) { numero in
Text("Libro n.º \(numero)")
}
}
.tabItem { Label("Estante", systemImage: "books.vertical") }
.tag(Seccion.estante) // el valor de esta pestaña
Text("Préstamos")
.tabItem { Label("Préstamos", systemImage: "tray") }
.tag(Seccion.prestamos)
}
// SwiftUI llama a este bloque cada vez que llega una URL para la app,
// esté cerrada (la abre y luego llama) o ya abierta (solo llama).
.onOpenURL { url in
// biblioteca://libro/7 → host "libro", último tramo "7".
guard url.host() == "libro", let numero = Int(url.lastPathComponent) else { return }
seccion = .estante // primero, la pestaña donde va el libro
libroAbierto = [numero] // y su pila: solo ese libro
}
}
}
#Preview { BibliotecaView() }Cómo se lee cada URL. Salida real de ejecutar URL(string:) y pedirle el esquema (scheme), el host() (lo que va justo después de ://; es una función, por eso lleva paréntesis) y lastPathComponent, el último tramo de la ruta (lo que va tras la última barra; si no hay ruta queda vacío):
biblioteca://libro/7 → esquema: biblioteca · host: libro · último tramo: 7 · Int: 7
biblioteca:// → esquema: biblioteca · host: nil · último tramo: · Int: nil
biblioteca://libro/siete → esquema: biblioteca · host: libro · último tramo: siete · Int: nil
biblioteca://vaya/cosa → esquema: biblioteca · host: vaya · último tramo: cosa · Int: nilFíjate en que Int("siete") da nil: el guard deja pasar solo lo que de verdad es un número. En la Pokédex no vas a leer la URL a mano como aquí: ya la traduce Destination.parse, en Kotlin, para las dos apps. Swift solo le pasa el texto (url.absoluteString, la URL entera como String).
Paso 1 · Declarar el esquema en el Info.plist
El archivo. Info.plist ya lo conoces (Ejecutar en iOS): la lista de datos con los que iOS conoce tu app antes de ejecutarla. Aquí no hay código; se declara el esquema pokedex y el sistema lo lee al instalar la app, como el intent-filter de Android.
Abre Xcode y, en el Project navigator (Cmd+1), selecciona iosApp → iosApp → Info.plist (en Xcode las carpetas se ven así; en disco es iosApp/iosApp/Info.plist). Xcode lo enseña como una tabla de claves y valores; para ver el texto, clic derecho sobre el fichero → Open As → Source Code. Dentro del <dict> principal, justo antes de <key>LSRequiresIPhoneOS</key>, añade (si tu fichero no tiene esa clave, pégalo justo antes del </dict> final, el que cierra el <dict> principal):
<!-- Los tipos de URL que esta app sabe abrir. -->
<key>CFBundleURLTypes</key>
<array>
<dict>
<!-- Quién reclama el esquema: el identificador de la app. -->
<key>CFBundleURLName</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<!-- Los esquemas: lo que va antes de «://». Aquí, pokedex. -->
<key>CFBundleURLSchemes</key>
<array>
<string>pokedex</string>
</array>
</dict>
</array>Cómo leer ese texto: es XML, y cada dato es un par <key> (el nombre) seguido de su valor; <array> es una lista, <dict> un grupo de pares y <!-- … --> un comentario que el sistema ignora.
CFBundleURLName no es el esquema: es el identificador de quién lo reclama. $(PRODUCT_BUNDLE_IDENTIFIER) es una variable que Xcode sustituye por el identificador de tu app al compilar, igual que las de Config.xcconfig en Ejecutar en iOS.
Paso 2 · Recibirlo: un solo sitio
El archivo. ContentView.swift es la raíz de la app: el TabView con las tres pestañas. Un enlace entrante se entrega a una escena. ContentView es la vista raíz de esa única escena (la que se pinta al arrancar), y ahí va el modificador .onOpenURL, que SwiftUI llama cada vez que la app recibe una URL. Para que un enlace pueda cambiar la pestaña visible, la pestaña elegida tiene que ser un estado (@State) atado al TabView con selection:. Abre iosApp/iosApp/ContentView.swift y sustituye el fichero entero por este (la propiedad insignia, el .badge y el segundo .task ya los tenías de la lección de ajustes: aparecen aquí para que no se pierdan al pegar):
import SwiftUI
import Shared
import KMPNativeCoroutinesAsync
/// Las tres secciones de la app, como valores que `TabView` puede seleccionar.
// `Hashable` (visto en Marcador): SwiftUI puede comparar estos valores.
private enum Pestana: Hashable {
case lista, favoritos, ajustes
}
struct ContentView: View {
// Un camino de fichas abiertas por pestaña: cada pestaña lleva su pila.
@State private var caminoLista: [Int32] = []
@State private var caminoFavoritos: [Int32] = []
@StateObject private var owner = IosViewModelStoreOwner()
@State private var ajustes = Settings(tema: Tema.sistema, favoritosPorNumero: false)
@State private var insignia: String? = nil // nil: sin insignia en Favoritos
// La pestaña visible. Tiene que ser estado porque un enlace
// entrante la cambia: si llega una ficha estando en Favoritos, hay que
// saltar a la Pokédex antes de apilar nada.
@State private var pestana = Pestana.lista
var body: some View {
TabView(selection: $pestana) { // `selection` ata el TabView al estado
PokemonListView(camino: $caminoLista)
.tabItem { Label("Pokédex", systemImage: "square.grid.2x2") }
.tag(Pestana.lista) // el valor que representa esta pestaña
FavoritesView(camino: $caminoFavoritos)
.tabItem { Label("Favoritos", systemImage: "heart.fill") }
.tag(Pestana.favoritos)
.badge(insignia) // con nil SwiftUI no pinta nada
SettingsView()
.tabItem { Label("Ajustes", systemImage: "gearshape") }
.tag(Pestana.ajustes)
}
// El tema se aplica en la raíz: dentro de la pantalla de ajustes solo
// pintaría esa pestaña.
.preferredColorScheme(ajustes.tema.esquema)
// `.onOpenURL` cubre los dos casos, app cerrada y app abierta,
// sin distinguirlos. En Android son dos sitios (`onCreate` y
// `onNewIntent`), y olvidar el segundo hace que el enlace falle si la app ya estaba abierta.
.onOpenURL { url in
// La traducción vive en Kotlin; aquí solo se llama (el paso 3 explica el
// nombre `DestinationCompanion`). Devuelve nil si
// no entiende el enlace, y entonces no se hace nada.
guard let destino = DestinationCompanion.shared.parse(url: url.absoluteString) else { return }
pestana = .lista // antes de apilar, la pestaña de la Pokédex
if let detalle = destino as? DestinationDetalle { // ¿es una ficha? (`DestinationDetalle` es `Destination.Detalle` de Kotlin)
caminoLista = [detalle.pokemonId] // una ficha: el camino es solo ella
} else {
caminoLista = [] // la lista: sin nada apilado
}
}
.task {
let vm = SettingsIosKt.settingsViewModel(owner: owner)
ajustes = vm.settingsForIos
do {
for try await nuevo in asyncSequence(for: vm.settingsForIosFlow) {
ajustes = nuevo
}
} catch { }
}
// La insignia de Favoritos: otro lector del mismo repositorio de favoritos.
.task {
let favoritos = FavoritesIosKt.favoritesViewModel(owner: owner)
insignia = favoritos.insigniaForIos
do {
for try await nuevo in asyncSequence(for: favoritos.insigniaForIosFlow) {
insignia = nuevo
}
} catch { }
}
}
}
#Preview {
ContentView()
}Compara con lo que hizo falta en Android: un mutableStateOf en la actividad, una asignación en onCreate, otra en onNewIntent y un LaunchedEffect que lo consume. Aquí es un solo modificador.
Por qué pestana = .lista. Es la línea que se olvida. Sin ella, un enlace recibido estando en Favoritos apilaría la ficha en la pila de la Pokédex, que no se está viendo. Al final del paso 4 puedes provocarlo.
Paso 3 · Un detalle de interop: DestinationCompanion
En Kotlin, parse se llama como Destination.parse(…), porque vive en el objeto acompañante (companion object) de la interfaz. Copiar eso a Swift parece lo obvio (en Swift, el companion de una clase normal sí se escribiría Clase.companion). Provócalo tú: en .onOpenURL, cambia DestinationCompanion.shared.parse por Destination.companion.parse y compila (Cmd+B). Xcode responde con un error parecido a este:
type 'any Destination' has no member 'companion'El mensaje es correcto y no dice dónde mirar. La causa es que Destination es una sealed interface, y una interfaz sellada llega a Swift como protocolo. Es el mismo motivo por el que en Los tipos que cruzan a Swift una interfaz de Kotlin llegaba como id<…>, un protocolo. Un protocolo no puede tener un objeto acompañante anidado, así que Kotlin/Native, al generar la cabecera que Swift lee (el resumen de lo que Kotlin ofrece), lo saca aparte y le pega el nombre delante: DestinationCompanion.shared. Las subclases pierden el punto por el mismo motivo: Destination.Detalle es DestinationDetalle. Devuelve la línea a como estaba.
Paso 4 · Probarlo en el simulador
Ejecuta la app (Cmd+R) en un simulador de iPhone, como en Ejecutar en iOS. Con ese simulador abierto, en la Terminal:
xcrun simctl openurl booted 'pokedex://pokemon/6'
xcrun simctl openurl booted 'https://sebastiannarvaez11.github.io/pokedex-kmp/pokemon/6'
xcrun simctl openurl booted 'pokedex://'
xcrun simctl openurl booted 'pokedex://vaya/cosa'simctl es la herramienta que controla los simuladores y openurl booted abre una URL en el que está arrancado. Qué debe pasar con cada una:
pokedex://pokemon/6abre la ficha de Charizard. Pruébalo primero con la app cerrada: en Xcode pulsa Stop (el cuadrado de arriba a la izquierda), que cierra la app en el simulador, o en la Terminalxcrun simctl terminate booted com.sebastiannarvaez.pokedex(o con el identificador de tu app). Lanza el comando: la app se abre sola, ya en la ficha. Después pruébalo con la app abierta: ve a la pestaña Favoritos, lanza el mismo comando otra vez y verás que salta a la Pokédex con la ficha apilada.- El
httpsabre Safari, no la app: tu app no declara ese dominio (haría falta lo del paso 5), así que iOS ni siquiera lo intenta. Y Safari enseña la página del curso que invita a abrir la app (si la web del curso está al día). pokedex://a secas, con una ficha abierta, deja la Pokédex en la lista, sin nada apilado.pokedex://vaya/cosano hace nada:parsedevuelvenily la app sigue como estaba.
Un enlace con una ficha ya abierta. Prueba ahora esto: con la ficha de Charizard abierta en la pestaña Pokédex, lanza xcrun simctl openurl booted 'pokedex://pokemon/25'. Debería abrir Pikachu. Verás que la ficha sigue siendo la de Charizard: es un fallo real, y se arregla con una línea.
Imagina la ficha como una hoja de papel con un nombre escrito. Cuando el enlace cambia el 6 por el 25 en la misma posición de la pila, SwiftUI piensa «es la misma hoja, solo han cambiado los datos», la reutiliza y, como la ficha carga sus datos una sola vez, al abrirse, nunca los vuelve a pedir. Hay que darle a cada hoja su número como etiqueta, para que un número distinto sea una hoja nueva. Eso hace .id(id): SwiftUI tira la vista vieja y crea otra cada vez que el id cambia. Se añade en los dos sitios donde se abre una ficha. Abre iosApp/iosApp/List/PokemonListView.swift, busca el .navigationDestination(for: Int32.self) y añade .id(id) detrás de la ficha:
.navigationDestination(for: Int32.self) { id in
PokemonDetailView(pokemonId: id)
}.navigationDestination(for: Int32.self) { id in
PokemonDetailView(pokemonId: id)
.id(id) // otro número, otra ficha: SwiftUI no reutiliza la anterior
}Y lo mismo en iosApp/iosApp/Favorites/FavoritesView.swift, donde las fichas se abren igual:
.navigationDestination(for: Int32.self) { id in
PokemonDetailView(pokemonId: id)
}.navigationDestination(for: Int32.self) { id in
PokemonDetailView(pokemonId: id)
.id(id)
}Ejecuta otra vez (Cmd+R), abre la ficha de Charizard y lanza el enlace del 25: ahora la ficha pasa a Pikachu. El mismo .id(id) queda también en FavoritesView.swift por prevención: ningún enlace abre hoy una ficha desde Favoritos, pero si algún día lo hace, la ficha se renueva igual.
Opcional · Provócalo. Pon // delante de pestana = .lista en el .onOpenURL, ejecuta, ve a Favoritos y lanza xcrun simctl openurl booted 'pokedex://pokemon/25'. No ves nada: la app pasa al frente y sigue en Favoritos. La ficha se ha apilado en la pila de la Pokédex, que no se está viendo, y aparece de golpe en cuanto tocas esa pestaña. Quita las // antes de seguir.
Paso 5 · Qué costaría pasar a Universal Links
Un Universal Link es un enlace https que abre la app sin diálogo, porque el dominio la respalda. Para eso el dueño del dominio publica un fichero, apple-app-site-association, que dice qué app y qué rutas. Sería así (es JSON: pares «nombre»: valor entre llaves; appIDs dice qué app, y components, qué rutas del dominio le tocan):
{
"applinks": {
"details": [
{
"appIDs": ["TEAMID.com.sebastiannarvaez.pokedex"],
"components": [
{ "/": "/pokemon/*", "comment": "la ficha de un Pokémon" }
]
}
]
}
}"/": "/pokemon/*" significa «las rutas que empiecen por /pokemon/»: el asterisco vale por cualquier cosa que venga detrás (el número del Pokémon). TEAMID es el Team ID (el que pusiste en Config.xcconfig en Ejecutar en iOS): el identificador de diez caracteres que Apple asigna a tu cuenta y que forma parte del identificador completo de la app. Lo que no basta con la cuenta gratuita es la capacidad Associated Domains, la que enciende los Universal Links (una capacidad es un permiso especial que se activa para la app en Xcode, en la pestaña Signing & Capabilities del proyecto): exige el Apple Developer Program, de pago. Y aunque la tuvieras, faltaría lo mismo que en Android: un dominio cuya raíz controles, porque el fichero va en https://<dominio>/.well-known/apple-app-site-association, en la raíz, nunca en una subcarpeta.
Esa CDN explica por qué los Universal Links pueden tardar hasta un día en empezar a funcionar tras publicar el fichero: lo que consulta el dispositivo no es tu servidor. La propia documentación lo cifra: «La CDN de Apple solicita el archivo apple-app-site-association de tu dominio en un plazo de 24 horas».
Paso 6 · Las dos plataformas, una al lado de la otra
| Android | iOS | |
|---|---|---|
| Declarar el esquema | dos intent-filter en el manifiesto | CFBundleURLTypes en el Info.plist |
| App cerrada | onCreate con intent.dataString | .onOpenURL |
| App abierta | onNewIntent (con singleTop) | .onOpenURL, el mismo |
| Al abrir el esquema propio | abre directo | pide confirmación la primera vez |
| Verificar el dominio | assetlinks.json en la raíz | apple-app-site-association, y una CDN de por medio |
| Traducir la URL | Destination.parse | DestinationCompanion.shared.parse |
La última fila es la que resume el curso: la traducción está escrita una sola vez.
Punto de control
xcrun simctl openurl booted 'pokedex://pokemon/6'abre la ficha de Charizard con la app cerrada.- Con la app abierta en Favoritos, el mismo comando salta a la Pokédex y apila la ficha.
- Con la ficha de Charizard abierta,
pokedex://pokemon/25la cambia por la de Pikachu (con.id(id)enPokemonListView.swiftyFavoritesView.swift). pokedex://a secas deja la Pokédex en la lista, sin nada apilado.- Un enlace con basura (
pokedex://vaya/cosa) no hace nada y no revienta. - Cmd+B compila sin errores: Swift ve
DestinationCompanionyDestinationDetalle. - El enlace
httpsabre Safari, en la página del curso que invita a abrir la app (si la web del curso está al día). - Sin tests nuevos: siguen 76 / 40 / 43 (JVM, Android y iOS).
Comprueba lo que entendiste
7 preguntas · necesitas 5 para completar la lección
1.¿Cuántos puntos de entrada necesita SwiftUI para recibir un enlace con la app cerrada y con la app abierta?
2.Swift responde «type 'any Destination' has no member 'companion'». ¿Por qué?
3.¿Qué pasa si se olvida
pestana = .listadentro del.onOpenURL?4.Al abrir
pokedex://pokemon/6en el simulador, ¿qué diferencia hay con Android?5.¿Qué le falta a esta app para usar Universal Links?
6.Con la ficha de Charizard abierta, llega
pokedex://pokemon/25y la pantalla sigue enseñando a Charizard. ¿Qué lo arregla?7.¿Dónde se declara en iOS que la app abre
pokedex://?