Saltar al contenido
K
Entorno y primer proyectoConcepto38 min Básico

Anatomía del proyecto

Al terminar: Leer y navegar el proyecto generado: módulos, source sets, catálogo de versiones y la receta para instalar una librería.

Ya tienes la app corriendo en el emulador y en el simulador. Esta lección casi no añade nada al proyecto: lo abre y lo lee, y al final colocas tres funciones de práctica en su sitio. Al terminar sabrás qué hay en cada carpeta, por qué hay dos módulos de Gradle y un proyecto de Xcode, qué APIs puede usar cada src/ y dónde vive cada número de versión.

No es material de relleno. Casi todos los errores raros de KMP —código que no compila para iOS, tests que no corren, tipos que no llegan a Swift— son en realidad errores de colocación: algo escrito en el source set equivocado. Leer bien el árbol es la mitad del diagnóstico.

El árbol, podado a lo esencial

settings.gradle.ktsqué módulos existen
build.gradle.ktsplugins declarados
gradle.propertiesajustes de Gradle y de Android
gradle/libs.versions.tomltodas las versiones
shared/el código compartido
build.gradle.ktsqué targets existen
src/
commonMain/kotlin/compila para todos los targets
commonTest/kotlin/corre en TODAS las plataformas
androidMain/kotlin/acceso al SDK de Android
iosMain/kotlin/acceso a las APIs de Apple
jvmMain/kotlin/JVM de escritorio
androidApp/la app de Android (Compose)
build.gradle.kts
src/main/AndroidManifest.xml
src/main/kotlin/…/android/MainActivity.kt
iosApp/la app de iOS (SwiftUI + Xcode)
Configuration/Config.xcconfig
iosApp.xcodeproj/esto se abre con Xcode
iosApp/ContentView.swift
Dos módulos de Gradle y un proyecto de Xcode, en rutas del disco. La separación entre shared y las apps no es estilística: AGP 9 la exige.

El árbol es el del disco, y es lo que ves en el panel de Android Studio con la vista Project que activaste en Crear el proyecto Pokédex: las mismas carpetas, en el mismo orden. (En Marcador la vista Android bastaba porque era solo Android; aquí esa vista escondería iosApp y los source sets de shared.) Si algún nombre se ve algo distinto en tu versión, guíate por la ruta del título de cada bloque.

Son tres carpetas, pero Gradle solo conoce dos módulos, y eso está escrito en settings.gradle.kts (en la raíz del proyecto), el plano del edificio que conociste en Crear el proyecto Pokédex. Lo que importa de él son sus últimas líneas:

settings.gradle.kts: lo esencial (con comentarios)
rootProject.name = "Pokedex"                      // el nombre del proyecto
 
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") // permite escribir projects.shared en vez de ":shared"
 
// iosApp no está aquí: es un proyecto de Xcode, no un módulo de Gradle.
include(":shared")                                // el código compartido
include(":androidApp")                            // la app de Android

Los bloques mavenContent { includeGroupAndSubgroups(...) } que hay más arriba en ese fichero le dicen a Gradle que en el repositorio de Google busque solo los grupos de Google. Sin eso, cada librería de terceros se pide primero a Google, que no la tiene, y solo después a Maven Central: más lento. Si tu fichero no tiene la línea enableFeaturePreview(...), no pasa nada: donde esta lección escribe projects.shared, escribe project(":shared"), que es lo mismo (es lo que hacías con :torneo en Marcador).

Y la línea que importa para entender KMP: iosApp no aparece. Gradle no sabe que existe una app de iOS. Lo que Gradle produce es un framework, y quien lo consume es Xcode. Compruébalo en la Terminal de Android Studio, sin abrir nada:

./gradlew projects
Root project 'Pokedex'
+--- Project ':androidApp'
\--- Project ':shared'

(Ese es el trozo que importa de la salida; antes salen unas líneas de cabecera.)

El otro fichero de la raíz, build.gradle.kts (Project: Pokedex), es el jefe de obra: no construye nada, solo deja disponibles los plugins para que cada módulo aplique los suyos.

