Saltar al contenido
K
Apéndice: más allá de la appConcepto70 min de lectura · 15 min de máquina Avanzado

Publicar una librería KMP

Al terminar: Publicar :torneo como librería en tu ordenador, consumirla por sus coordenadas, empaquetarla para iOS y vigilar su compatibilidad binaria.

:torneo ya tiene dos consumidores y los dos son tuyos: :app lo usa con project(":torneo"), y la app de iOS no depende de él (sigue con su propio paquete en Swift). Para un módulo así no hace falta publicar nada, y conviene decirlo antes de empezar: la mayoría de los módulos compartidos no necesitan publicarse jamás. Esta lección va de qué cambia cuando sí hace falta: convertir :torneo en una librería con nombre y versión que otro equipo pueda usar sin tener tu proyecto.

Lo harás de verdad, pero en tu ordenador, sin subir nada a internet: publicarás :torneo, mirarás qué ficheros salen, la usarás desde :app como lo haría un tercero, y verás qué le faltaría para publicarse fuera. Necesitas haber terminado Migrar un proyecto Android existente y Migrar el disco con expect/actual, y el Mac con Xcode de esas lecciones. El panel del proyecto está en la vista Android (la que dejaste al final de la lección anterior): los ficheros de Gradle cuelgan de Gradle Scripts, y en la Terminal de Android Studio ejecutas ./gradlew.

Temas: coordenadas y versiones, maven-publish, XCFramework y compatibilidad binaria.

Consumir lo tuyo frente a publicar para terceros

Este montaje tiene una propiedad que se pierde en cuanto publicas: no hay versión que cuadrar. Si cambias una firma en :torneo, el compilador de :app se queja en el mismo build.

Consumir tu propio móduloPublicar para terceros
Versiónel estado de tu proyectosemántica, y para siempre
Romper una APIlo arregla el mismo buildrompe compilaciones ajenas
Destinoslos que tú usaslos que use cualquiera
Formatouna carpeta de tu proyectoMaven, XCFramework, Package.swift
Coste de un cambiominutosuna versión y sus notas

Tres de esos nombres son nuevos. Maven es el formato de repositorio de librerías de la JVM y de Android (una carpeta, local o en internet, con una subcarpeta por librería y versión). Un XCFramework es el paquete que Xcode entiende para usar código nativo de otro; lo construirás más abajo. Y Package.swift es el fichero que describe un paquete de Swift, y que viste en Tu primer paquete de Swift.

«Semántica» se refiere al versionado semántico (semver): la versión son tres números, mayor.menor.parche. Se sube el parche por un arreglo, el menor por algo nuevo que no rompe nada y el mayor cuando algo deja de ser compatible. Así, quien depende de tu 1.4.2 sabe que 1.5.0 es seguro y que 2.0.0 puede romperle la compilación.

Paso · Instala maven-publish y ponle nombre y versión

Objetivo: que :torneo sepa publicarse, con unas coordenadas propias.

Gradle Scripts
build.gradle.kts (Module :torneo)se modifica: plugins, group y version
El fichero que tocas en este paso.

Es la receta «Instala X», la más corta de todas:

1 · Qué instalas y por qué ahora. maven-publish es el plugin de Gradle que sabe publicar un módulo en un repositorio de Maven, el formato de repositorio de librerías de la JVM y de Android. Hace falta ya, porque sin él :torneo no sabe qué es «publicarse».

2 · Versión y catálogo. Ninguno: es un plugin que viene dentro de Gradle, como java-library o application de la primera lección (Tu primer proyecto en Kotlin). No lleva versión, no pasa por libs.versions.toml y no se declara en la raíz.

3 · El módulo. En build.gradle.kts (Module :torneo), añade el plugin al bloque plugins y, justo debajo de ese bloque, las coordenadas de la librería. Los comentarios explican cada línea:

build.gradle.kts (Module :torneo): plugin, group y version
plugins {
    alias(libs.plugins.kotlin.multiplatform)
    alias(libs.plugins.android.kmp.library)
    alias(libs.plugins.kotlin.serialization)
    // Viene con Gradle: sin versión y sin catálogo.
    id("maven-publish")
}
 
// Las coordenadas de una librería son tres: grupo : nombre : versión.
// El grupo, como un nombre de dominio al revés, para que no choque con el de nadie.
group = "com.sebastiannarvaez.marcador"
// La versión, con `mayor.menor.parche`. Sin esta línea, Gradle publica la versión «unspecified».
version = "1.0.0"
// El nombre sale solo: es el del módulo, `torneo`.

