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
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:
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 AndroidLos 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 projectsRoot 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.
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.
Las reglas son dos, y con ellas se entiende todo:
- Un source set hijo ve el código de sus padres.
iosMainpuede llamar a lo que hay encommonMain. - Un source set solo puede usar las APIs de su nivel. En
commonMainno existejava.io.File(la clase de Java para trabajar con ficheros) niNSString(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.
// java.io.File es de la JVM. Esta línea, escrita en commonMain...
fun rutaDeCache(): String = java.io.File("cache").absolutePathy 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.Fileaparece subrayado en rojo encommonMainsin 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:compileKotlinJvmen 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.ktO 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, UtilKtEl 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:
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:
[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:
[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:
[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.
El módulo de Android lee el catálogo
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 {
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:
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.
- La versión, una línea nueva en
[versions]:kermit = "2.2.0". - La librería, una línea nueva en
[libraries]:kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }. - La dependencia, en el bloque del source set que la usa, dentro de
sourceSets { }:
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):
- Una que reciba un número y devuelva su etiqueta de tres cifras con almohadilla: con el 7 devuelve «#007».
- Una que devuelva la ruta absoluta de una carpeta llamada
cache, conFile("cache").absolutePath(la clasejava.io.Filede esta lección). Ponla en el source set de la JVM,jvmMain, el único de la lista que no es de un móvil. - Una que devuelva el modelo del dispositivo. Ponla en
iosMain, conUIDevice.currentDevice.model(la claseUIDevice, que importas conimport platform.UIKit.UIDevice, es la misma que usaPlatform.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:compileKotlinIosSimulatorArm64Debe terminar en:
BUILD SUCCESSFULSi 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:
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')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").absolutePathpackage com.sebastiannarvaez.pokedex.ejercicios.colocacion
import platform.UIKit.UIDevice
// UIDevice es de Apple (UIKit): este fichero vive en iosMain.
fun modeloDelDispositivo(): String = UIDevice.currentDevice.modelLa 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 projectslista exactamente:androidAppy:shared, y ningún:iosApp. -
En
shared/src/hay cinco carpetas:commonMain,commonTest,androidMain,iosMainyjvmMain. -
Los cuatro ficheros de plataforma se llaman
Platform.kt,Platform.android.kt,Platform.ios.ktyPlatform.jvm.kt. -
La salida de
./gradlew :shared:dependenciescontiene este bloque (antes salen unas líneas de cabecera).allSourceSetsCompileDependenciesMetadataes 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.ktsdevuelve alias con puntos. Los delibs.versions.…(androidCompileSdk,androidMinSdk,androidTargetSdk) están en[versions], en camelCase; loslibs.plugins.…, en[plugins]; los demás (libs.androidx.core.ktx…) existen con guiones en la sección[libraries]degradle/libs.versions.toml. -
./gradlew :shared:compileKotlinJvm :shared:compileKotlinIosSimulatorArm64termina enBUILD SUCCESSFULcon tus tres funciones deejercicios/colocacion, y./gradlew :shared:jvmTestsigue en verde: la carpetaejerciciosno rompe nada.
Comprueba lo que entendiste
5 preguntas · necesitas 4 para completar la lección
1.¿Qué contiene
commonMain?2.Con AGP 9, ¿qué es obligatorio respecto a la app de Android (
androidApp)?3.Tienes un archivo
Platform.ktencommonMaincon una declaraciónexpect. ¿Cómo debe llamarse su implementación eniosMainsegún la convención oficial de Kotlin?4.¿Para qué sirve
withHostTest {}dentro del bloqueandroid { }del módulo compartido?5.En
[libraries]del catálogo hay una líneaandroidx-core-ktx = { … }. ¿Cómo la escribes en unbuild.gradle.kts?