build.gradle.kts de la raíz: lo esencial (con comentarios)
plugins {
    // apply false = «déjalo disponible, pero no lo apliques aquí»: cada módulo lo aplica con alias(...)
    alias(libs.plugins.kotlinMultiplatform) apply false   // Kotlin Multiplatform: lo aplica :shared
    alias(libs.plugins.androidKmpLibrary) apply false     // Android como librería KMP: lo aplica :shared
    alias(libs.plugins.androidApplication) apply false    // construye la app Android: lo aplica :androidApp
    alias(libs.plugins.composeCompiler) apply false       // el compilador de Compose: lo aplica :androidApp
}

Por qué las apps van en módulos aparte

Podrías pensar que meter la app de Android dentro de shared ahorraría un módulo. Con AGP 9 eso ya no es una opción.

Obligatorio, no recomendado: la propia documentación de KMP lo da como requisito, no como consejo. De ahí salen los dos módulos de Gradle y el proyecto de Xcode: shared aplica com.android.kotlin.multiplatform.library y produce una librería multiplataforma, androidApp aplica com.android.application y produce el .apk, e iosApp no aplica ningún plugin de Gradle porque lo compila Xcode.

Los source sets

Esta es la idea central de KMP y la que más cuesta al principio. Cada carpeta bajo src/ es un source set (la palabra que definió Qué es KMP y qué no), y cada uno tiene un contrato distinto sobre qué APIs puede usar.

commonMainKotlin puro
androidMainSDK de Android
jvmMainJVM
appleMainAPIs de Apple
iosMain
iosArm64iPhone
iosSimulatorArm64simulador
Cada nivel solo puede usar las APIs de su altura. Lo de arriba lo ve todo el mundo.

Las reglas son dos, y con ellas se entiende todo:

  1. Un source set hijo ve el código de sus padres. iosMain puede llamar a lo que hay en commonMain.
  2. Un source set solo puede usar las APIs de su nivel. En commonMain no existe java.io.File (la clase de Java para trabajar con ficheros) ni NSString (el tipo de texto de Apple): si lo escribes ahí, no compila.

Mira la segunda regla en acción. Imagina que escribes, al final de Greeting.kt (que vive en commonMain), una función que usa esa clase de Java. Es solo para leer: si la pruebas, borra la línea antes de seguir, o el ejercicio y el punto de control de esta lección fallarían.

ejemplo: una línea que commonMain no admite (con comentarios)
// java.io.File es de la JVM. Esta línea, escrita en commonMain...
fun rutaDeCache(): String = java.io.File("cache").absolutePath

y compilas el código común dos veces, una por target: primero el de la JVM y luego el del simulador de iOS.

./gradlew :shared:compileKotlinJvm
./gradlew :shared:compileKotlinIosSimulatorArm64
> Task :shared:compileKotlinJvm
 
BUILD SUCCESSFUL in 2s
> Task :shared:compileKotlinIosSimulatorArm64 FAILED
e: file:///…/shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/Greeting.kt:13:29 Unresolved reference 'java'.

(El 13:29 es línea y columna: en tu fichero será donde la hubieras escrito.) La misma línea, en el mismo fichero, compila para un target y no para otro. Eso es lo que significa commonMain: no es «código que compila en todas partes», es código que se compila una vez por cada target declarado, y basta con que uno no conozca una API para que ese target falle. Dos consecuencias prácticas:

  • El IDE suele avisarte antes: con el proyecto sincronizado, java.io.File aparece subrayado en rojo en commonMain sin compilar nada, porque el análisis usa el denominador común de los targets.
  • Diagnóstico: cuando un compañero te diga «a mí me compila», la pregunta no es qué versión tiene, sino qué target compiló. :shared:compileKotlinJvm en verde no dice nada sobre iOS.

En el árbol de arriba hay dos cajas que no existen como carpeta en el proyecto: appleMain y los destinos finales (iosArm64 e iosSimulatorArm64, el iPhone y el simulador). Las crea el plugin.