Las tres juntas se escriben com.sebastiannarvaez.marcador:torneo:1.0.0, y así la nombraría quien la usara. Pulsa Sync Now. Debe terminar sin errores rojos; si sale rojo, mira la caja «Si Sync sale en rojo» de Los ficheros de Gradle de tu proyecto.

Paso · Publica en tu ordenador

Objetivo: ver qué sale cuando :torneo se publica.

Maven tiene un repositorio local: la carpeta .m2/repository de tu carpeta personal. Publicar ahí no sube nada a internet, y es la forma de probar una publicación entera sin permiso de nadie. En la Terminal de Android Studio:

./gradlew :torneo:publishToMavenLocal

Verás una tarea por cada destino, y la última las junta:

> Task :torneo:publishKotlinMultiplatformPublicationToMavenLocal
> Task :torneo:publishJvmPublicationToMavenLocal
> Task :torneo:publishIosSimulatorArm64PublicationToMavenLocal
> Task :torneo:publishIosArm64PublicationToMavenLocal
> Task :torneo:publishAndroidPublicationToMavenLocal
> Task :torneo:publishToMavenLocal
 
BUILD SUCCESSFUL

(La guía de publicación de Gradle lo confirma: publicar en el repositorio local de Maven copia la publicación a la carpeta .m2/repository de tu carpeta personal, con su .pom y otros metadatos.)

Mira lo que ha salido (ls lista el contenido de una carpeta; ~ es tu carpeta personal):

ls ~/.m2/repository/com/sebastiannarvaez/marcador
torneo
torneo-android
torneo-iosarm64
torneo-iossimulatorarm64
torneo-jvm

No es un artefacto, son cinco: uno por destino, más uno «raíz» que se llama como el módulo.

El «host» es el ordenador donde compilas, aquí tu Mac, que puede compilar los destinos de iOS. Con el plugin de Android que ya usa :torneo, la publicación de Android sale sin añadir nada.

com/sebastiannarvaez/marcador/
torneo/1.0.0/la raíz: .module, .pom y un .jar casi vacío
torneo-jvm/1.0.0/torneo-jvm-1.0.0.jar, -sources.jar, .module, .pom
torneo-android/1.0.0/torneo-android-1.0.0.aar, -sources.jar, .module, .pom
torneo-iosarm64/1.0.0/torneo-iosarm64-1.0.0.klib, -sources.jar, .module, .pom
torneo-iossimulatorarm64/1.0.0/lo mismo, para el simulador
Dentro de cada carpeta hay una por versión (1.0.0). Esto es lo que lleva cada una.

Tres extensiones nuevas:

  • .jar: el código compilado de la JVM, en bytecode (el código intermedio que entiende la JVM, no el que escribes). .aar: el equivalente para Android (código más recursos). .klib: el formato de librería de Kotlin/Native, que es el compilador de Kotlin que produce código nativo para iOS, sin JVM.
  • .pom: la etiqueta clásica de Maven (un pequeño fichero XML), con el nombre y las dependencias.
  • .module: los metadatos de Gradle, que dicen «esta librería tiene una variante para cada plataforma».

La raíz no lleva clases: lleva metadatos. Es la que resuelve, cuando alguien escribe la dependencia en su código común, cuál de las carpetas hijas le corresponde a cada plataforma. Ese reparto lo hace posible el .module.

(Es lo que Gradle llama Gradle Module Metadata: un formato pensado para que la resolución de dependencias sea multiplataforma y consciente de las variantes.)

Dos trampas que ahorran horas, las dos reproducibles:

Paso · Úsala como lo haría otro equipo

Objetivo: consumir :torneo por sus coordenadas, no con project(":torneo"), para comprobar que lo publicado funciona. Es un experimento: al terminar lo deshaces.

Gradle Scripts
settings.gradle.kts (Project Settings)se modifica: mavenLocal()
build.gradle.kts (Module :app)se modifica: la dependencia
Los dos ficheros del experimento, los dos temporales.

Primero, el cambio que va a fallar. En build.gradle.kts (Module :app), busca implementation(project(":torneo")) y cámbiala por las coordenadas de la librería:

build.gradle.kts (Module :app): dependencia por coordenadas
    // Antes: implementation(project(":torneo"))
    // Ahora, grupo : nombre : versión, como la escribiría un equipo que no tiene tu proyecto.
    implementation("com.sebastiannarvaez.marcador:torneo:1.0.0")

(En un proyecto real la pondrías en el catálogo, como [libraries]; aquí es un experimento corto.) Pulsa Sync Now y falla:

Could not find com.sebastiannarvaez.marcador:torneo:1.0.0.
Searched in the following locations:
  - https://repo.maven.apache.org/maven2/com/sebastiannarvaez/marcador/torneo/1.0.0/torneo-1.0.0.pom

Gradle solo busca donde le han dicho, y tu proyecto solo lo manda a Google y a Maven Central (mavenCentral()), que no conocen tu librería. Dile que mire también en el repositorio local. En settings.gradle.kts (Project Settings), dentro de dependencyResolutionManagement { repositories { … } }, añade mavenLocal() debajo de mavenCentral():

settings.gradle.kts: el repositorio local, dentro de dependencyResolutionManagement
        google()
        mavenCentral()
        // El repositorio local de Maven: la carpeta .m2/repository donde publicaste.
        mavenLocal()

Los repositorios van aquí, en settings.gradle.kts, y no en el build.gradle.kts de un módulo: si un módulo declara los suyos, Gradle se niega (Build was configured to prefer settings repositories over project repositories but repository 'MavenLocal' was added by build file 'app/build.gradle.kts'). Pulsa Sync Now y comprueba que :app compila con la librería publicada:

./gradlew :app:assembleGratisDebug
./gradlew :app:dependencies --configuration gratisDebugRuntimeClasspath | grep "marcador:torneo"

La segunda orden pide la lista de librerías de :app y se queda con las de :torneo: grep deja solo las líneas que contienen el texto que le das, y | le pasa la salida de la orden anterior.

+--- com.sebastiannarvaez.marcador:torneo:1.0.0
|    \--- com.sebastiannarvaez.marcador:torneo-android:1.0.0

Pidió torneo y recibió torneo-android: el .module de la raíz le dijo cuál le servía a una app de Android. Eso es lo que hace una librería multiplataforma publicada.

Deshaz el experimento. Vuelve a poner implementation(project(":torneo")) en build.gradle.kts (Module :app), borra la línea de mavenLocal() de settings.gradle.kts, y pulsa Sync Now.

Paso · Para iOS: un XCFramework

Objetivo: producir el paquete que Xcode entiende, que no es un .klib.

Un consumidor de Swift no sabe qué es un .klib ni quiere saberlo. Lo que espera es un paquete que Xcode entienda. Hasta ahora build.gradle.kts (Module :torneo) pide un framework por cada destino de iOS (uno para el iPhone, otro para el simulador). Un XCFramework los agrupa en uno solo.

En build.gradle.kts (Module :torneo) haz dos cambios. Primero, una línea nueva en lo más alto del fichero, encima de plugins (los import de un fichero de Gradle van siempre arriba del todo):

build.gradle.kts (Module :torneo): primera línea del fichero
// Importa la clase que sabe agrupar frameworks.
import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework

Segundo, el bloque de los destinos de iOS cambia en dos líneas. Este es el resultado, con los comentarios en las líneas nuevas:

build.gradle.kts (Module :torneo): destinos de iOS con XCFramework
    // El paquete que agrupará los frameworks, con nombre `Torneo`.
    val xcf = XCFramework("Torneo")
    listOf(iosArm64(), iosSimulatorArm64()).forEach { ios ->
        ios.binaries.framework {
            baseName = "Torneo"
            // Mete este framework (el del iPhone o el del simulador) en el paquete.
            xcf.add(this)
            isStatic = true
        }
    }

Pulsa Sync Now y construye el paquete:

./gradlew :torneo:assembleTorneoReleaseXCFramework

La tarea se llama como el XCFramework("Torneo") que declaraste. El resultado queda en torneo/build/XCFrameworks/release/Torneo.xcframework, con una carpeta por arquitectura: ios-arm64 (iPhone) e ios-arm64-simulator (simulador).

Un XCFramework es el formato; falta el canal para entregarlo. Hay dos, y la documentación es clara sobre la diferencia:

La alternativa más antigua es CocoaPods, otro gestor de dependencias de iOS: se configura con un fichero Podfile y necesita Ruby instalado en el Mac del consumidor. Kotlin también puede entregar un XCFramework por ese canal, pero según la documentación su integración no se puede combinar con embedAndSignAppleFrameworkForXcode, la tarea de la integración directa. Swift Package Manager, en cambio, viene con Xcode y no exige nada más.

Quién consumeQué le dasPor qué
Tu propio equipo, mismo proyectonada: una fase de scriptel proyecto ya es la versión
Equipo de iOS en otro proyectoXCFramework + Package.swiftSwift Package Manager viene con Xcode

Si dudas, Swift Package Manager.

Paso · Qué exportar y qué no

Objetivo: decidir qué parte de lo que usa :torneo forma parte de su contrato público.

La regla tiene dos mitades. La primera: implementation es tuyo, api es de quien te use. Una dependencia declarada con implementation no aparece en lo que ve el consumidor al compilar; con api, sí. Ya lo decidiste en build.gradle.kts (Module :torneo): api para las corrutinas (Flow y StateFlow están en las firmas de :torneo) e implementation para la serialización (un detalle interno). Se ve en los metadatos que acabas de publicar. Abre el .pom de la variante de la JVM (cat escribe en pantalla el contenido de un fichero):

cat ~/.m2/repository/com/sebastiannarvaez/marcador/torneo-jvm/1.0.0/torneo-jvm-1.0.0.pom
  <dependencies>
    <dependency>
      <groupId>org.jetbrains.kotlinx</groupId>
      <artifactId>kotlinx-coroutines-core-jvm</artifactId>
      <version>1.11.0</version>
      <scope>compile</scope>
    </dependency>
    …
    <dependency>
      <groupId>org.jetbrains.kotlinx</groupId>
      <artifactId>kotlinx-serialization-json-jvm</artifactId>
      <version>1.11.0</version>
      <scope>runtime</scope>
    </dependency>
  </dependencies>

Las corrutinas tienen scope compile: el consumidor las ve al compilar. La serialización, runtime: solo viaja para ejecutar. Lo que escribiste en build.gradle.kts es lo que decide qué le prometes al que te use.

En Kotlin/Native hay una consecuencia extra, y es la segunda mitad: Swift solo ve lo que el framework contiene.

:torneo devuelve Flow en varias funciones (PartidoEnVivo.eventos(), por ejemplo). Comprueba qué ve Swift. Genera el framework del simulador y busca Flow con grep en su cabecera, el fichero .h que describe la API en Objective-C:

./gradlew :torneo:linkDebugFrameworkIosSimulatorArm64
grep -n '(id<.*Flow>)eventos' torneo/build/bin/iosSimulatorArm64/debugFramework/Torneo.framework/Headers/Torneo.h
- (id<TorneoKotlinx_coroutines_coreFlow>)eventos __attribute__((swift_name("eventos()")));

Swift ve Kotlinx_coroutines_coreFlow: el tipo existe, pero con un nombre mangled (una mezcla del módulo y del nombre) y sin su API. Para que lo vea como Flow, se exporta la librería, en el bloque framework { … }:

build.gradle.kts (Module :torneo): exportar las corrutinas
        ios.binaries.framework {
            baseName = "Torneo"
            // Solo se puede exportar lo que es `api`: por eso las corrutinas lo eran.
            export(libs.kotlinx.coroutines.core)
            xcf.add(this)
            isStatic = true
        }

Vuelve a generar el framework y a buscar: la cabecera ahora dice (id<TorneoFlow>)eventos, el tipo se llama Flow, y la cabecera ha pasado de unas 4 800 líneas a más de 16 000: es el precio de la cita de arriba, que entra la API entera de las corrutinas. Para :torneo no compensa mientras ningún Swift tenga que usar Flow. Quita la línea del export y deja la cabecera pequeña.

La documentación de JetBrains lo resume así: por defecto la exportación no es transitiva (si exportas foo, que depende de bar, solo la API de foo entra en el framework) y no recomienda transitiveExport, porque añade al framework todas las dependencias de lo exportado y eso aumenta el tiempo de compilación y el tamaño del binario.

Hay una advertencia que conviene memorizar. transitiveExport = true es tentador porque arregla de golpe un «unknown type» en Swift. Y arrastra al framework el árbol entero de dependencias de lo exportado, con la eliminación de código muerto desactivada en todo ese árbol. La alternativa correcta es aburrida: exportar explícitamente las tres o cuatro cosas que Swift toca de verdad.

