Saltar al contenido
K
Entorno y primer proyectoConcepto30 min de lectura · 8 min de máquina Intermedio

Gradle sin dolor

Al terminar: Entender qué tarea produce el framework de iOS y cómo diagnosticar un build que falla.

Gradle ya lo conoces: el catálogo, implementation, el botón Sync Now. Lo que cambia en KMP es una sola cosa, y lo cambia todo: un mismo build.gradle.kts describe varias plataformas a la vez, y cada decisión que tomas ahí —una librería, una versión, un ajuste— se multiplica por cada plataforma. Esta lección es el mapa de esa multiplicación.

Al terminar sabrás nombrar de memoria la tarea que produce el framework de iOS, ver con un comando qué librería recibe cada plataforma, reconocer un desajuste de versiones por su mensaje y saber cuándo Gradle te enseña un resultado que no ha calculado ahora. Y tendrás un orden de diagnóstico para cuando un build falle con un mensaje que no habla de lo que falla.

Un target declarado es una familia de tareas

Esta es la regla que explica casi todo lo raro de Gradle en KMP: tú no escribes tareas, escribes targets, y el plugin genera las tareas por ti. Compilar, enlazar, ejecutar tests y armar el framework son tareas distintas por cada target. Una tarea (task) es una unidad de trabajo de Gradle con nombre propio, como jvmTest.

Abre la Terminal de Android Studio (la pestaña Terminal, abajo) en la raíz del proyecto y pídele a Gradle su lista de tareas:

./gradlew :shared:tasks --all

Salen más de cien. Estas son las que importan hoy:

Build tasks
-----------
compileKotlinIosSimulatorArm64 - Compiles a klibrary from the 'main' compilation in target 'iosSimulatorArm64'.
linkDebugFrameworkIosSimulatorArm64 - Links a framework 'debugFramework' for a target 'iosSimulatorArm64'.
linkReleaseFrameworkIosArm64 - Links a framework 'releaseFramework' for a target 'iosArm64'.
 
Verification tasks
------------------
iosSimulatorArm64Test - Executes Kotlin/Native unit tests for target iosSimulatorArm64.
jvmTest - Runs the tests of the test test run.
testAndroidHostTest - Run unit tests for the androidMain build.

Las descripciones las escribe el plugin, y cuentan la historia entera: Kotlin/Native llega al framework en dos pasos.

commonMain + iosMaintu código
compileKotlinIosSimulatorArm64produce una .klib
linkDebugFrameworkIosSimulatorArm64produce Shared.framework
Dos pasos, dos tareas: primero la klib del target, luego el framework que Xcode enlaza.

El .klib es el formato intermedio de Kotlin/Native: código compilado que todavía no es un binario de Apple. A Xcode no le dice nada. Quien lo traduce a algo que Xcode entiende es el segundo paso, el de enlazado (link: unir las piezas ya compiladas en un solo binario que otro programa pueda usar).

Los nombres son largos pero no hay que aprendérselos: son link + Debug/Release + Framework + el target en CamelCase (con mayúscula en cada palabra, sin espacios). Por ejemplo, el framework de release para el iPhone físico (iosArm64) es linkReleaseFrameworkIosArm64.

Fíjate en que esa tarea existe porque declaraste el target, no porque tengas un iPhone: el plugin genera la familia de tareas al configurar el proyecto, mucho antes de que se compile nada. Compilar y firmar son cosas distintas, y solo la segunda necesita papeles (una cuenta de desarrollador): puedes compilar hoy el framework del iPhone en tu Mac sin tener iPhone. Y cada target que declares se lleva su compilación, su enlazado y sus tests: añadir uno es gratis al configurar y caro al compilar.

Una línea en commonMain, una dependencia por target

El fichero es shared/build.gradle.kts. Dentro de kotlin { } hay un bloque sourceSets { }, y dentro, un bloque por cada source set: commonMain.dependencies { }, androidMain.dependencies { }… Cada uno es la lista de librerías de ese source set, con las palabras que ya conoces (implementation, api). No hay testImplementation: los tests comunes tienen su propio bloque, commonTest.dependencies.

Hoy tu módulo solo tiene el de los tests comunes:

build.gradle.kts (Module :shared): el bloque de dependencias hoy (con comentarios)
sourceSets {
    commonTest.dependencies {            // librerías de los tests que corren en TODAS las plataformas
        implementation(kotlin("test"))   // kotlin-test: el atajo del plugin de Kotlin, sin número de versión
    }
}

Una línea tuya, una edición distinta por destino. Compruébalo con lo único que ya declaras. Cada target tiene su lista de «librerías con las que compila» (en la JVM se llama classpath; en iOS, klibraries, las .klib de arriba) y dependencies --configuration imprime una:

./gradlew :shared:dependencies --configuration jvmTestCompileClasspath
./gradlew :shared:dependencies --configuration iosSimulatorArm64TestCompileKlibraries

La primera línea de cada resultado dice de qué lista se trata. Esto sale, recortado a lo que importa:

jvmTestCompileClasspath - Compile classpath for 'jvm/test'.
+--- org.jetbrains.kotlin:kotlin-stdlib:2.4.20
+--- org.jetbrains.kotlin:kotlin-test -> 2.4.20
|    +--- org.jetbrains.kotlin:kotlin-stdlib:2.4.20 (*)
|    \--- org.jetbrains.kotlin:kotlin-test-junit:2.4.20
|         +--- org.jetbrains.kotlin:kotlin-test:2.4.20 (*)
|         \--- junit:junit:4.13.2
 
iosSimulatorArm64TestCompileKlibraries - Compile classpath for 'iosSimulatorArm64/test'.
+--- org.jetbrains.kotlin:kotlin-stdlib:2.4.20
+--- org.jetbrains.kotlin:kotlin-test -> 2.4.20
\--- org.jetbrains.kotlin:kotlin-test:2.4.20 (c)

La misma línea kotlin("test") trae JUnit en la JVM (la librería de tests de la JVM; kotlin-test-junit es el puente) y nada de eso en iOS, donde no existe. Gradle ha elegido, para cada target, la variante que le corresponde. La flecha -> significa «Gradle ha elegido esta versión»: aquí la del plugin de Kotlin, porque kotlin("test") no lleva número; (*) marca un trozo que ya se mostró más arriba, y (c) una restricción: esa línea no añade una librería nueva, solo fija qué versión de kotlin-test se usa si alguien la pide.

De ahí sale la regla de colocación: en commonMain solo caben librerías que publiquen una variante para cada uno de tus targets. Las de una sola plataforma (core-ktx, activity-compose…) van en androidMain o en el módulo androidApp, y si las pones en commonMain el error llega al compilar iOS (más abajo, en los síntomas). La receta completa de instalar una librería en un source set está en Anatomía del proyecto, y se ejecuta de verdad en La primera dependencia.

api e implementation: ahora hay dos destinatarios

implementation deja una librería de uso interno del módulo, y api la hace parte de lo que el módulo ofrece a los demás: eso ya lo sabes. En KMP «los demás» son dos cosas distintas: otros módulos de Gradle (aquí, androidApp) y el framework que lee Swift. Para que los tipos de una librería lleguen a Swift con su nombre limpio hay que exportarla, con una línea más dentro del bloque del framework:

binaries.framework { }: la línea de exportar (fragmento)
export(libs.kermit)   // los tipos de esa librería llegan a Swift con su nombre; solo se admite si es `api`

Qué decide cada palabra, y el experimento que lo demuestra, está en La primera dependencia, donde instalas la primera librería de verdad. Y El ViewModel compartido exporta el ViewModel a Swift con export de verdad.

El framework de iOS: dos líneas que deciden

El bloque de targets de iOS dentro de kotlin { } ya está en tu fichero (solo para leer, no lo pegues otra vez):

shared/build.gradle.kts
    // Sin iosX64: el simulador de los Mac con Intel se queda fuera. Es una
    // decision, no un descuido, y se explica en la leccion de persistencia.
    listOf(iosArm64(), iosSimulatorArm64()).forEach { iosTarget ->
        iosTarget.binaries.framework {
            baseName = "Shared"
            // Estatico: Xcode solo tiene que enlazarlo. Un framework dinamico
            // habria que incrustarlo y firmarlo en cada compilacion.
            isStatic = true
        }
    }

