El mapa de pantallas compartido
Al terminar: Modelar los destinos una sola vez y convertirlos en URL para las dos plataformas.
Al terminar esta lección, la app tendrá un mapa de pantallas escrito una sola vez, en Kotlin puro, y una función que traduce una URL a una pantalla. Ninguna de las dos plataformas asoma por ese fichero: ni Navigation 3, ni NavigationStack, ni un solo import de interfaz.
La pregunta con la que casi todo el mundo se estrella al montar un KMP con interfaz nativa es «¿qué librería de navegación uso para las dos plataformas?». Es la pregunta equivocada. La buena es qué parte de la navegación es un dato y qué parte es un gesto del sistema. Los datos viajan; los gestos, no.
Cinco cosas distintas que llamamos «navegación»
Cuando dices «navegación» estás juntando cinco piezas. Solo tres son datos:
| Pieza | ¿Se comparte? | Por qué |
|---|---|---|
| El catálogo de pantallas | Sí | Es una lista de nombres: un sealed interface. |
| Los argumentos de cada pantalla | Sí | Es un data class. No toca la interfaz. |
| La traducción a URL y de vuelta | Sí | Es parsear texto: lógica pura y comprobable. |
| La pila hacia atrás | A medias | El modelo sí; el gesto de deslizar desde el borde, no. |
| Transiciones, barras y animaciones | No | Son píxeles, y ahí es donde una app se siente nativa. |
Android ha llegado al mismo sitio por su lado, y eso es lo que hace posible compartir el mapa: las dos plataformas creen hoy que la pila es una lista que posee la app.
Los comandos se escriben en la Terminal de Android Studio, abierta en la raíz del proyecto. Esta lección no instala nada: kotlinx.serialization ya está en shared desde HTTP y Ktor: el cliente de PokeAPI.
Las rutas son las de la vista Project, la que enseña las carpetas tal como están en el disco.
Paso 1 · El mapa, en Kotlin puro
El archivo. Destination.kt es el plano del metro: la lista de pantallas de la app y lo que lleva cada una. Va en commonMain, en un paquete nuevo, navigation. Créalo en la vista Project: clic derecho sobre la carpeta shared → New → File y escribe la ruta desde la carpeta del módulo (el método de El módulo compartido y la prueba de humo). Android Studio crea las carpetas que falten:
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.ktEl concepto. Dos pantallas, y una de ellas con argumento. Fíjate en lo que no va a haber: ni composables, ni vistas, ni rutas escritas como texto del estilo "detalle/{codigo}". El argumento es una propiedad tipada, y el día que Detalle necesite otro dato (por ejemplo, la generación del Pokémon), lo añades aquí y los dos compiladores te dicen dónde falta.
Esta es la primera mitad del fichero: el catálogo y la traducción a URL. Escríbela, con su comentario:
package com.sebastiannarvaez.pokedex.navigation
import kotlinx.serialization.Serializable
/**
* Las pantallas de la app, como dato.
*
* Lo que se comparte es **el mapa**, no la navegacion. Android lo recorre con
* Navigation 3 y iOS con `NavigationStack`, y ninguna de las dos librerias
* asoma por aqui: este fichero es Kotlin puro.
*
* Compartirlo tiene dos pagos concretos. Uno, un enlace profundo se traduce una
* sola vez y las dos apps abren la misma pantalla. Dos, anadir una pantalla
* obliga a tocar un `when` en Kotlin, donde el compilador comprueba que no
* falta ninguna.
*/
@Serializable
sealed interface Destination {
@Serializable
data object Lista : Destination
@Serializable
data class Detalle(val pokemonId: Int) : Destination
/**
* La direccion de esta pantalla.
*
* Sirve para compartir y para los enlaces profundos. Que viva junto al
* `parse` no es casualidad: si se separan, uno cambia y el otro no.
*/
fun toPath(): String = when (this) {
Lista -> "/"
is Detalle -> "/pokemon/$pokemonId"
}
}Las tres anotaciones @Serializable (la de la interfaz y la de cada pantalla) no las usa hoy nadie: las claves de Android llevarán la suya (Navegación en Android con Navigation 3) y iOS no serializa nada. Están para que, el día que haga falta guardar o enviar una pantalla como texto (en el estado que sobrevive a que el sistema cierre la app, por ejemplo), el mapa ya sepa hacerlo sin cambiar de forma.
Con la llave final, el fichero ya compila: en el paso 2 se escribirá otro trozo dentro de esa interfaz, justo encima de esa llave.
El when sin else es el guardia
Ese when sin else es el que hace que el mapa no se quede a medias. Añade mañana una pantalla de favoritos y toPath() deja de compilar hasta que le des una dirección. No es un aviso, no es un test: es el compilador negándose.
Por eso toPath no tiene rama else, y por eso conviene no ponérsela nunca. Un else -> "/" convertiría el guardia en una trampa silenciosa: la pantalla nueva compilaría y se compartiría con la dirección de la lista.
Paso 2 · parse devuelve null, y no lanza
El concepto. toPath convierte una pantalla en texto; parse hace el viaje contrario: recibe la URL de un enlace y decide a qué pantalla lleva. La URL entra del exterior —un chat, un navegador, otra app—, así que puede ser cualquier cosa.
parse recorta texto con varias funciones que aún no has visto juntas: substringAfter (lo que va después de un trozo), substringBefore (lo que va antes), removePrefix (quita un principio), toIntOrNull (el texto como número, o null) y trim/trimStart con un carácter (quitan ese carácter de los extremos). Un ejemplo con datos ajenos, la dirección de una tienda, antes de leer parse:
fun main() {
val url = "tienda://producto/42?color=rojo"
val sinEsquema = url.substringAfter("://", missingDelimiterValue = url) // lo que va después de «://» (o todo, si no está)
println(sinEsquema)
val ruta = sinEsquema.substringBefore('?') // lo que va antes de «?»: fuera los parámetros
println(ruta)
val numero = ruta.removePrefix("producto/") // quita ese principio si lo tiene
println(numero)
println(numero.toIntOrNull()) // el texto como número…
println("rojo".toIntOrNull()) // …o null si no lo es
println("/a/b/".trim('/')) // quita las barras de los dos extremos
println("/a/b/".trimStart('/')) // solo las del principio
}Pégalo en el Kotlin Playground (play.kotlinlang.org) y ejecútalo: imprime
producto/42?color=rojo
producto/42
42
42
null
a/b
a/b/Escríbelo. Es la segunda mitad del fichero.
Tres piezas de sintaxis que verás en parse: const val declara una constante fijada al compilar (un valor que no cambia nunca); un argumento con nombre, como missingDelimiterValue = url, dice para qué sirve el valor que pasas; y when { … } sin argumento evalúa cada condición en orden y ejecuta la rama de la primera que sea verdadera.
Va dentro de la interfaz, debajo de toPath() y antes de la última llave:
companion object {
/** El esquema propio, que funciona sin dominio ni cuenta de pago. */
const val ESQUEMA = "pokedex"
/** El dominio del enlace `https`, que sí se puede compartir por chat. */
const val HOST = "pokedex-kmp.example"
fun parse(url: String): Destination? {
// 1. Quita «esquema://» si lo hay: pokedex://pokemon/25 → pokemon/25
val sinEsquema = url
.substringAfter("://", missingDelimiterValue = url)
// 2. Y, si queda, el dominio del enlace https.
.let { if (it.startsWith(HOST)) it.removePrefix(HOST) else it }
// 3. Deja solo la ruta, con una barra delante y sin «?parámetros».
val ruta = "/" + sinEsquema.trim('/').substringBefore('?')
return when {
ruta == "/" -> Lista // la raíz es la lista
ruta.startsWith("/pokemon/") -> // «/pokemon/25»…
ruta.removePrefix("/pokemon/").toIntOrNull()?.let(::Detalle) // …es el detalle SI lo de después es un número
else -> null // cualquier otra cosa: ninguna pantalla
}
}
}Dos detalles del paso 2. Primero, ese .let { … } del principio no lleva interrogación: se ejecuta siempre, y recibe en it el texto que ya hay, para decidir qué hacer con él. Segundo, removePrefix(HOST) ya no hace nada si el texto no empieza por HOST, así que el if es una redundancia inofensiva: deja escrita la intención («quita el dominio solo si está») para quien lea el código.
Y después de la llave que cierra la interfaz, al final del fichero, dos funciones de extensión que construyen los enlaces:
/** El enlace que se comparte: `https`, para que sea pulsable en cualquier app. */
fun Destination.toShareUrl(): String = "https://${Destination.HOST}${toPath()}"
/** El enlace interno, que abre la app sin pasar por el navegador. */
fun Destination.toDeepLink(): String = "${Destination.ESQUEMA}://${toPath().trimStart('/')}"Comprueba que compila:
./gradlew :shared:compileKotlinJvmLas decisiones. Tres caben en esas pocas líneas.
parse devuelve Destination? y no lanza. Una URL que no entendemos no es un fallo del programa: es alguien pegando cualquier cosa en el navegador, o un enlace viejo de una versión anterior. Un null obliga a la plataforma a decidir qué hacer —normalmente, abrir la lista— y no revienta la app al arrancar.
toPath y parse viven pegados, en el mismo fichero. Separados, uno cambia y el otro no, y el fallo aparece meses después en un enlace que alguien compartió por chat.
Dos esquemas, una sola traducción. Un enlace profundo (deep link) es una URL que abre la app directamente en una pantalla concreta; el esquema es lo que va antes de ://. pokedex://pokemon/25 abre la app directamente y no necesita ni dominio ni cuenta de pago; https://pokedex-kmp.example/pokemon/25 es pulsable en cualquier chat (que ese https abra la app y no el navegador exige vincular la app con un dominio real, plomería de cada sistema que verás en Deep links en Android y Deep links en iOS; con el dominio de ejemplo de esta guía sirve para probar parse). parse se come los dos porque lo primero que hace es quitar el esquema y, si está, el dominio.
Paso 3 · El test que comprueba la ida y la vuelta
El archivo. DestinationTest.kt va en commonTest, la carpeta de tests que se ejecutan para el código común. Créalo igual que el anterior, con la ruta:
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/navigation/DestinationTest.ktUn primer test, a mano. Empieza por el más sencillo: la URL del esquema propio abre el detalle de Pikachu.
package com.sebastiannarvaez.pokedex.navigation
import kotlin.test.Test
import kotlin.test.assertEquals
class DestinationTest {
@Test
fun elEsquemaPropioAbreElDetalle() {
assertEquals(Destination.Detalle(25), Destination.parse("pokedex://pokemon/25"))
}
}Ejecútalo (--tests limita la ejecución a esa clase):
./gradlew :shared:jvmTest --tests '*DestinationTest'Debe terminar en BUILD SUCCESSFUL. Los cuatro tests que faltan son más comprobaciones del mismo tipo, así que no se escriben a mano: abre el fichero, pulsa Copiar fichero (aquí debajo), selecciona todo (Cmd+A) y pega (Cmd+V). Sustituye el contenido, y el primer test que escribiste sigue ahí. El gesto es el de Clases y objetos.
Vuelve a ejecutar la misma orden: ahora son cinco tests. Dos merecen leerse:
@Test
fun loQueNoEntendemosNoRevienta() {
// Alguien pegando cualquier cosa no es un error de programacion.
assertNull(Destination.parse("pokedex://vaya/cosa"))
assertNull(Destination.parse("pokedex://pokemon/pikachu"))
assertNull(Destination.parse("no es una url"))
}
@Test
fun todaPantallaSobreviveAlViajeDeIdaYVuelta() {
// Una instancia de cada pantalla. La lista se mantiene a mano, y el
// compilador ayuda por el otro lado: `toPath()` es un `when` exhaustivo
// sobre una jerarquia sellada, asi que anadir una pantalla **no
// compila** hasta traducirla. Este test cubre lo que el compilador no
// ve: que `parse` sepa deshacer lo que `toPath` hizo.
val todas: List<Destination> = listOf(
Destination.Lista,
Destination.Detalle(151),
)
todas.forEach { destino ->
assertEquals(destino, Destination.parse(destino.toDeepLink()), "falla el esquema propio")
assertEquals(destino, Destination.parse(destino.toShareUrl()), "falla el enlace https")
}
}El primero comprueba lo que decidió el paso 2 (la basura devuelve null). El segundo es el de la ida y la vuelta: toma una pantalla, la convierte en enlace por los dos caminos y comprueba que parse devuelve la misma pantalla.
Sigue habiendo una lista que alguien puede olvidarse de ampliar, y por eso conviene saber quién es el guardia de verdad: el compilador, no el test. El test cubre lo otro, que es que parse sepa deshacer lo que toPath hizo.
Provócalo: añade una pantalla y mira quién te avisa
El guardia del que hablaba el when sin else se ve mejor rompiéndolo. Añade debajo de Detalle esta línea, solo ella, sin tocar nada más:
@Serializable
data object Favoritos : DestinationY compila:
./gradlew :shared:compileKotlinJvmFalla toPath(), y falla en compilación. Entre las líneas del informe verás una como esta (la ruta y los números varían):
e: file:///…/Destination.kt:35:28 'when' expression must be exhaustive. Add the 'Favoritos' branch or an 'else' branch.Eso es exactamente lo que se buscaba: la pantalla nueva no existe hasta que tiene dirección. Lo que no falla es lo que hay que vigilar:
parsesigue compilando. Suwhenes sobre textos, no sobre la jerarquía sellada, así que nadie te obliga a añadir la rama para/favoritos. Sin ella, el enlacepokedex://favoritosdevuelvenully abre la lista. El test de ida y vuelta lo caza, pero solo si te acuerdas de meterDestination.Favoritosen su listatodas.- Swift no dirá nada. Una
sealed interfacede Kotlin llega a Swift como una clase base con subclases, no como unenum: elswitchde iOS exige undefaulty deja de avisarte cuando añades un caso. Está a fondo en Los tipos que cruzan a Swift.
De ahí la regla de esta lección: el compilador de Kotlin te cubre una de las dos puntas del mapa. La otra la cubre el test de ida y vuelta, y por eso vive pegado al fichero que prueba.
Borra la línea de Favoritos: el proyecto de las lecciones siguientes no la tiene.
Por qué NavKey no se pone en el módulo compartido
Navigation 3 trae su propia interfaz marcadora, NavKey, la que ya pusiste en las claves de Marcador (Navegación en Marcador), y es tentador ponerla directamente sobre Destination para ahorrarse una traducción en Android.
Técnicamente se podría: Destination ya es @Serializable, y la parte de Navigation 3 donde vive NavKey (navigation3-runtime) se publica también para iOS. La razón de no hacerlo es de diseño: el módulo compartido pasaría a depender de una librería de AndroidX solo para una interfaz marcadora que ni la lógica compartida ni iOS usan para nada. Un núcleo con menos dependencias compila más rápido y se prueba más fácil, y lo que se ahorra es muy poco: un when de dos ramas en la frontera de Android, que se escribirá cuando un enlace tenga que convertirse en pantalla, en Deep links en Android.
Punto de control
./gradlew :shared:jvmTest --tests '*DestinationTest'termina enBUILD SUCCESSFULcon los cinco tests deDestinationTest../gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Testtermina enBUILD SUCCESSFULcon 37 tests en la JVM, 36 en Android host y 38 en iOS sin contar los de la carpetaejercicios: 5 más que al terminar El ViewModel del detalle. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.Destination.ktno tiene ni unimportde Navigation 3 ni de nada de interfaz: solokotlinx.serialization.toPath()es unwhensin ramaelse, y has visto al compilador negarse cuando faltaba una rama.- Los tests de
DestinationTestcomprueban queparseabre la misma pantalla conpokedex://pokemon/25y conhttps://pokedex-kmp.example/pokemon/25, y que devuelvenull(sin lanzar) conno es una url.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.De todo lo que llamamos «navegación», ¿qué es lo que sube a
commonMainen este proyecto?2.¿Qué devuelve
Destination.parse("pokedex://pokemon/pikachu")?3.
Destination.parse(url)devuelveDestination?en vez de lanzar una excepción. ¿Por qué?4.¿Por qué
Destinationno implementa directamenteNavKey, la interfaz marcadora de Navigation 3?5.Añades una pantalla nueva a
Destinationy no tocas nada más. ¿Qué parte del mapa te avisa y qué parte no?