Paso · Compatibilidad binaria: el problema que no tenías

Objetivo: que un cambio inofensivo en el código no rompa a quien ya te usa sin que te enteres.

Mientras todo está en un proyecto, cambiar la firma de una función es un cambio y un build. Publicada, el consumidor puede tener compilado contra tu versión anterior y cargar la nueva sin recompilar.

Hay dos herramientas. La más antigua es el plugin binary-compatibility-validator, pero su propio README avisa de algo que cambia la decisión:

(Su propio README, en github.com/Kotlin/binary-compatibility-validator, lo dice: está en modo de mantenimiento, solo recibe correcciones críticas y vuelca la API binaria de la parte JVM de una librería; las funciones nuevas se añaden a la validación experimental del plugin de Kotlin.)

La otra es la que trae el propio plugin de Kotlin, todavía experimental, y es la que vas a usar. En build.gradle.kts (Module :torneo), dentro de kotlin { … }, justo debajo de jvmToolchain(17):

build.gradle.kts (Module :torneo): validación de ABI
    // Un permiso explícito: esta validación es experimental y puede cambiar.
    @OptIn(org.jetbrains.kotlin.gradle.dsl.abi.ExperimentalAbiValidation::class)
    // Activa la validación: Gradle crea las tareas `checkKotlinAbi` y `updateKotlinAbi`.
    abiValidation()

(Sin el @OptIn, el build falla con The ABI Validation DSL is experimental and may be changed or removed in any future release.) Pulsa Sync Now y comprueba:

./gradlew :torneo:checkKotlinAbi

Falla, y es lo esperado: todavía no hay nada con lo que comparar.

> ABI check failed for project torneo
  Expected file with ABI declarations 'torneo/api/torneo.klib.api' does not exist.

Acepta la API actual como referencia. Se escribe en la carpeta torneo/api (un fichero por plataforma) y a partir de ahí checkKotlinAbi pasa:

./gradlew :torneo:updateKotlinAbi
./gradlew :torneo:checkKotlinAbi

Ahora, el cambio que parece inofensivo. Abre Puntos.kt (es un fichero de commonMain, en torneo/src/commonMain/kotlin/com/sebastiannarvaez/marcador/torneo/; en Android Studio, Navigate → File… te deja escribir su nombre) y añade a puntosPor un tercer parámetro con valor por defecto:

Puntos.kt: un cambio que parece inocente
// Antes: fun puntosPor(golesAFavor: Int, golesEnContra: Int): Int =
fun puntosPor(golesAFavor: Int, golesEnContra: Int, victoria: Int = 3): Int =
    ReglamentoLiga.puntosPor(golesAFavor, golesEnContra)

Quien llama con dos argumentos sigue compilando. Pero ejecuta ./gradlew :torneo:checkKotlinAbi:

<<<ABI has changed>>>
-final fun com.sebastiannarvaez.marcador.torneo/puntosPor(kotlin/Int, kotlin/Int): kotlin/Int
+final fun com.sebastiannarvaez.marcador.torneo/puntosPor(kotlin/Int, kotlin/Int, kotlin/Int = ...): kotlin/Int

La firma binaria antigua, puntosPor(Int, Int), ha desaparecido: un consumidor que compilara contra la 1.0.0 y cargara tu 1.1.0 sin recompilar fallaría al buscarla. En código fuente es compatible, en binario no. La validación lo ha visto en segundos. Deshaz el cambio (quita el parámetro) y comprueba que checkKotlinAbi vuelve a pasar. Si el cambio hubiera sido a propósito, el procedimiento es subir el número mayor de la versión y ejecutar updateKotlinAbi para aceptar la nueva referencia.

Y si fuera de verdad: un repositorio remoto y Maven Central

Hasta aquí has publicado en tu carpeta. Publicar fuera cambia dónde y añade requisitos, no la forma de trabajar. Para un repositorio de tu empresa, el plugin se configura con una URL y unas credenciales. Esto es solo un ejemplo (la dirección no existe), y se escribe en el mismo build.gradle.kts (Module :torneo):

ejemplo: publicar en un repositorio remoto (no lo escribas)
publishing {
    repositories {
        maven {
            // El nombre decide las dos propiedades de las credenciales: equipoUsername y equipoPassword.
            name = "equipo"
            url = uri("https://maven.ejemplo.com/releases")
            // Usuario y contraseña NO van en el fichero: se leen de las propiedades de Gradle.
            credentials(PasswordCredentials::class)
        }
    }
}