(El motivo de que falte iosX64 lo leíste en Anatomía del proyecto; el comentario del fichero lo promete para otra lección, pero ya lo tienes.)

baseName = "Shared" es lo que hace que en Swift se escriba import Shared. Cambia esa cadena y tienes que cambiar el import de todos los ficheros Swift, así que se elige una vez y no se toca.

Un framework dinámico viaja como un fichero aparte que Xcode tiene que copiar dentro de la app y firmar; uno estático se funde con el binario de la app al enlazar, y no hay nada que copiar ni firmar. Para una app que consume su propio código compartido, el estático es más simple. Tiene una factura: cada target de Xcode que lo enlace se lleva su propia copia del código.

Lo que cuesta cada cosa, medido

Aquí está el motivo entero de que el proyecto declare un target jvm() que no llega a ninguna tienda. Los dos comandos ejecutan el mismo test de commonTest, forzando que se rehaga todo el trabajo (--rerun-tasks, que se explica en la sección de cachés):

./gradlew :shared:jvmTest --rerun-tasks
./gradlew :shared:iosSimulatorArm64Test --rerun-tasks
BUILD SUCCESSFUL in 1s
5 actionable tasks: 5 executed
 
BUILD SUCCESSFUL in 3s
12 actionable tasks: 12 executed

Un segundo contra tres, y cinco tareas contra doce. La diferencia no es el test: es que la versión de iOS tiene que compilar una klib y enlazar un binario nativo antes de poder ejecutar nada. Los tuyos no serán exactamente estos, pero el orden de magnitud sí.

El reparto práctico que sigue el curso: la JVM en cada guardado, las tres plataformas antes de dar algo por terminado.

./gradlew :shared:jvmTest :shared:testAndroidHostTest :shared:iosSimulatorArm64Test

Versiones que tienen que ir alineadas

En Android ya tenías un número que subir con cuidado. En KMP son tres que se miran entre sí, y cada uno vive en un sitio:

  • Kotlin, en [versions] del catálogo (kotlin). Lo usan dos plugins a la vez: el de Kotlin Multiplatform y el compilador de Compose, los dos con version.ref = "kotlin". Por eso se cambia en una línea y se mueven juntos: si cada plugin tuviera su número, tarde o temprano discreparían.
  • AGP, el plugin de Gradle para Android, en [versions] (agp).
  • Gradle, en gradle/wrapper/gradle-wrapper.properties (distributionUrl): se abre en la vista Project, dentro de gradle/wrapper/. AGP exige una versión mínima de Gradle.

Cuando un par no encaja, el mensaje suele decir exactamente qué. Con AGP 9.4.0 y un wrapper atrasado a Gradle 9.3.1, la sincronización falla así:

> Failed to apply plugin 'com.android.internal.version-check'.
   > Minimum supported Gradle version is 9.6.0. Current version is 9.3.1.
     Try updating the 'distributionUrl' property in …/gradle-wrapper.properties to 'gradle-9.6.0-bin.zip'.

El caso más traicionero es el contrario: una librería publicada con un Kotlin más nuevo que el tuyo. Gradle siempre se queda con la versión más alta que alguien pida, pero eso arregla la librería estándar (lo que Kotlin trae de serie: String, List…), no el formato de los .klib. Cada .klib lleva apuntada la «versión de ABI» (la versión de su formato binario), y un compilador viejo no sabe leer una nueva. Con una librería compilada con Kotlin 2.3 y un proyecto en Kotlin 2.2, el compilador de iOS dice:

w: KLIB resolver: Skipping '…/kermit-iosSimulatorArm64Main-2.2.0.klib' having incompatible ABI version '2.3.0'. The library was produced by '2.3.21' compiler.
The current Kotlin compiler can consume libraries having ABI version <= '2.2.0'.
Please upgrade your Kotlin compiler version to consume this library.
e: KLIB resolver: Could not find "…/kermit-iosSimulatorArm64Main-2.2.0.klib" in […]

(Es la librería que instalarás en La primera dependencia; el ejemplo es el de un proyecto con un Kotlin más viejo que ella.) Fíjate en la forma: el aviso w: dice la causa y el error e: solo que no encuentra el fichero. Si lees solo la última línea te pierdes la explicación. La salida es subir kotlin en el catálogo, o bajar la librería a una versión compilada con tu Kotlin.