Es decir: los intermedios aparecen porque declaraste iosArm64() e iosSimulatorArm64(), no porque los pidieras. appleMain agrupa el código común a todas las plataformas de Apple (iOS, macOS, tvOS, watchOS). Tu proyecto no tiene ningún target de macOS, así que hoy appleMain y iosMain ven lo mismo; si creas la carpeta appleMain/kotlin/ y escribes ahí código que use una API de Apple, compila para iOS sin tocar el build.gradle.kts.

La convención de nombres de fichero

Cuando implementas algo por plataforma, Kotlin tiene una convención oficial que es fácil de incumplir sin que nadie te avise. (Una declaración «top-level» es una función o propiedad escrita fuera de cualquier clase.) La Pokédex la sigue desde el primer fichero:

commonMain/kotlin/…/pokedex/Platform.kt          ← sin sufijo
androidMain/kotlin/…/pokedex/Platform.android.kt  ← sufijo del source set
iosMain/kotlin/…/pokedex/Platform.ios.kt
jvmMain/kotlin/…/pokedex/Platform.jvm.kt

O sea que no es cosmético. Cuando se compila un target (por ejemplo la JVM), commonMain y jvmMain se compilan juntos. Si los dos tuvieran un Util.kt con funciones de nivel superior, los dos generarían una clase fachada llamada UtilKt, y la JVM no admite dos clases con el mismo nombre. Es un error real:

e: …/commonMain/…/Util.kt:1:1 Duplicate JVM class name 'com/sebastiannarvaez/pokedex/UtilKt' generated from: UtilKt, UtilKt

El sufijo (Util.jvm.kt) hace que las fachadas se llamen distinto y evita la colisión antes de que exista.

Y hay una ventaja diaria que se agradece más: cuando tengas cuatro pestañas abiertas en el IDE con el mismo nombre de fichero, el sufijo es lo único que te dice cuál estás editando.

El fichero de build del módulo compartido

shared/build.gradle.kts es la ficha del módulo compartido: aquí se declara qué targets existen. Lo retocaste en El módulo compartido y la prueba de humo, donde también está el recordatorio de cómo se leen sus bloques (son lambdas); este es su esqueleto, para leerlo de un vistazo:

build.gradle.kts de shared: el esqueleto (con comentarios)
plugins {
    alias(libs.plugins.kotlinMultiplatform)   // Kotlin Multiplatform: sabe compilar un mismo código para varios destinos
    alias(libs.plugins.androidKmpLibrary)     // Android como librería de KMP (no como app)
}
 
kotlin {
    jvm()                                     // destino 1: la JVM, solo para tests rápidos
    android { /* namespace, compileSdk, minSdk, withHostTest {} */ }   // destino 2: Android
    listOf(iosArm64(), iosSimulatorArm64())   // destinos 3 y 4: iPhone y simulador
        .forEach { /* el framework que Xcode enlazará */ }
 
    sourceSets {                              // dependencias POR source set (ver abajo «Cómo se instalará una librería en el módulo compartido»)
        commonTest.dependencies { implementation(kotlin("test")) }
    }
}

Tres cosas que merecen atención.

jvm() no hace falta para una app móvil, y de hecho ese target no llega a ninguna tienda. Está porque los tests de la JVM terminan en un segundo y los de iOS tienen que compilar y enlazar un binario nativo. Los números medidos están en Gradle sin dolor.

withHostTest {} (explicado en El módulo compartido y la prueba de humo) habilita que commonTest se ejecute también como test de Android, en tu máquina y no en un dispositivo. Es opt-in (hay que pedirlo): sin esa línea esos tests no existen, y tú crees tener una cobertura en Android que en realidad no tienes.

