Saltar al contenido
K
Cimientos del código compartidoPaso a paso38 min de lectura · 12 min de máquina Básico

La primera dependencia

Al terminar: Añadir Kermit al código compartido y ver el mismo log en Logcat y en la consola de Xcode.

Hasta ahora el módulo compartido no dependía de nada: solo Kotlin y lo que trae el propio compilador. Al terminar esta lección tendrá su primera librería externa, y la misma línea de código, escrita una sola vez, aparecerá en Logcat y en la consola de Xcode.

La librería es Kermit, el registro multiplataforma de Touchlab. Pero lo que se aprende aquí no es Kermit: es la receta para añadir cualquier dependencia a commonMain. Comprobar que publica tus targets, declararla en el catálogo, ponerla en el source set, saber qué decide api frente a implementation y comprobar que el resultado llega vivo a las dos plataformas. Ese último paso tiene una trampa, que verás en el paso 7: un registro que existe pero que una consulta no enseña.

Paso 1 · Qué instalas y por qué ahora

Kermit es una librería de registro de mensajes (logging): una llamada como registro.i { "hola" } y el mensaje sale por el sitio que corresponde a cada plataforma. Se instala en el módulo compartido porque el código común quiere registrar sin saber dónde corre, y se instala ahora porque, a partir de aquí, hay código que corre en dos plataformas y conviene poder ver qué hace en cada una. En el paso 6 lo usará Greeting.

Paso 2 · Comprueba que publica tus targets antes de escribir nada

Una librería de Kotlin Multiplatform no publica un solo archivo (un artefacto, en la jerga): publica uno por target, y un fichero .module que dice cuáles hay. Si la librería no publica ios_simulator_arm64, tu proyecto compilará en Android y fallará en cuanto Gradle compile para iOS, con un No matching variant of … was found (el error 4 de la caja «Si Sync sale en rojo», más abajo).

curl -s https://repo1.maven.org/maven2/co/touchlab/kermit/maven-metadata.xml | grep -E "<latest>|<release>"

curl -s descarga la página (el -s es «sin barra de progreso»). El | pasa lo descargado al comando siguiente, y grep -E deja solo las líneas que contienen alguna de las dos palabras (el | de dentro de las comillas significa «o»). Verás dos líneas, <latest> y <release>, con el número de versión entre las etiquetas; es el número que se usa en el paso 3.

Los dos que le importan a este proyecto son ios_arm64 e ios_simulator_arm64. Para verlos con tus ojos, abre en el navegador https://repo1.maven.org/maven2/co/touchlab/kermit/2.2.0/kermit-2.2.0.module y busca esas dos palabras. Esa comprobación tarda medio minuto y te ahorra un error larguísimo en el que el target solo aparece escondido entre atributos.

Pokedex-KMP
gradle
libs.versions.tomlse modifica: [versions] y [libraries]
shared
build.gradle.ktsse modifica: sourceSets
Los dos ficheros de toda instalación: el catálogo y el build.gradle.kts del módulo que usa la librería.

Abre gradle/libs.versions.toml. Kermit tiene su propio número de versión, así que hacen falta dos líneas, cada una en su sección: la versión, al final de [versions], y la librería, al final de [libraries] (antes de la línea en blanco y de la cabecera [plugins]).

[versions]: la línea nueva
kermit = "2.2.0"
[libraries]: la línea nueva
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }

module junta las dos mitades de las coordenadas (grupo:nombre) y version.ref = "kermit" dice «la versión es la que se llama kermit en [versions]». El alias kermit se usará como libs.kermit. No es un plugin, así que no se toca el build.gradle.kts de la raíz.

Paso 4 · La dependencia, en commonMain

Abre shared/build.gradle.kts (en la vista Project, dentro de la carpeta shared). Dentro de kotlin { } ya hay un bloque sourceSets { } con commonTest.dependencies. Deja ese bloque como el de abajo: la parte nueva es commonMain.dependencies, que va antes de commonTest.dependencies.

sourceSets: el bloque nuevo (con comentarios)
    sourceSets {
        commonMain.dependencies {        // librerías del código común: la usan TODOS los targets
            api(libs.kermit)             // api: los demás módulos (androidApp) y el framework de iOS pueden usarla; ver abajo
        }
        commonTest.dependencies {        // ya estaba: librerías de los tests comunes
            implementation(kotlin("test"))
        }
    }

Una sola línea, y Gradle resuelve una variante distinta por cada target (lo viste con kotlin("test") en Gradle sin dolor). Pulsa Sync Now.

api, implementation y export