Qué guarda Gradle, y cuándo no fiarse

Gradle es rápido porque no repite trabajo. Cada tarea termina en la salida con una de estas marcas, que conviene saber leer:

  • UP-TO-DATE: nada de lo que mira la tarea ha cambiado desde la última vez; no se ejecuta.
  • FROM-CACHE: la tarea no se ejecutó ahora, pero alguien (tú, otro proyecto) ya la había ejecutado con exactamente esas entradas y Gradle tomó el resultado guardado. Es la caché de compilación (build cache).
  • Sin marca: se ejecutó de verdad.

Y al principio de la salida, Configuration cache entry reused quiere decir que Gradle ni siquiera ha vuelto a leer tus build.gradle.kts: reutiliza lo que calculó la vez anterior (la caché de configuración).

Dos ajustes de gradle.properties las activan. Ábrelo en la raíz del proyecto (vista Project), gradle.properties: es un fichero de pares clave=valor con los ajustes de Gradle para este proyecto (ya tocaste org.gradle.jvmargs en Gradle: ficheros, catálogo y dependencias). Esta guía trabaja con estas dos líneas puestas. Como mucho, añades esas dos líneas a gradle.properties y pulsas Sync Now; nada más cambia en el proyecto:

gradle.properties: las dos líneas de caché (con comentarios)
# Caché de compilación: reutiliza resultados de tareas ya ejecutadas con las mismas entradas
org.gradle.caching=true
# Caché de configuración: no vuelve a leer los build.gradle.kts si nada ha cambiado
org.gradle.configuration-cache=true