Los dos targets de iOS son distintos de verdad: iosArm64 es el iPhone físico e iosSimulatorArm64 el simulador en un Mac con Apple Silicon. Falta el tercero que verás en casi todos los tutoriales, iosX64 —el simulador en un Mac Intel—, y falta a propósito: es Tier 3 para Kotlin y, además, la librería de base de datos que usarás más adelante no lo publica. La consecuencia real es que la Pokédex no se puede compilar para el simulador de un Mac con Intel. En un proyecto de aprendizaje eso no duele; en uno con un equipo mezclado, es el tipo de línea que decide qué máquinas puedes usar.

El catálogo de versiones, sección a sección

Ábrelo en gradle/libs.versions.toml; es el mismo que comprobaste en Crear el proyecto Pokédex, con las claves que allí se comprobaron (si algún alias se llama distinto en el tuyo, lee los de aquí con tus nombres). Tiene las tres secciones, y cada cosa va en la suya. Las versiones, que son solo números:

libs.versions.toml: [versions] (con comentarios)
[versions]                    # los NÚMEROS, en camelCase
kotlin = "2.4.20"             # compilador de Kotlin; también la versión de los plugins de Kotlin y de Compose
agp = "9.4.0"                 # Android Gradle Plugin
androidCompileSdk = "37"      # estos tres no son librerías: son valores que leen los build.gradle.kts
androidMinSdk = "24"
androidTargetSdk = "37"
coreKtx = "1.19.0"            # una versión por librería (o familia de librerías)
activityCompose = "1.13.0"
composeBom = "2026.08.00"     # el BOM: fija a la vez la versión de todas las librerías de Compose
lifecycle = "2.11.0"

Las librerías, cada una con sus coordenadas (grupo:nombre) y la versión a la que apuntan:

libs.versions.toml: [libraries] (con comentarios)
[libraries]                   # las LIBRERÍAS: alias en minúsculas con guiones
# module = "grupo:nombre" es otra forma de escribir group + name; version.ref apunta a una versión de arriba
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycle" }
androidx-compose-bom = { module = "androidx.compose:compose-bom", version.ref = "composeBom" }
# Las de Compose no llevan versión: la pone el BOM de arriba
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }

Y los plugins, con su id en lugar de coordenadas:

libs.versions.toml: [plugins] (con comentarios)
[plugins]                     # los PLUGINS: alias en minúsculas con guiones (o camelCase, como aquí)
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
androidKmpLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
androidApplication = { id = "com.android.application", version.ref = "agp" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

Hay una regla de nombres que confunde a todo el mundo la primera vez: el alias se escribe con guiones y se usa con puntos.

Así que androidx-core-ktx del catálogo se escribe libs.androidx.core.ktx en el build.gradle.kts. En los plugins, un alias en camelCase (kotlinMultiplatform) se escribe tal cual: libs.plugins.kotlinMultiplatform.

androidApp/build.gradle.kts es la ficha de la app de Android. Su bloque android { } dice cómo se llama y para qué versiones de Android se construye:

android { } de androidApp (con comentarios)
android {
    namespace = "com.sebastiannarvaez.pokedex.android"   // paquete donde Android genera su código (la clase R)
    compileSdk = libs.versions.androidCompileSdk.get().toInt()   // contra qué API se compila (37)
 
    defaultConfig {
        applicationId = "com.sebastiannarvaez.pokedex"   // la identidad única de la app en el móvil
        minSdk = libs.versions.androidMinSdk.get().toInt()       // Android más antiguo donde se instala (API 24)
        targetSdk = libs.versions.androidTargetSdk.get().toInt() // para el que está comprobada (API 37)
        versionCode = 1                                   // número interno de versión, sube en cada publicación
        versionName = "0.1.0"                             // el que ve el usuario
    }
 
    buildFeatures { compose = true }                     // activa Compose en este módulo
}

Y su bloque dependencies { } pide cada librería por su alias. La correspondencia, línea a línea:

dependencias de androidApp: de dónde sale cada alias (con comentarios)
dependencies {
    implementation(projects.shared)                       // el módulo compartido (lo habilita enableFeaturePreview)
    implementation(libs.androidx.core.ktx)                // [libraries] androidx-core-ktx
    implementation(libs.androidx.lifecycle.runtime.ktx)   // [libraries] androidx-lifecycle-runtime-ktx
    implementation(libs.androidx.activity.compose)        // [libraries] androidx-activity-compose
    implementation(platform(libs.androidx.compose.bom))   // [libraries] androidx-compose-bom: platform(...) importa sus versiones
    implementation(libs.androidx.compose.ui)              // sin versión: la pone el BOM
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
}

Fíjate también en projects.shared: es lo que habilita la línea enableFeaturePreview de settings.gradle.kts. El módulo compartido se referencia como un objeto tipado y no como la cadena ":shared", así que si te equivocas al escribirlo, el IDE te lo subraya al momento. (En Marcador no se activó esa línea y escribías project(":torneo"): son dos formas de decir lo mismo.)

Cómo se instalará una librería en el módulo compartido

Esto es solo lectura: no lo hagas ahora, porque cambiaría el proyecto y los puntos de control de esta lección dejarían de cuadrar. La lección La primera dependencia lo ejecuta de verdad con la librería Kermit (registro de mensajes que funciona igual en Android y en iOS). La receta es la de Los ficheros de Gradle de tu proyecto, con una diferencia: en KMP hay que decir para qué source set es la librería.

  1. La versión, una línea nueva en [versions]: kermit = "2.2.0".
  2. La librería, una línea nueva en [libraries]: kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }.
  3. La dependencia, en el bloque del source set que la usa, dentro de sourceSets { }:
build.gradle.kts de shared: dónde entra cada librería (con comentarios)
sourceSets {
    commonMain.dependencies {      // para el código común: la usan todos los destinos
        api(libs.kermit)           // api o implementation: lo decide La primera dependencia
    }
    androidMain.dependencies {     // solo para Android (por ejemplo, una librería del SDK de Android)
        // implementation(libs.…)
    }
    iosMain.dependencies {         // solo para iOS
        // implementation(libs.…)
    }
    commonTest.dependencies {      // solo para los tests comunes
        implementation(kotlin("test"))
    }
}

Una librería de KMP publica una variante por destino, y Gradle elige sola la que corresponde a cada target: por eso basta con declararla en commonMain. Las de una sola plataforma (core-ktx o activity-compose, por ejemplo) van en androidMain o en el módulo androidApp, no en commonMain: en iOS no existen, y el error llega al compilar iOS (lo verás en Gradle sin dolor). Es el mismo error de colocación de toda la lección.

Reto · Ejercicio: cada función en su sitio

En el módulo shared, dentro de una carpeta ejercicios/colocacion (bajo com/sebastiannarvaez/pokedex, en el source set que corresponda a cada función), escribe tres funciones. Cada una va en su source set, y en un fichero con el nombre que manda la convención de esta lección (sin sufijo en commonMain, con el sufijo del source set en los demás):

  1. Una que reciba un número y devuelva su etiqueta de tres cifras con almohadilla: con el 7 devuelve «#007».
  2. Una que devuelva la ruta absoluta de una carpeta llamada cache, con File("cache").absolutePath (la clase java.io.File de esta lección). Ponla en el source set de la JVM, jvmMain, el único de la lista que no es de un móvil.
  3. Una que devuelva el modelo del dispositivo. Ponla en iosMain, con UIDevice.currentDevice.model (la clase UIDevice, que importas con import platform.UIKit.UIDevice, es la misma que usa Platform.ios.kt).

Para crear cada fichero, la receta de El módulo compartido y la prueba de humo («Cómo se crea un fichero en este curso»): en la vista Project, clic derecho sobre la carpeta shared → New → File y la ruta completa desde src/. Cuando termines, en la Terminal:

./gradlew :shared:compileKotlinJvm :shared:compileKotlinIosSimulatorArm64

Debe terminar en:

BUILD SUCCESSFUL

Si pones la tercera en commonMain, la JVM se queja con Unresolved reference 'platform'; si pones la segunda, es iOS quien dice Unresolved reference 'java'. La carpeta ejercicios es práctica tuya: se queda en tu proyecto y compila siempre con él, pero no forma parte del proyecto final del módulo: las lecciones siguientes no la usan.