Con eso Gradle crea una tarea por destino y repositorio (publishAllPublicationsToEquipoRepository las lanza todas). Si la ejecutas sin credenciales, dice exactamente qué falta:

Credentials required for this build could not be resolved.
> The following Gradle properties are missing for 'equipo' credentials:
    - equipoUsername
    - equipoPassword

Las propiedades van en gradle.properties (Global Properties) (el de tu carpeta personal, no el del proyecto), para que el usuario y la contraseña no acaben en un fichero que compartes.

Maven Central, el repositorio público de librerías, además exige que lo publicado cumpla unos requisitos que ningún build local comprueba:

Los requisitos que fija Sonatype, el operador de Maven Central, incluyen que todos los ficheros desplegados vayan firmados con GPG/PGP (un .asc con la firma por cada uno) y que cada <artifactId>-<version>.jar venga con su -sources.jar y su -javadoc.jar.

Además, pide metadatos en el .pom (nombre, descripción, URL, licencia, desarrolladores y dónde está el código) y que el grupo (com.sebastiannarvaez.marcador) pertenezca a quien publica, que se demuestra verificando el dominio. Kotlin tiene una guía de publicación en Maven Central paso a paso. Y una restricción que ahorra disgustos. Para evitar duplicados, publica todos los artefactos desde un único ordenador: Maven Central prohíbe las publicaciones duplicadas y hace fallar el proceso si se crean.

Cuándo NO merece la pena

Este es el aviso honesto, y es el motivo por el que la lección empieza diciendo que :torneo no necesita publicarse.

Publicar una librería KMP multiplica el coste de mantenimiento por el número de destinos, y no de forma lineal: cada destino añade su propio artefacto, su matriz de pruebas, su posible fallo de compilación cruzada y su superficie de ABI que vigilar. :torneo tiene jvm, android, iosArm64 e iosSimulatorArm64: publicaría cuatro artefactos más la raíz, y cada versión serían cinco cosas que pueden salir mal en cinco sitios.

No lo hagas si:

  • Los consumidores son tus propios módulos. project(":torneo") y una fase de script te dan lo mismo con cero ceremonia y sin desincronización posible.
  • Tienes un único consumidor de iOS y está en tu organización. Un proyecto con los dos, o un módulo compartido por los dos, resuelve el caso sin inventar versiones.
  • La API todavía se mueve. Publicar congela decisiones. Si aún no sabes si puntosPor debería recibir el reglamento entero o solo los puntos de la victoria, publicarla te obliga a arrastrar esa duda durante toda la vida de la 1.x.
  • No vas a dar soporte. Una librería publicada sin nadie que responda es peor que no publicarla: alguien la adopta, se queda atascado en un Kotlin viejo y la culpa acaba siendo tuya.

Hazlo cuando haya varios consumidores que no controlas y una API que ya se ha estabilizado con uso real. Ese es el único momento en que el catering compensa.

Comprueba tu proyecto

  • build.gradle.kts (Module :torneo) tiene id("maven-publish"), group, version, el XCFramework("Torneo") (con su import arriba), abiValidation() y ningún export(...).
  • ./gradlew :torneo:publishToMavenLocal termina en verde, y ls ~/.m2/repository/com/sebastiannarvaez/marcador muestra las cinco carpetas.
  • ./gradlew :torneo:assembleTorneoReleaseXCFramework termina en verde y deja Torneo.xcframework.
  • ./gradlew :torneo:checkKotlinAbi termina en verde, y la carpeta torneo/api tiene los volcados de la API.
  • :app vuelve a depender de project(":torneo") y settings.gradle.kts no tiene mavenLocal(): ./gradlew :app:assembleGratisDebug sigue en verde.

Cuando hayas terminado y quieras limpiar lo publicado, rm -r ~/.m2/repository/com/sebastiannarvaez borra esa carpeta y todo lo que contiene del repositorio local. Hazlo al final: los pasos anteriores leen de ahí, y si lo haces antes tendrás que volver a ejecutar ./gradlew :torneo:publishToMavenLocal.

Reto · Un equipo de iOS quiere tu núcleo, y ni siquiera tienen Android Studio

Otro equipo de tu empresa está haciendo una app de iOS que necesita la lógica de torneos de Marcador: equipos, partidos y tabla de posiciones, sin las corrutinas ni el guardado. Trabajan solo en Swift, en su propio proyecto, y no van a instalar Gradle.