Aquí hay dos destinatarios de lo que shared ofrece: androidApp, que es otro módulo de Gradle, y Swift, que lee el framework de iOS (el paquete que Kotlin/Native construye con tu código para que Xcode lo use; enlazarlo es unirlo a la app). Para decidir entre api e implementation hace falta una idea nueva: la firma pública, que es lo que se ve de una función o clase desde fuera del módulo: su nombre, sus parámetros y lo que devuelve. Cuando un tipo de una librería aparece ahí, decimos que asoma. Un ejemplo mínimo, con datos ajenos al proyecto:

ejemplo mínimo: una firma sin tipos de librería y otra con uno (con comentarios; no lo escribas)
// Ambas funciones están en shared, en commonMain, y son públicas.
fun saludo(): String = "hola"                           // firma: saludo(), devuelve String.
                                                        // Solo tipos de Kotlin: no asoma nada.
fun registroSonda(): Logger = Logger.withTag("sonda")   // firma: registroSonda(), devuelve un Logger.
                                                        // Logger es un tipo de Kermit: asoma.

Con esa segunda función, lo que se midió en el proyecto fue esto:

  • androidApp con api(libs.kermit): compila y puede escribir registroSonda().i { "hola" }. Con implementation(libs.kermit) falla con Cannot access class 'co.touchlab.kermit.Logger'. Check your module classpath for missing or conflicting dependencies.: la app ve la firma, pero no tiene la librería para entender el tipo.
  • El framework de iOS ya contiene Logger, con api y con implementation, pero con un nombre con prefijo. Es la línea que Kotlin escribe en Shared.h, el fichero que describe a Swift lo que el framework ofrece:
Shared.h: sin export (con api o con implementation)
+ (SharedKermitLogger *)registroSonda __attribute__((swift_name("registroSonda()")));
  • Para el nombre limpio hay que exportar. export(libs.kermit) va dentro del bloque del framework en shared/build.gradle.kts, y le dice «los tipos de esta librería llegan a Swift con su nombre, sin prefijo»:
ejemplo mínimo: export (con comentarios; no lo escribas)
iosTarget.binaries.framework {
    baseName = "Shared"
    export(libs.kermit)    // solo se puede exportar una dependencia declarada con api
}
Shared.h: con export y api
+ (SharedLogger *)registroSonda __attribute__((swift_name("registroSonda()")));

Y con export y implementation a la vez, el enlazado se detiene con Following dependencies exported in the debugFramework binary are not specified as API-dependencies of a corresponding source set. Por eso export exige api: api es la condición, no la exportación.

La regla es: implementation por defecto, y api cuando un tipo de la librería asoma en una firma pública. Mirar si compila hoy no basta: si el consumidor recibe la librería por otro camino, con implementation también compila, y el día que deje de recibirla falla. Lo que cuenta es lo que shared promete en sus firmas.

En este proyecto Log (el paso 5) esconde Kermit: sus funciones solo reciben y devuelven String y Throwable?, y el Logger queda dentro, privado. Ningún tipo de Kermit asoma, y por eso hoy bastaría implementation. El proyecto declara api(libs.kermit) por prudencia, para no cambiarla el día que androidApp o Swift necesiten un tipo de la librería; api no es «lo seguro», porque cada dependencia api la heredan los consumidores. Si un ejemplo posterior del curso usa una palabra distinta de la que pediría la regla, se explica allí con su motivo.

Sync Now y comprobar

Sync Now debe terminar sin errores rojos. Para ver que la librería ha llegado, y a cuál de sus variantes, en la Terminal. :shared:dependencies pide a Gradle el árbol de dependencias del módulo shared, y --configuration iosSimulatorArm64CompileKlibraries lo limita a lo que se usa al compilar para el simulador de iOS (un klib es el formato de librería de Kotlin/Native):

./gradlew :shared:dependencies --configuration iosSimulatorArm64CompileKlibraries
iosSimulatorArm64CompileKlibraries - Compile classpath for 'iosSimulatorArm64/main'.
+--- co.touchlab:kermit:2.2.0
|    \--- co.touchlab:kermit-iossimulatorarm64:2.2.0
|         +--- co.touchlab:kermit-core:2.2.0
|         |    \--- co.touchlab:kermit-core-iossimulatorarm64:2.2.0
|         |         \--- org.jetbrains.kotlin:kotlin-stdlib:2.3.21 -> 2.4.20
|         \--- org.jetbrains.kotlin:kotlin-stdlib:2.3.21 -> 2.4.20
\--- org.jetbrains.kotlin:kotlin-stdlib:2.4.20