En este fichero los comentarios (#) van en su propia línea, nunca detrás del valor: en un .properties lo que escribas después del = forma parte del valor, y un true # caché… no es true. Gradle no avisa: las dos cachés quedarían apagadas sin ningún mensaje.

Míralo funcionar. Ejecuta dos veces seguidas la misma tarea:

./gradlew :shared:jvmTest
./gradlew :shared:jvmTest

La segunda termina en una fracción de segundo y sus tareas dicen UP-TO-DATE, incluida jvmTest: no ha vuelto a ejecutar tus tests. Para un resultado verde que puedes dar por bueno es lo correcto; para una duda («¿de verdad pasan?») es una trampa, y en KMP pesa más porque una tarea de iOS es la que más tarda y la que más ganas da de dejar en caché.

La caché ahorra trabajo, pero cuando un resultado te huele raro no hay que fiarse de ella. Dos formas de desconfiar, de menos a más:

./gradlew :shared:jvmTest --rerun          # solo esa tarea se ejecuta de nuevo
./gradlew :shared:jvmTest --rerun-tasks    # todas las tareas del camino, ignorando también la caché

Hay otra opción (una «bandera»: algo que añades al final del comando), --no-build-cache, que sirve para otra duda: impide tomar resultados guardados (FROM-CACHE), pero no obliga a ejecutar nada. Si la tarea está UP-TO-DATE, sigue UP-TO-DATE. Úsala cuando lo que te huele raro es un FROM-CACHE.

Y hay otra memoria que no es de Gradle: Kotlin/Native guarda en ~/.konan (tu carpeta personal) su propio compilador y las herramientas de Apple que necesita. Tu primer test de iOS, en El módulo compartido y la prueba de humo, la descargó (unos 300 MB que ocupan casi 2 GB descomprimidos), y por eso tardó tanto; Ejecutar en iOS cuenta qué hacer si alguna vez se vuelve a descargar. No se borra a mano: si tiene un problema, Gradle te lo dice.

Cuando algo falla

Los mensajes de error de un build KMP tienen una manía: hablan de tareas cuando el problema es de targets, y hablan de símbolos o de variantes cuando el problema es de configuración. Este orden de diagnóstico ahorra tiempo.

  1. ¿Falla Gradle o falla Xcode? Corre la tarea de Gradle a mano: ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64. Si termina en verde, el problema no está en tu Kotlin, está en el proyecto de Xcode.
  2. Lee la primera línea que empieza por e: o el bloque What went wrong:. Casi siempre dice el fichero y la causa. Estas son las familias que verás:
    • Cannot locate tasks that match ':shared:iosX64Test' as task 'iosX64Test' not found → el target no está declarado, así que no hay tarea (lo viste en El módulo compartido y la prueba de humo).
    • Unresolved reference solo al compilar un target → usas una API de plataforma (java.io.File, android.os.Build…) en commonMain. Es el error de colocación de Anatomía del proyecto.
    • No matching variant of … was found con platform.type 'jvm' frente a 'native' → una librería solo de Android o de la JVM en commonMain: no tiene variante para iOS (platform.type es la etiqueta que dice para qué plataforma sirve cada variante). Se pasa a androidMain.
    • Could not find co.touchlab:kermit:2.2.99 → el número de versión no existe (un dígito de más, o una librería sin publicar para ese número). Revisa [versions].
    • Unresolved reference 'kermitt' en un build.gradle.kts → un alias del catálogo mal escrito; recuerda que los guiones se leen como puntos.
    • Minimum supported Gradle version is … o incompatible ABI version → versiones sin alinear, la sección anterior.
    • e: java.lang.OutOfMemoryError: Java heap space al compilar → a Gradle le falta memoria. Sube la cifra de -Xmx en org.gradle.jvmargs de gradle.properties y sincroniza.
  3. ¿Compila todo pero un tipo no aparece en Swift? Ya no es un problema de build: es de exportación. Casi siempre el tipo es internal, o falta export para una librería (que además tiene que ser api).
  4. ¿Cambias Kotlin y la app de iOS no se entera? Xcode está usando un framework viejo. El arreglo (poner la fase de script antes de «Compile Sources») está en Integración con iOS.

Punto de control

Esta lección no cambia ninguna línea de código: se comprueba ejecutando tareas.

  • ./gradlew :shared:tasks --all | grep -i framework lista cinco líneas: embedAndSignAppleFrameworkForXcode y cuatro de enlazado (linkDebugFrameworkIosArm64, linkDebugFrameworkIosSimulatorArm64, linkReleaseFrameworkIosArm64 y linkReleaseFrameworkIosSimulatorArm64: dos targets por dos configuraciones).
  • ./gradlew :shared:linkDebugFrameworkIosSimulatorArm64 termina en BUILD SUCCESSFUL y deja un Shared.framework bajo shared/build/bin/iosSimulatorArm64/debugFramework/.
  • ./gradlew :shared:dependencies --configuration jvmTestCompileClasspath incluye junit:junit:4.13.2, y la misma consulta con iosSimulatorArm64TestCompileKlibraries no lo incluye.
  • gradle.properties tiene org.gradle.caching=true y org.gradle.configuration-cache=true, y una segunda ejecución de ./gradlew :shared:jvmTest marca jvmTest como UP-TO-DATE o FROM-CACHE; con --rerun se ejecuta de nuevo.
  • ./gradlew :shared:jvmTest --rerun-tasks tarda alrededor de un segundo, y :shared:iosSimulatorArm64Test --rerun-tasks varios. Los tuyos no serán exactamente estos, pero el orden de magnitud sí.

Comprueba lo que entendiste

7 preguntas · necesitas 5 para completar la lección

  1. 1.¿Qué produce la tarea linkDebugFrameworkIosSimulatorArm64?

  2. 2.Escribes implementation(kotlin("test")) una sola vez en commonTest.dependencies. Al mirar :shared:dependencies ves junit:junit en la lista de la JVM y no en la de iOS. ¿Qué ha pasado?

  3. 3.¿Por qué el framework de la Pokédex se declara con isStatic = true?

  4. 4.Declaras una librería con implementation en commonMain y, en el bloque del framework de iOS, escribes export(libs.esa.libreria). ¿Qué ocurre al enlazar?

  5. 5.¿Para qué sirve tener el target jvm() en una app que solo es Android e iOS?

  6. 6.Ejecutas ./gradlew :shared:jvmTest por segunda vez sin tocar nada y jvmTest sale marcada UP-TO-DATE. Quieres estar seguro de que los tests se ejecutan de verdad ahora. ¿Qué haces?

  7. 7.La sincronización falla con Minimum supported Gradle version is 9.6.0. Current version is 9.3.1. ¿Dónde se arregla?

Faltan 7 de 7