Saltar al contenido
K
Detalle y navegaciónPaso a paso35 min de lectura · 8 min de máquina Intermedio

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 pantallasSíEs una lista de nombres: un sealed interface.
Los argumentos de cada pantallaSíEs un data class. No toca la interfaz.
La traducción a URL y de vueltaSíEs parsear texto: lógica pura y comprobable.
La pila hacia atrásA mediasEl modelo sí; el gesto de deslizar desde el borde, no.
Transiciones, barras y animacionesNoSon 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.kt
shared
src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.ktse crea
src/commonTest/kotlin/com/sebastiannarvaez/pokedex/navigation/DestinationTest.ktse crea (paso 3)
Un paquete nuevo, con un fichero y su test.

El 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:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.kt
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:

ejemplo mínimo: recortar una dirección por partes (con comentarios)
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:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.kt
    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:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.kt
/** 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:compileKotlinJvm

Las 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.kt

Un primer test, a mano. Empieza por el más sencillo: la URL del esquema propio abre el detalle de Pikachu.

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/navigation/DestinationTest.kt
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.

DestinationTest.kt

Vuelve a ejecutar la misma orden: ahora son cinco tests. Dos merecen leerse:

shared/src/commonTest/kotlin/com/sebastiannarvaez/pokedex/navigation/DestinationTest.kt
    @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:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/navigation/Destination.kt
    @Serializable
    data object Favoritos : Destination

Y compila:

./gradlew :shared:compileKotlinJvm

Falla 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:

  1. parse sigue compilando. Su when es sobre textos, no sobre la jerarquía sellada, así que nadie te obliga a añadir la rama para /favoritos. Sin ella, el enlace pokedex://favoritos devuelve null y abre la lista. El test de ida y vuelta lo caza, pero solo si te acuerdas de meter Destination.Favoritos en su lista todas.
  2. Swift no dirá nada. Una sealed interface de Kotlin llega a Swift como una clase base con subclases, no como un enum: el switch de iOS exige un default y 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 en BUILD SUCCESSFUL con los cinco tests de DestinationTest.
  • ./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test termina en BUILD SUCCESSFUL con 37 tests en la JVM, 36 en Android host y 38 en iOS sin contar los de la carpeta ejercicios: 5 más que al terminar El ViewModel del detalle. Si hiciste ejercicios en módulos anteriores, tu cifra será mayor.
  • Destination.kt no tiene ni un import de Navigation 3 ni de nada de interfaz: solo kotlinx.serialization.
  • toPath() es un when sin rama else, y has visto al compilador negarse cuando faltaba una rama.
  • Los tests de DestinationTest comprueban que parse abre la misma pantalla con pokedex://pokemon/25 y con https://pokedex-kmp.example/pokemon/25, y que devuelve null (sin lanzar) con no es una url.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.De todo lo que llamamos «navegación», ¿qué es lo que sube a commonMain en este proyecto?

  2. 2.¿Qué devuelve Destination.parse("pokedex://pokemon/pikachu")?

  3. 3.Destination.parse(url) devuelve Destination? en vez de lanzar una excepción. ¿Por qué?

  4. 4.¿Por qué Destination no implementa directamente NavKey, la interfaz marcadora de Navigation 3?

  5. 5.Añades una pantalla nueva a Destination y no tocas nada más. ¿Qué parte del mapa te avisa y qué parte no?

Faltan 5 de 5