Ver solución

Tres ficheros, uno por source set:

shared/src/commonMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/colocacion/Etiquetas.kt
package com.sebastiannarvaez.pokedex.ejercicios.colocacion
 
// Kotlin puro: no usa nada de ninguna plataforma, así que vale para todas.
fun etiquetaDeNumero(numero: Int): String = "#" + numero.toString().padStart(3, '0')
shared/src/jvmMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/colocacion/Cache.jvm.kt
package com.sebastiannarvaez.pokedex.ejercicios.colocacion
 
import java.io.File
 
// java.io.File solo existe en la JVM: este fichero vive en jvmMain.
fun rutaDeCache(): String = File("cache").absolutePath
shared/src/iosMain/kotlin/com/sebastiannarvaez/pokedex/ejercicios/colocacion/Dispositivo.ios.kt
package com.sebastiannarvaez.pokedex.ejercicios.colocacion
 
import platform.UIKit.UIDevice
 
// UIDevice es de Apple (UIKit): este fichero vive en iosMain.
fun modeloDelDispositivo(): String = UIDevice.currentDevice.model

La primera es Kotlin puro y va en commonMain, sin sufijo. La segunda usa una clase de la JVM y por eso vive en jvmMain, con el sufijo .jvm.kt; la tercera usa una clase de Apple y vive en iosMain, con .ios.kt. Fíjate en una consecuencia: rutaDeCache solo existe en el target de la JVM, y modeloDelDispositivo solo en iOS, así que el código común no puede llamarlas (para eso hace falta expect y actual, como en Platform.kt). Una función de un solo target es justo lo que commonMain no admite.

Punto de control

Esta lección solo añade la carpeta de práctica ejercicios; el resto se comprueba leyendo y ejecutando.

  • ./gradlew projects lista exactamente :androidApp y :shared, y ningún :iosApp.

  • En shared/src/ hay cinco carpetas: commonMain, commonTest, androidMain, iosMain y jvmMain.

  • Los cuatro ficheros de plataforma se llaman Platform.kt, Platform.android.kt, Platform.ios.kt y Platform.jvm.kt.

  • La salida de ./gradlew :shared:dependencies contiene este bloque (antes salen unas líneas de cabecera). allSourceSetsCompileDependenciesMetadata es el nombre de la lista de lo que ven, juntos, todos los source sets, y esa única línea es toda la dependencia del código común hoy:

    allSourceSetsCompileDependenciesMetadata
    \--- org.jetbrains.kotlin:kotlin-stdlib:2.4.20
  • grep -n "libs\." androidApp/build.gradle.kts devuelve alias con puntos. Los de libs.versions.… (androidCompileSdk, androidMinSdk, androidTargetSdk) están en [versions], en camelCase; los libs.plugins.…, en [plugins]; los demás (libs.androidx.core.ktx…) existen con guiones en la sección [libraries] de gradle/libs.versions.toml.

  • ./gradlew :shared:compileKotlinJvm :shared:compileKotlinIosSimulatorArm64 termina en BUILD SUCCESSFUL con tus tres funciones de ejercicios/colocacion, y ./gradlew :shared:jvmTest sigue en verde: la carpeta ejercicios no rompe nada.

Comprueba lo que entendiste

5 preguntas · necesitas 4 para completar la lección

  1. 1.¿Qué contiene commonMain?

  2. 2.Con AGP 9, ¿qué es obligatorio respecto a la app de Android (androidApp)?

  3. 3.Tienes un archivo Platform.kt en commonMain con una declaración expect. ¿Cómo debe llamarse su implementación en iosMain según la convención oficial de Kotlin?

  4. 4.¿Para qué sirve withHostTest {} dentro del bloque android { } del módulo compartido?

  5. 5.En [libraries] del catálogo hay una línea androidx-core-ktx = { … }. ¿Cómo la escribes en un build.gradle.kts?

Faltan 5 de 5