El árbol se lee de arriba abajo: cada +--- o \--- es una dependencia, y lo que cuelga con más sangría es lo que esa trae. Una línea tuya, y para iOS Gradle ha escogido kermit-iossimulatorarm64. Repite la consulta con jvmCompileClasspath y saldrá kermit-jvm; con iosArm64CompileKlibraries, la del iPhone. La flecha 2.3.21 -> 2.4.20 es Gradle alineando la librería estándar de Kotlin con la de tu proyecto: Kermit se compiló con Kotlin 2.3.21 y tu proyecto usa 2.4.20.

Paso 5 · Un Logger propio, no el estático

Kermit trae un Logger global listo para usar. No lo usamos: el proyecto envuelve uno propio en un object (el singleton que viste en object, companion y vararg). El fichero usa además argumentos con nombre y por defecto (Funciones y parámetros), Throwable? (Nulabilidad, Excepciones: try, catch y require) y una lambda final fuera de los paréntesis (Lambdas y funciones de orden superior).

Antes de escribir el fichero, un ejemplo mínimo de Kermit con datos ajenos al proyecto. Un nivel es la importancia de un mensaje (de menos a más: depuración, información, aviso, error) y el umbral (minSeverity) es el nivel por debajo del cual el mensaje no sale.

ejemplo mínimo: niveles de Kermit (con comentarios)
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import co.touchlab.kermit.loggerConfigInit
import co.touchlab.kermit.platformLogWriter
 
val registro = Logger(
    config = loggerConfigInit(platformLogWriter(), minSeverity = Severity.Info), // umbral: de Info para arriba
    tag = "Tienda",                                  // la etiqueta que filtras en Logcat
)
registro.d { "carrito abierto" }                     // Debug: por debajo del umbral, no sale
registro.i { "3 productos en el carrito" }           // Info
registro.w { "queda 1 litro de leche" }              // Warn: aviso
registro.e(IllegalStateException("sin stock")) { "no se pudo pagar" } // Error, con la excepción que lo causó

Salida real (se ejecutó dentro de un test de la JVM, donde Kermit escribe en la consola; no hace falta que lo repitas):

Info: (Tienda) 3 productos en el carrito
Warn: (Tienda) queda 1 litro de leche
Error: (Tienda) no se pudo pagar
java.lang.IllegalStateException: sin stock

En Logcat las mismas líneas salen como I Tienda : …, W Tienda : … y E Tienda : …. El Log de abajo no es el Log de Android: es un object tuyo, en el paquete core, y por eso no lleva etiqueta en cada llamada (la pone una vez).

Una diferencia con el ejemplo: aquí d e i pasan el texto suelto, y w y e lo pasan dentro de una lambda { mensaje }. Es porque w y e reciben además la causa (la excepción), y Kermit pide la causa primero y el mensaje al final, en lambda; d e i no tienen causa y usan la forma corta.

Crea el fichero como en El módulo compartido y la prueba de humo: clic derecho sobre shared → New → File y escribe la ruta completa src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/Log.kt (Android Studio crea la carpeta core si no existe). Plan B desde la Terminal de Android Studio: mkdir -p shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core y touch shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/Log.kt. Ábrelo y pega esto, entero:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/core/Log.kt
package com.sebastiannarvaez.pokedex.core
 
import co.touchlab.kermit.Logger
import co.touchlab.kermit.Severity
import co.touchlab.kermit.loggerConfigInit
import co.touchlab.kermit.platformLogWriter
 
/**
 * El registro de la app, con una sola etiqueta para todo.
 *
 * Se usa un `Logger` propio y no el estatico de Kermit porque el estatico es
 * estado global: dos modulos que lo configuren distinto se pisan, y en un test
 * no hay forma de silenciarlo.
 *
 * `platformLogWriter()` resuelve solo el destino de cada plataforma: Logcat en
 * Android y `os_log` en iOS. No hay que escribir nada por plataforma.
 */
object Log {
 
    private val logger: Logger = Logger(
        config = loggerConfigInit(
            platformLogWriter(),
            // En una compilacion de release este umbral sube, para no dejar en
            // el registro del dispositivo nada que no deba estar ahi.
            minSeverity = Severity.Debug,
        ),
        tag = "Pokedex",
    )
 
    fun d(mensaje: String) = logger.d(mensaje)
 
    fun i(mensaje: String) = logger.i(mensaje)
 
    fun w(mensaje: String, causa: Throwable? = null) = logger.w(causa) { mensaje }
 
    fun e(mensaje: String, causa: Throwable? = null) = logger.e(causa) { mensaje }
}