Diseña la entrega: qué artefacto produces, con qué tarea, cómo se lo das, qué exportas y qué pones en marcha para no romperles la compilación dentro de seis meses.

La solución amplía un poco lo practicado: menciona un ZIP, un binaryTarget y un checksum, que no has escrito en esta lección. Para el reto basta con razonar el artefacto, el canal, qué exportas y cómo no romper a nadie; esas tres piezas solo se nombran, no hay que dominarlas.

Ver solución

El artefacto: un XCFramework, no un framework. Necesitan compilar en el simulador de sus portátiles y en dispositivo real, y eso son arquitecturas distintas. Un XCFramework las mete todas en un mismo bundle y Xcode elige. Se declara con XCFramework("Torneo") en los destinos de iOS y se construye con ./gradlew :torneo:assembleTorneoReleaseXCFramework.

El canal: Swift Package Manager. Viene con Xcode, no exige Ruby ni CocoaPods, y encaja con un equipo que solo escribe Swift. En la práctica: comprimes el .xcframework en un ZIP, lo publicas donde tengas acceso directo y pones el Package.swift con un binaryTarget (la entrada que apunta a un binario ya compilado) que usa esa URL y un checksum (una huella del ZIP, para que Xcode compruebe que no ha cambiado). JetBrains recomienda que el Package.swift viva en un repositorio propio, separado del código Kotlin, para poder versionar el manifiesto sin pelearse con las etiquetas del proyecto original. Y hay un motivo extra para no elegir CocoaPods: su integración no se puede combinar con embedAndSignAppleFrameworkForXcode, la tarea de la integración directa que usa una app de iOS que vive en el mismo proyecto que el código compartido (la de Marcador no la usa: tiene su propio paquete en Swift). Si ese equipo adoptara alguna vez la integración directa, CocoaPods le estorbaría.

Qué exportas. Solo lo que Swift toca de verdad: Torneo, Equipo, Partido y la tabla. Y como no necesitan Flow, no exportas las corrutinas (ya viste lo que cuesta: la cabecera se triplica). Nada de transitiveExport = true. Y una decisión que no es obvia: hoy :torneo lo expone todo (el Ejemplo, las demos de Cronometro, los guardados…). Para este consumidor merece la pena separar un módulo pequeño con solo el dominio y publicar ese: el artefacto más fácil de consumir es el que menos expone, y lo que hagas público ya no lo puedes esconder sin romper a alguien.

No romperles dentro de seis meses. Dos cosas. Una, versionado semántico de verdad: cualquier cambio en las firmas exportadas es una versión mayor. Dos, la validación de ABI en cada cambio: checkKotlinAbi te dice si una firma binaria ha desaparecido, como has visto con puntosPor. Es la herramienta del plugin de Kotlin, que cubre también el volcado de iOS; binary-compatibility-validator está en modo de mantenimiento y cubre sobre todo la parte JVM, que para un consumidor de iOS es la mitad que no le interesa.

Lo que no harías. Publicar en Maven Central. El consumidor es de Swift: no va a resolver coordenadas de Gradle. Los artefactos Maven solo tienen sentido si además hay un consumidor de Kotlin o Android, y hoy no lo hay.

Comprueba lo que entendiste

6 preguntas · necesitas 5 para completar la lección

  1. 1.Publicas :torneo con los destinos jvm, android, iosArm64 e iosSimulatorArm64, grupo com.sebastiannarvaez.marcador y versión 1.0.0. ¿Qué carpetas salen en el repositorio local de Maven?

  2. 2.¿Para qué sirve el fichero de metadatos de módulo de Gradle (.module) en una librería KMP?

  3. 3.Un equipo de iOS que solo escribe Swift, en su propio proyecto, quiere consumir tu módulo compartido. ¿Qué les entregas?

  4. 4.En Swift aparece un «unknown type» al usar un tipo de una dependencia de tu módulo. ¿Por qué la documentación de Kotlin desaconseja arreglarlo con transitiveExport = true?

  5. 5.¿Cuál de estas situaciones NO justifica publicar tu módulo compartido?

  6. 6.Añades a puntosPor un tercer parámetro con valor por defecto. Quien la llama con dos argumentos sigue compilando, pero checkKotlinAbi falla. ¿Por qué?

Faltan 6 de 6