Los comentarios del fichero ya dicen el porqué de cada decisión. Una aclaración sobre el del umbral: Severity.Debug deja pasar todos los niveles, y es lo que usa el proyecto mientras se desarrolla; la subida del umbral para una app publicada es una decisión que se toma al publicar, no algo que el código haga solo. Dos detalles más, línea a línea:

Log.kt: dos líneas, explicadas (con comentarios)
// platformLogWriter() elige solo el destino: Logcat en Android y `os_log` (el registro del sistema de Apple) en iOS.
// No hace falta escribir código distinto por plataforma (lo que la lección siguiente llama expect/actual):
// antes de dividir el código por plataforma, mira si tu librería ya lo resolvió.
platformLogWriter(),
 
// El mensaje va en una lambda { mensaje }: Kermit solo la ejecuta si el nivel está activo,
// así que no se construye un texto que nadie va a leer.
fun w(mensaje: String, causa: Throwable? = null) = logger.w(causa) { mensaje }

Paso 6 · Llamarlo desde el saludo

Abre Greeting.kt (en commonMain, paquete com.sebastiannarvaez.pokedex) y sustituye su contenido: pasa a devolver la frase y a registrarla. Es el fichero entero:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Greeting.kt
package com.sebastiannarvaez.pokedex
 
import com.sebastiannarvaez.pokedex.core.Log
 
/**
 * La prueba de humo del proyecto: si esta frase aparece en las dos pantallas,
 * el codigo compartido esta llegando a Android y a iOS.
 */
class Greeting {
    private val platform = currentPlatform()
 
    fun greet(): String {
        val saludo = "Hola desde ${platform.name}"
        // La misma linea de codigo acaba en Logcat y en la consola de Xcode.
        Log.i(saludo)
        return saludo
    }
}

Paso 7 · Comprobarlo en las dos plataformas

En Android sale a la primera. Ejecuta la app y pide el registro filtrando por la etiqueta:

# adb es la herramienta de Android que habla con el emulador o el móvil conectado.
# logcat es su registro; -d lo vuelca y termina; -s deja solo las líneas con esa etiqueta
adb logcat -d -s "Pokedex"
09-10 18:37:32.613 14875 14875 I Pokedex : Hola desde Android 35

En iOS, ejecuta la app desde Xcode (Cmd+R). La forma más fácil de ver el registro es la consola de Xcode: si no ves la zona de texto bajo el editor, ábrela con View → Debug Area → Activate Console. Ahí aparece la línea de Kermit sin hacer nada más. Desde la Terminal, la consulta equivalente no la muestra. <UDID> es el identificador del simulador arrancado (se explica en la última caja de este paso), y el nombre de la app, Pokedex, es el del esquema que ves junto al botón Run.

xcrun simctl es la herramienta de Apple que controla los simuladores desde la Terminal; spawn ejecuta un comando dentro del simulador, aquí log show, que lee su registro.

xcrun simctl spawn <UDID> log show --last 2m --predicate 'process == "Pokedex"'

Punto de control

  • libs.versions.toml declara kermit = "2.2.0" en [versions] y la entrada kermit en [libraries], cada una en su sección.
  • En shared/build.gradle.kts, la dependencia entra en commonMain.dependencies con api(libs.kermit).
  • ./gradlew :shared:dependencies --configuration iosSimulatorArm64CompileKlibraries muestra co.touchlab:kermit-iossimulatorarm64:2.2.0.
  • ./gradlew :androidApp:assembleDebug termina en BUILD SUCCESSFUL.
  • adb logcat -d -s "Pokedex" enseña la línea I Pokedex : Hola desde Android N.
  • En la consola de Xcode aparece la línea con el emoji y la etiqueta Pokedex; en la Terminal, log show con --info --debug enseña la misma frase, y sin esas banderas, no.
  • Log.kt no usa el Logger estático de Kermit: construye el suyo con loggerConfigInit(...).

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué se comprueba en el fichero .module de una librería antes de añadirla a commonMain?

  2. 2.Kermit se declara con api(libs.kermit) en commonMain. ¿Qué consigue api que no consigue implementation?

  3. 3.El proyecto crea su propio Logger en vez de usar el estático de Kermit. ¿Cuál es la razón práctica?

  4. 4.platformLogWriter() escribe en Logcat en Android y en os_log en iOS, y tú no escribes ni una línea distinta por plataforma. ¿Qué enseña eso?

  5. 5.En el simulador, xcrun simctl spawn <UDID> log show --last 2m --predicate 'process == "Pokedex"' no enseña ninguna línea de Kermit. ¿Qué está pasando?

Faltan 5 de 5