Introducción: ¿Qué es Kotlin y por qué aprenderlo?
🟢 Principiante
~15 min
2
Configuración del entorno de desarrollo
🟢 Principiante
~20 min
3
Fundamentos del lenguaje Kotlin
🟢 Principiante
~45 min
4
Programación orientada a objetos en Kotlin
🟡 Intermedio
~40 min
5
Funciones de orden superior, lambdas y colecciones
🟡 Intermedio
~35 min
6
Coroutines y programación asíncrona
🟡 Intermedio
~40 min
7
Kotlin Multiplatform: Introducción y arquitectura
🟠 Avanzado
~30 min
8
Configurando tu primer proyecto KMP
🟠 Avanzado
~35 min
9
Librerías esenciales del ecosistema KMP 2026
🟠 Avanzado
~30 min
10
Patrones avanzados, testing y CI/CD
🔴 Experto
~40 min
11
Compose Multiplatform — UI compartida en todas las plataformas
🟠 Avanzado
~50 min
12
Ktor Server — Backend completo en Kotlin
🟠 Avanzado
~45 min
13
Arquitectura MVI con KMP
🔴 Experto
~40 min
14
Interoperabilidad con Swift
🔴 Experto
~35 min
15
Optimización, profiling y debugging avanzado
🔴 Experto
~30 min
16
Publicar en Google Play y App Store desde KMP
🔴 Experto
~25 min
17
Animaciones avanzadas en Compose Multiplatform
🔴 Experto
~45 min
18
KMP + Wear OS y tvOS
🔴 Experto
~35 min
19
Kotlin/Wasm — Kotlin en el navegador
🟠 Avanzado
~40 min
20
K2 Compiler y optimizaciones de rendimiento
🔴 Experto
~35 min
21
Seguridad: cifrado, certificate pinning y ofuscación
🔴 Experto
~40 min
22
Monorepo KMP: proyectos grandes en equipos
🔴 Experto
~35 min
💡 Consejo de lectura: Cada sección está diseñada para leerse de forma independiente. Si ya conoces Kotlin, salta directamente a la sección 7. Usa Ctrl+F para navegar por el índice.
FUNDAMENTOS DE KOTLIN
Sección 1 · Introducción: ¿Qué es Kotlin y por qué aprenderlo?
¿Qué es Kotlin?
Kotlin es un lenguaje de programación moderno, conciso y seguro creado por JetBrains (los mismos creadores de IntelliJ IDEA). Fue presentado en 2011, se hizo open-source en 2012 y en 2017 Google lo adoptó como lenguaje oficial para Android. Desde entonces, su popularidad no ha parado de crecer.
A diferencia de Java (que lleva más de 30 años de historia y arrastra mucho código legacy), Kotlin fue diseñado desde cero pensando en:
Seguridad ante nulos → elimina la mayoría de NullPointerException
Concisión → menos código para hacer lo mismo
Interoperabilidad total con Java → puedes usar cualquier librería Java en Kotlin
Multiparadigma → combina programación orientada a objetos (OOP) y programación funcional (FP)
¿Dónde se usa Kotlin en 2026?
Plataforma
Uso
Android
Lenguaje oficial. Toda la documentación oficial de Android usa Kotlin
Backend / Servidor
Con frameworks como Ktor, Spring Boot (con Kotlin DSL) o Quarkus
iOS
A través de Kotlin Multiplatform (KMP)
Web
Kotlin/JS compila a JavaScript
Desktop
Con Compose Multiplatform
Embedded/WASM
Kotlin/Wasm (WebAssembly), experimental pero muy prometedor
¿Por qué aprenderlo en 2026?
✅ Un solo lenguaje para todo: con KMP escribes lógica de negocio una vez y la reutilizas en Android, iOS, Web y Desktop.
✅ Demanda laboral creciente: cada empresa con app Android necesita desarrolladores Kotlin.
✅ Kotlin 2.x: el compilador K2 (actualmente estable) es significativamente más rápido y preciso.
✅ Jetpack Compose y Compose Multiplatform: el toolkit de UI moderno de Google está construido en Kotlin y ya soporta todas las plataformas.
✅ Comunidad activa: JetBrains, Google y la comunidad open-source invierten fuertemente en su ecosistema.
Sección 2 · Configuración del entorno de desarrollo
Herramientas que vamos a instalar
Para seguir este tutorial necesitas:
JDK 17 o superior (recomendamos JDK 21 LTS, que es el estándar actual)
IntelliJ IDEA Community Edition (gratis) o Android Studio (si tu enfoque es Android/KMP)
Kotlin CLI (opcional, para ejecutar scripts desde la terminal)
Gradle 8.x (se instala automáticamente con los proyectos)
Paso 1 — Instalar JDK 21
El JDK (Java Development Kit) es necesario porque Kotlin compila a bytecode de la JVM.
En macOS (con Homebrew):
bash
brew install --cask temurin@21
En Windows:
Descarga el instalador desde adoptium.net → elige Temurin 21 LTS.
En Linux (Debian/Ubuntu):
bash
sudo apt updatesudo apt install temurin-21-jdk
Verifica la instalación:
bash
java -version# Salida esperada: openjdk version "21.x.x" ...
Paso 2 — Instalar IntelliJ IDEA o Android Studio
IntelliJ IDEA Community → ideal para backend, scripts y aprender el lenguaje
Descarga en: jetbrains.com/idea/download (elige Community, es gratis)
Android Studio Koala / Ladybug (2025+) → ideal si quieres hacer Android o KMP
Descarga en: developer.android.com/studio
Ambos IDEs tienen soporte nativo para Kotlin, autocompletado inteligente, refactoring, debugger y mucho más. Si estás empezando, IntelliJ IDEA es más sencillo para los primeros pasos.
Paso 3 — Tu primer proyecto Kotlin
Abre IntelliJ IDEA → New Project
Selecciona Kotlin en la lista izquierda
Elige Console Application (aplicación de consola, perfecta para aprender)
Sistema de build: Gradle - Kotlin DSL (no uses Groovy; el DSL de Kotlin es el estándar actual)
JDK: selecciona el 21 que acabas de instalar
Haz clic en Create
IntelliJ genera automáticamente esta estructura:
mi-proyecto/
├── build.gradle.kts ← Configuración del build (en Kotlin DSL)
├── settings.gradle.kts ← Nombre del proyecto
├── gradle/
│ └── wrapper/ ← Gradle Wrapper (no necesitas instalar Gradle manualmente)
└── src/
└── main/
└── kotlin/
└── Main.kt ← Tu primer archivo Kotlin
El archivo Main.kt generado luce así:
kotlin
fun main() { println("Hello, World!")}
Haz clic en el botón ▶ verde junto a fun main() y verás en la consola:
Hello, World!
🎉 ¡Felicidades! Ya ejecutaste tu primer programa Kotlin.
El archivo build.gradle.kts explicado
kotlin
// build.gradle.ktsplugins { kotlin("jvm") version "2.1.0" // Plugin de Kotlin para JVM application // Plugin para aplicaciones ejecutables}group = "com.miempresa" // Identificador de tu organización (estilo Java)version = "1.0-SNAPSHOT" // Versión de tu proyectorepositories { mavenCentral() // Repositorio donde Gradle descarga las dependencias}dependencies { // Aquí añadirás tus librerías. Por ejemplo: // implementation("io.ktor:ktor-client-core:3.0.0") testImplementation(kotlin("test")) // Librería de testing de Kotlin}application { mainClass.set("MainKt") // Clase principal (nota: Kotlin añade "Kt" al nombre del archivo)}
💡 ¿Por qué MainKt y no Main? Cuando Kotlin compila un archivo llamado Main.kt que contiene funciones de nivel superior (fuera de una clase), genera automáticamente una clase Java llamada MainKt. Esto es transparente para ti en Kotlin, pero importante para la configuración del build.
Sección 3 · Fundamentos del lenguaje Kotlin
Esta sección cubre los bloques de construcción esenciales. Apréndetelos bien porque todo lo demás se construye sobre estos conceptos.
3.1 Variables: val vs var
En Kotlin existen dos palabras clave para declarar variables:
Palabra clave
Significado
¿Se puede reasignar?
val
value (valor)
❌ No (inmutable, como final en Java)
var
variable
✅ Sí (mutable)
kotlin
fun main() { val nombre = "Carlos" // Inmutable: no puedes cambiar su valor var edad = 25 // Mutable: puedes cambiar su valor // nombre = "Pedro" // ❌ Error de compilación: Val cannot be reassigned edad = 26 // ✅ Correcto println("$nombre tiene $edad años") // Salida: Carlos tiene 26 años}
Regla de oro: Usa siempre val por defecto. Solo usa var cuando necesites reasignar el valor. Esto hace tu código más seguro y predecible.
3.2 Tipos de datos básicos
Kotlin infiere el tipo automáticamente, pero también puedes declararlo explícitamente:
kotlin
fun main() { // Números enteros val entero: Int = 42 val enteroLargo: Long = 10_000_000_000L // El guion bajo mejora la legibilidad val enteroCorto: Short = 100 val enteroByte: Byte = 127 // Números decimales val decimal: Double = 3.14159 val decimalSimple: Float = 3.14f // Texto val texto: String = "Hola, Kotlin" val caracter: Char = 'K' // Booleano val esVerdad: Boolean = true // Kotlin infiere el tipo si no lo especificas: val inferido = "Esto es un String" // Kotlin sabe que es String val numero = 42 // Kotlin sabe que es Int println(inferido.length) // Puedes usar métodos de String directamente // Salida: 22}
Nota importante: En Kotlin, los tipos primitivos como Int, Double, etc. se comportan como objetos (tienen métodos), pero el compilador los optimiza a tipos primitivos de la JVM en tiempo de compilación. Lo mejor de ambos mundos.
3.3 Null Safety — La característica más importante de Kotlin
En Java, el NullPointerException es conocido como el "error del billón de dólares" porque ha causado millones de bugs. Kotlin resuelve esto a nivel de sistema de tipos.
En Kotlin, los tipos NO pueden ser nulos por defecto:
kotlin
fun main() { var nombre: String = "Ana" // nombre = null // ❌ Error de compilación: Null can not be a value of a non-null type String // Para permitir nulos, añades "?" al tipo: var nombreNulable: String? = "Ana" nombreNulable = null // ✅ Ahora sí está permitido}
¿Cómo trabajar con valores nulables?
kotlin
fun main() { val nombre: String? = obtenerNombre() // puede ser null // Opción 1: Safe call operator (?.) // Si nombre es null, devuelve null sin lanzar excepción val longitud = nombre?.length println(longitud) // Imprime null si nombre es null, o el número si no lo es // Opción 2: Elvis operator (?:) // Si el lado izquierdo es null, usa el valor de la derecha como fallback val longitudSegura = nombre?.length ?: 0 println(longitudSegura) // Imprime 0 si nombre es null // Opción 3: Smart cast con if if (nombre != null) { // Dentro de este bloque, Kotlin sabe que nombre NO es null println(nombre.length) // No necesitas "?." } // Opción 4: let (para ejecutar un bloque solo si no es null) nombre?.let { valor -> println("El nombre es: $valor") // Solo se ejecuta si nombre != null } // Opción 5: !! (non-null assertion) — ÚSALO CON CUIDADO // Lanza NullPointerException si es null. Evítalo salvo que estés 100% seguro val longitudPeligrosa = nombre!!.length // ⚠️ Puede lanzar excepción}fun obtenerNombre(): String? = null // Simula una función que puede devolver null
🔑 Clave para entender null safety: El sistema de tipos de Kotlin distingue entre String (nunca null) y String? (puede ser null). El compilador te obliga a manejar los posibles nulos antes de acceder al valor. Esto elimina los NullPointerException en tiempo de compilación, no en tiempo de ejecución.
3.4 Strings y Templates
Kotlin tiene un sistema de strings muy poderoso:
kotlin
fun main() { val nombre = "María" val edad = 30 // String template: usa $ para insertar variables println("Hola, $nombre!") // Expresiones dentro de ${...} println("El año que viene tendrás ${edad + 1} años") // Strings multilínea (triple comillas) val json = """ { "nombre": "$nombre", "edad": $edad } """.trimIndent() // trimIndent() elimina la indentación común println(json) // Operaciones comunes con Strings val texto = " Kotlin es genial " println(texto.trim()) // "Kotlin es genial" println(texto.uppercase()) // " KOTLIN ES GENIAL " println(texto.contains("genial")) // true println(texto.replace("genial", "increíble")) println("Kotlin".startsWith("Ko")) // true println("Kotlin".substring(0, 3)) // "Kot"}
3.5 Condicionales: if, when
En Kotlin, if y when son expresiones (devuelven un valor), no solo sentencias:
kotlin
fun main() { val numero = 7 // if como expresión (devuelve un valor) val resultado = if (numero > 5) "grande" else "pequeño" println(resultado) // "grande" // when: el switch moderno de Kotlin (mucho más potente) val dia = 3 val nombreDia = when (dia) { 1 -> "Lunes" 2 -> "Martes" 3 -> "Miércoles" 4 -> "Jueves" 5 -> "Viernes" 6, 7 -> "Fin de semana" // múltiples valores else -> "Día inválido" } println(nombreDia) // "Miércoles" // when con rangos val nota = 85 val calificacion = when (nota) { in 90..100 -> "Sobresaliente" in 70..89 -> "Notable" in 50..69 -> "Aprobado" else -> "Suspenso" } println(calificacion) // "Notable" // when con tipos (muy útil con sealed classes, lo veremos más adelante) val valor: Any = "Hola" when (valor) { is String -> println("Es un String de longitud ${valor.length}") is Int -> println("Es un entero: $valor") is List<*>-> println("Es una lista con ${(valor as List<*>).size} elementos") }}
3.6 Bucles: for, while, repeat
kotlin
fun main() { // for con rangos for (i in 1..5) { print("$i ") // 1 2 3 4 5 } println() // Rango exclusivo (hasta 4, sin incluir 5) for (i in 1 until 5) { print("$i ") // 1 2 3 4 } println() // Con paso (step) for (i in 0..10 step 2) { print("$i ") // 0 2 4 6 8 10 } println() // Hacia atrás for (i in 5 downTo 1) { print("$i ") // 5 4 3 2 1 } println() // Iterar sobre una lista val frutas = listOf("manzana", "pera", "naranja") for (fruta in frutas) { println(fruta) } // Con índice for ((indice, fruta) in frutas.withIndex()) { println("$indice: $fruta") } // while var contador = 0 while (contador < 3) { println("Contador: $contador") contador++ } // repeat (más idiomático para repetir N veces) repeat(3) { iteracion -> println("Iteración $iteracion") }}
3.7 Funciones
Las funciones en Kotlin son ciudadanos de primera clase y tienen muchas características potentes:
kotlin
// Función básicafun saludar(nombre: String): String { return "Hola, $nombre!"}// Función de expresión única (single-expression function)// Cuando el cuerpo es una sola expresión, puedes omitir {} y returnfun saludarCorto(nombre: String) = "Hola, $nombre!"// Parámetros con valores por defectofun crearUsuario( nombre: String, edad: Int = 18, // valor por defecto activo: Boolean = true // valor por defecto): String { return "Usuario: $nombre, Edad: $edad, Activo: $activo"}// Funciones con argumentos nombradosfun main() { println(saludar("Kotlin")) // "Hola, Kotlin!" println(saludarCorto("Kotlin")) // "Hola, Kotlin!" // Con valores por defecto no necesitas pasar todos los parámetros println(crearUsuario("Ana")) // Usuario: Ana, Edad: 18, Activo: true // Argumentos nombrados: puedes cambiar el orden println(crearUsuario(edad = 25, nombre = "Luis", activo = false)) // Usuario: Luis, Edad: 25, Activo: false // Función con número variable de argumentos (vararg) println(sumar(1, 2, 3, 4, 5)) // 15}fun sumar(vararg numeros: Int): Int { return numeros.sum()}
3.8 Tipos de retorno: Unit y Nothing
kotlin
// Unit es como "void" en Java: la función no devuelve nada útil// Puedes omitirlo, es implícitofun imprimirMensaje(mensaje: String): Unit { println(mensaje)}// Equivalente:fun imprimirMensaje2(mensaje: String) { println(mensaje)}// Nothing: indica que la función NUNCA devuelve (lanza excepción o bucle infinito)fun lanzarError(mensaje: String): Nothing { throw IllegalStateException(mensaje)}
PARTE II — PROGRAMACIÓN ORIENTADA A OBJETOS
Sección 4 · Programación Orientada a Objetos en Kotlin
Kotlin es un lenguaje orientado a objetos muy bien diseñado. Verás que es mucho más conciso que Java sin perder claridad.
4.1 Clases y constructores
kotlin
// Clase con constructor primario// Los parámetros del constructor primario pueden ser propiedades directamente con val/varclass Persona( val nombre: String, // propiedad inmutable var edad: Int, // propiedad mutable val email: String = "" // con valor por defecto) { // Propiedad calculada (getter personalizado) val esMayorDeEdad: Boolean get() = edad >= 18 // Bloque init: se ejecuta cuando se crea el objeto init { require(edad >= 0) { "La edad no puede ser negativa" } require(nombre.isNotBlank()) { "El nombre no puede estar vacío" } } // Constructor secundario (menos común, prefiere valores por defecto) constructor(nombre: String) : this(nombre, 0) { println("Constructor secundario llamado") } // Método fun saludar(): String = "Hola, soy $nombre y tengo $edad años" // toString para representación legible override fun toString(): String = "Persona(nombre=$nombre, edad=$edad)"}fun main() { val persona = Persona("Ana", 25, "ana@email.com") println(persona.saludar()) // Hola, soy Ana y tengo 25 años println(persona.esMayorDeEdad) // true println(persona) // Persona(nombre=Ana, edad=25) persona.edad = 26 // Podemos cambiar edad (es var) // persona.nombre = "Otra" // ❌ Error: nombre es val}
4.2 Data Classes — Clases de datos
Las data class son perfectas para representar datos. Kotlin genera automáticamente equals(), hashCode(), toString(), copy() y métodos de desestructuración:
kotlin
data class Usuario( val id: Int, val nombre: String, val email: String)fun main() { val usuario1 = Usuario(1, "Carlos", "carlos@email.com") val usuario2 = Usuario(1, "Carlos", "carlos@email.com") val usuario3 = Usuario(2, "Ana", "ana@email.com") // equals() generado automáticamente (compara por valor, no por referencia) println(usuario1 == usuario2) // true (mismos valores) println(usuario1 == usuario3) // false (diferentes valores) // toString() generado automáticamente println(usuario1) // Usuario(id=1, nombre=Carlos, email=carlos@email.com) // copy() para crear una copia modificando algunos campos val usuarioActualizado = usuario1.copy(email = "nuevo@email.com") println(usuarioActualizado) // Usuario(id=1, nombre=Carlos, email=nuevo@email.com) // Desestructuración val (id, nombre, email) = usuario1 println("ID: $id, Nombre: $nombre")}
💡 ¿Cuándo usar data class? Cuando tu clase existe principalmente para almacenar datos (DTOs, modelos de dominio, estados de UI). No uses data class para clases con lógica de negocio compleja.
4.3 Herencia y clases abstractas
En Kotlin, todas las clases son final por defecto (no se pueden heredar). Para permitir herencia, usa open:
kotlin
// Clase base (debe ser "open" para poder heredarse)open class Animal( val nombre: String, val sonido: String) { // Método "open" puede ser sobreescrito open fun hacerSonido(): String = "$nombre dice: $sonido" // Método "final" (por defecto en Kotlin): no puede sobreescribirse fun respirar() = "$nombre está respirando"}// Clase derivadaclass Perro(nombre: String) : Animal(nombre, "Guau") { // Sobreescribimos el método override fun hacerSonido(): String = "¡${super.hacerSonido()}! ¡${super.hacerSonido()}!" fun buscarPalo() = "$nombre busca el palo"}class Gato(nombre: String) : Animal(nombre, "Miau") { override fun hacerSonido(): String = "${super.hacerSonido()}..."}// Clase abstracta: no puede instanciarse directamenteabstract class Figura { abstract fun area(): Double // Sin implementación, las subclases DEBEN implementarlo abstract fun perimetro(): Double fun descripcion() = "Área: ${area()}, Perímetro: ${perimetro()}"}class Circulo(val radio: Double) : Figura() { override fun area() = Math.PI * radio * radio override fun perimetro() = 2 * Math.PI * radio}class Rectangulo(val ancho: Double, val alto: Double) : Figura() { override fun area() = ancho * alto override fun perimetro() = 2 * (ancho + alto)}fun main() { val perro = Perro("Rex") val gato = Gato("Whiskers") println(perro.hacerSonido()) // ¡Rex dice: Guau! ¡Rex dice: Guau! println(gato.hacerSonido()) // Whiskers dice: Miau... println(perro.buscarPalo()) // Rex busca el palo val circulo = Circulo(5.0) println(circulo.descripcion()) // Área: 78.54, Perímetro: 31.42}
4.4 Interfaces
Las interfaces en Kotlin son más potentes que en Java: pueden tener implementaciones por defecto:
kotlin
interface Volador { val velocidadMaxima: Int // propiedad abstracta fun volar(): String // método abstracto // Método con implementación por defecto fun aterrizar(): String = "Aterrizando suavemente a $velocidadMaxima km/h"}interface Nadador { fun nadar(): String = "Nadando"}// Una clase puede implementar múltiples interfacesclass Pato(override val velocidadMaxima: Int = 80) : Volador, Nadador { override fun volar() = "El pato vuela a $velocidadMaxima km/h" // nadar() y aterrizar() usan las implementaciones por defecto}fun main() { val pato = Pato() println(pato.volar()) // El pato vuela a 80 km/h println(pato.nadar()) // Nadando println(pato.aterrizar()) // Aterrizando suavemente a 80 km/h}
4.5 Sealed Classes — Jerarquías cerradas
Las sealed class son una de las características más potentes de Kotlin. Representan jerarquías de tipos cerradas: el compilador sabe exactamente todas las subclases posibles.
Son perfectas para modelar estados y resultados:
kotlin
// Resultado de una operación de redsealed class ResultadoRed<out T> { data class Exito<T>(val datos: T) : ResultadoRed<T>() data class Error(val mensaje: String, val codigo: Int) : ResultadoRed<Nothing>() object Cargando : ResultadoRed<Nothing>()}// Estado de la UI de loginsealed class EstadoLogin { object Inactivo : EstadoLogin() object Cargando : EstadoLogin() data class Exitoso(val nombreUsuario: String) : EstadoLogin() data class Fallido(val razon: String) : EstadoLogin()}fun procesarResultado(resultado: ResultadoRed<String>) { // when con sealed class: el compilador verifica que cubras TODOS los casos // Si añades una nueva subclase y olvidas el caso, el compilador te avisa when (resultado) { is ResultadoRed.Exito -> println("✅ Datos recibidos: ${resultado.datos}") is ResultadoRed.Error -> println("❌ Error ${resultado.codigo}: ${resultado.mensaje}") is ResultadoRed.Cargando-> println("⏳ Cargando...") }}fun main() { procesarResultado(ResultadoRed.Exito("datos del servidor")) procesarResultado(ResultadoRed.Error("No encontrado", 404)) procesarResultado(ResultadoRed.Cargando)}
🔑 ¿Por qué son tan útiles las sealed classes? Porque cuando las combinas con when, el compilador garantiza exhaustividad. Si añades una nueva subclase y olvidaste manejar ese caso en algún when, recibirás un error de compilación, no un bug en producción.
4.6 Object, Companion Object y Singletons
kotlin
// object: crea un singleton (instancia única)object ConfiguracionApp { const val VERSION = "2.0.0" val baseUrl = "https://api.miapp.com" fun obtenerHeaders() = mapOf( "Content-Type" to "application/json", "App-Version" to VERSION )}class BaseDeDatos private constructor() { companion object { // companion object: similar a métodos estáticos en Java // pero más potente porque puede implementar interfaces private var instancia: BaseDeDatos? = null fun obtenerInstancia(): BaseDeDatos { return instancia ?: BaseDeDatos().also { instancia = it } } const val NOMBRE_BD = "mi_base_de_datos" } fun consultar(query: String) = "Resultado de: $query"}fun main() { // Acceso directo sin instanciar (es un singleton) println(ConfiguracionApp.VERSION) // "2.0.0" println(ConfiguracionApp.baseUrl) // "https://api.miapp.com" // Singleton con companion object val bd1 = BaseDeDatos.obtenerInstancia() val bd2 = BaseDeDatos.obtenerInstancia() println(bd1 === bd2) // true: misma instancia println(BaseDeDatos.NOMBRE_BD) // "mi_base_de_datos"}
4.7 Extension Functions — Ampliar clases sin heredarlas
Las extension functions son una de las características más elegantes de Kotlin. Te permiten añadir métodos a cualquier clase (incluso del SDK de Java) sin modificarla ni heredarla:
kotlin
// Añadimos un método a Stringfun String.capitalizar(): String { return this.split(" ") .joinToString(" ") { palabra -> palabra.replaceFirstChar { it.uppercase() } }}// Añadimos un método a Intfun Int.esPar(): Boolean = this % 2 == 0// Extension function con receptor nulablefun String?.oDefecto(defecto: String = "Desconocido"): String { return this ?: defecto}// Extension sobre una listafun <T> List<T>.segundoONull(): T? = if (this.size >= 2) this[1] else nullfun main() { println("hola mundo kotlin".capitalizar()) // "Hola Mundo Kotlin" println(4.esPar()) // true println(7.esPar()) // false val nombre: String? = null println(nombre.oDefecto()) // "Desconocido" println(nombre.oDefecto("Anónimo")) // "Anónimo" val lista = listOf("a", "b", "c") println(lista.segundoONull()) // "b" println(emptyList<String>().segundoONull()) // null}
💡 Truco importante: Las extension functions son una herramienta potente para escribir DSLs (Domain Specific Languages) en Kotlin. Frameworks como Ktor, Jetpack Compose y Gradle Kotlin DSL usan extensiones de forma masiva para crear APIs muy legibles.
PARTE III — FUNCIONAL Y COLECCIONES
Sección 5 · Funciones de orden superior, Lambdas y Colecciones
Esta sección es donde Kotlin empieza a diferenciarse de muchos lenguajes. Las funciones son ciudadanos de primera clase: se pueden almacenar en variables, pasar como parámetros y devolver desde otras funciones.
5.1 Lambdas y funciones de orden superior
kotlin
fun main() { // Una lambda es una función anónima val saludar = { nombre: String -> "Hola, $nombre!" } println(saludar("Kotlin")) // "Hola, Kotlin!" // Función de orden superior: recibe otra función como parámetro fun operacion(a: Int, b: Int, op: (Int, Int) -> Int): Int { return op(a, b) } val suma = operacion(3, 4) { x, y -> x + y } val producto = operacion(3, 4) { x, y -> x * y } println(suma) // 7 println(producto) // 12 // Cuando la lambda es el último parámetro, va fuera del paréntesis (trailing lambda) // Esta convención se usa MUCHO en Kotlin (Compose, Coroutines, etc.) val resultado = operacion(10, 5) { a, b -> println("Calculando $a - $b") a - b // Último valor = valor de retorno de la lambda } println(resultado) // 5 // "it": nombre implícito cuando la lambda tiene UN solo parámetro val numeros = listOf(1, 2, 3, 4, 5) val dobles = numeros.map { it * 2 } println(dobles) // [2, 4, 6, 8, 10]}
5.2 Colecciones: List, Set, Map
Kotlin distingue entre colecciones inmutables (solo lectura) y mutables:
kotlin
fun main() { // ===== LIST ===== val lista = listOf(1, 2, 3, 4, 5) // Inmutable val listaM = mutableListOf(1, 2, 3) // Mutable listaM.add(4) listaM.removeAt(0) println(listaM) // [2, 3, 4] // ===== SET ===== val set = setOf(1, 2, 3, 2, 1) // Sin duplicados, inmutable println(set) // [1, 2, 3] val setM = mutableSetOf("a", "b") setM.add("c") // ===== MAP ===== val mapa = mapOf( // Inmutable "nombre" to "Ana", "edad" to "25", "ciudad" to "Madrid" ) println(mapa["nombre"]) // "Ana" println(mapa.getOrDefault("pais", "Desconocido")) // "Desconocido" val mapaM = mutableMapOf<String, Int>() mapaM["puntos"] = 100 mapaM["vidas"] = 3 mapaM["puntos"] = mapaM["puntos"]!! + 50 // Actualizar valor println(mapaM) // {puntos=150, vidas=3}}
5.3 Operaciones sobre colecciones
Kotlin tiene una API de colecciones increíblemente rica. Estas son las operaciones más usadas:
kotlin
5.4 Sequences — Colecciones lazy
Cuando tienes cadenas largas de operaciones sobre muchos elementos, las Sequence son más eficientes porque procesan elemento a elemento en lugar de crear colecciones intermedias:
kotlin
fun main() { val numeros = (1..1_000_000).toList() // Con List (eager): crea 3 listas intermedias en memoria val resultadoLista = numeros .filter { it % 2 == 0 } // Crea lista de 500.000 elementos .map { it * 3 } // Crea otra lista de 500.000 elementos .take(5) // Finalmente toma 5 // Con Sequence (lazy): procesa elemento a elemento, para cuando tiene 5 val resultadoSequence = numeros .asSequence() // Convierte a Sequence .filter { it % 2 == 0 } // Lazy: no ejecuta aún .map { it * 3 } // Lazy: no ejecuta aún .take(5) // Lazy: no ejecuta aún .toList() // Terminal: ejecuta todo, se para en el elemento 10 println(resultadoLista) // [6, 12, 18, 24, 30] println(resultadoSequence) // [6, 12, 18, 24, 30] // Mismo resultado, pero Sequence es MUCHO más eficiente con colecciones grandes}
💡 Regla práctica: Usa Sequence cuando tengas más de 1000 elementos o cuando la cadena de operaciones sea larga. Para colecciones pequeñas, las listas normales son más simples y suficientemente eficientes.
PARTE IV — ASINCRONÍA
Sección 6 · Coroutines y Programación Asíncrona
Las coroutines son la propuesta de Kotlin para la programación asíncrona y concurrente. Son ligeras (puedes tener millones de ellas sin problema), estructuradas y mucho más sencillas de entender que los callbacks o los futuros/promesas.
6.1 ¿Qué problema resuelven las coroutines?
Imagina que tienes una app que necesita descargar datos de internet. Sin coroutines, tienes dos opciones malas:
Bloquear el hilo principal → La UI se congela. Muy mala UX.
Usar callbacks → "Callback hell", código difícil de leer y mantener.
Con coroutines:
kotlin
// Código que parece síncrono pero es asíncrono internamentesuspend fun cargarDatos() { val datos = descargarDatos() // Suspende sin bloquear el hilo mostrarEnUI(datos) // Continúa cuando los datos llegaron}
import kotlinx.coroutines.*fun main() = runBlocking { // runBlocking: lanza una coroutine y bloquea el hilo hasta que termina // (solo usar en main() o tests, nunca en código de producción) println("Inicio en hilo: ${Thread.currentThread().name}") // launch: lanza una coroutine que no devuelve valor val job = launch { delay(1000) // Suspende 1 segundo sin bloquear el hilo println("Coroutine completada") } println("Esto se imprime ANTES que la coroutine termina") job.join() // Espera a que la coroutine termine println("Todo completado")}// Salida:// Inicio en hilo: main// Esto se imprime ANTES que la coroutine termina// (espera 1 segundo)// Coroutine completada// Todo completado
6.3 launch vs async / await
kotlin
import kotlinx.coroutines.*suspend fun obtenerUsuario(id: Int): String { delay(500) // Simula llamada de red return "Usuario$id"}suspend fun obtenerPosts(userId: Int): List<String> { delay(800) // Simula llamada de red return listOf("Post1 de $userId", "Post2 de $userId")}fun main() = runBlocking { // === SECUENCIAL (tarda 1300ms) === val t1 = System.currentTimeMillis() val usuario = obtenerUsuario(1) // Espera 500ms val posts = obtenerPosts(1) // Espera 800ms más println("Secuencial tardó ${System.currentTimeMillis() - t1}ms") // ~1300ms // === PARALELO con async/await (tarda ~800ms) === val t2 = System.currentTimeMillis() val usuarioDeferred = async { obtenerUsuario(2) } // Inicia inmediatamente val postsDeferred = async { obtenerPosts(2) } // Inicia inmediatamente val usuario2 = usuarioDeferred.await() // Espera el resultado val posts2 = postsDeferred.await() // Ya está listo o casi println("Paralelo tardó ${System.currentTimeMillis() - t2}ms") // ~800ms (ambas coroutines corrieron en paralelo)}
6.4 Coroutine Scope y Context
Los Dispatchers determinan en qué hilo o pool de hilos corre la coroutine:
kotlin
import kotlinx.coroutines.*fun main() = runBlocking { // Dispatchers.Main → Hilo principal de UI (Android/Desktop) // Dispatchers.IO → Pool optimizado para I/O (red, base de datos, archivos) // Dispatchers.Default → Pool para cómputo intensivo (CPU) // Dispatchers.Unconfined → Sin restricción de hilo launch(Dispatchers.IO) { println("IO en: ${Thread.currentThread().name}") // Aquí harías llamadas de red o base de datos } launch(Dispatchers.Default) { println("Default en: ${Thread.currentThread().name}") // Aquí harías cálculos pesados } // withContext: cambia de dispatcher dentro de una coroutine val resultado = withContext(Dispatchers.IO) { // Simula operación de red delay(100) "datos de la red" } println("Recibido: $resultado")}
6.5 Flow — Streams reactivos con Coroutines
Flow es la respuesta de Kotlin a los streams reactivos (similar a RxJava pero más simple):
kotlin
import kotlinx.coroutines.*import kotlinx.coroutines.flow.*// Un Flow emite múltiples valores de forma asíncronafun obtenerTemperaturas(): Flow<Double> = flow { // flow { } es un builder de Flow while (true) { val temperatura = (15..35).random().toDouble() emit(temperatura) // Emite un valor delay(1000) // Espera 1 segundo }}fun numerosImpares(): Flow<Int> = flow { for (i in 1..10) { if (i % 2 != 0) emit(i) delay(100) }}fun main() = runBlocking { // Recolectar (consumir) un Flow numerosImpares() .filter { it > 3 } // Filtra valores .map { it * it } // Transforma .collect { valor -> // Terminal: consume cada valor println(valor) } // Salida: 25, 49, 81 // StateFlow: similar a LiveData, guarda el último valor emitido // SharedFlow: para eventos que no deben perderse // (Muy usados en arquitectura Android con ViewModel)}
6.6 Manejo de errores en Coroutines
kotlin
import kotlinx.coroutines.*fun main() = runBlocking { // try/catch funciona normalmente con coroutines val resultado = try { withContext(Dispatchers.IO) { // Simula un error de red throw Exception("Error de conexión") "datos" } } catch (e: Exception) { println("Error capturado: ${e.message}") "valor por defecto" } println("Resultado: $resultado") // CoroutineExceptionHandler: manejador global para coroutines launch{} val handler = CoroutineExceptionHandler { _, excepcion -> println("Excepción no manejada: ${excepcion.message}") } val job = launch(handler) { throw RuntimeException("¡Boom!") } job.join()}
PARTE V — KOTLIN MULTIPLATFORM
Sección 7 · Kotlin Multiplatform: Introducción y arquitectura
¿Qué es Kotlin Multiplatform (KMP)?
Kotlin Multiplatform es una tecnología que permite compartir código Kotlin entre múltiples plataformas: Android, iOS, Web, Desktop y Server. En 2023 pasó a ser estable y en 2026 es una tecnología madura y adoptada por empresas como Netflix, McDonald's, VMware y muchas más.
La idea central: Escribe la lógica de negocio una vez en Kotlin, y usa las plataformas nativas para la UI.
┌─────────────────────────────────────────────────────────┐
│ Tu Aplicación │
├────────────────┬──────────────┬───────────────┬─────────┤
│ Android UI │ iOS UI │ Web UI │Desktop │
│ (Compose / │ (SwiftUI / │ (React / WASM)│(Compose)│
│ XML) │ UIKit) │ │ │
├────────────────┴──────────────┴───────────────┴─────────┤
│ CÓDIGO COMPARTIDO EN KOTLIN (KMP) │
│ • Lógica de negocio │
│ • Modelos de datos │
│ • Llamadas a red (Ktor) │
│ • Base de datos local (SQLDelight / Room) │
│ • Serialización (kotlinx.serialization) │
│ • ViewModels / Presenters │
└─────────────────────────────────────────────────────────┘
7.1 KMP vs otras soluciones multiplataforma
KMP
Flutter
React Native
Lenguaje
Kotlin
Dart
JavaScript/TypeScript
UI
Nativa (por defecto)
Propio (Canvas)
Componentes nativos
Rendimiento
Nativo
Muy bueno
Bueno
Integración nativa
⭐⭐⭐⭐⭐
⭐⭐⭐
⭐⭐⭐
Curva de aprendizaje
Media
Media
Baja (si sabes JS)
Madurez (2026)
⭐⭐⭐⭐
⭐⭐⭐⭐⭐
⭐⭐⭐⭐
Empresa detrás
JetBrains + Google
Google
Meta
La ventaja clave de KMP: No es un framework que impone una forma de hacer UI. Puedes compartir solo la capa de lógica y usar SwiftUI nativo en iOS y Jetpack Compose en Android. O puedes usar Compose Multiplatform para compartir también la UI.
7.2 expect / actual — El corazón de KMP
Cuando necesitas funcionalidad específica de plataforma, KMP usa el mecanismo expect/actual:
kotlin
// En commonMain (código compartido):expect fun obtenerPlataforma(): String // "Espero" que cada plataforma implemente estoexpect fun getCurrentTimeMillis(): Long// En androidMain (implementación Android):actual fun obtenerPlataforma(): String = "Android ${android.os.Build.VERSION.SDK_INT}"actual fun getCurrentTimeMillis(): Long = System.currentTimeMillis()// En iosMain (implementación iOS):actual fun obtenerPlataforma(): String = UIDevice.currentDevice.systemName() + " " + UIDevice.currentDevice.systemVersionactual fun getCurrentTimeMillis(): Long = (NSDate().timeIntervalSince1970 * 1000).toLong()
Sección 8 · Configurando tu primer proyecto KMP
Usando el asistente oficial
La forma más fácil de crear un proyecto KMP es usando kmp.jetbrains.com, el generador oficial de JetBrains.
Pero aquí lo vamos a hacer a mano para entender cada pieza.
8.2 El build.gradle.kts del módulo shared explicado
kotlin
// shared/build.gradle.ktsplugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.androidLibrary) alias(libs.plugins.kotlinSerialization) // Para kotlinx.serialization alias(libs.plugins.sqldelight) // Para SQLDelight (base de datos)}kotlin { // Targets (plataformas destino) androidTarget { compilations.all { kotlinOptions.jvmTarget = "17" } } // iOS targets iosX64() // Simulador iOS en Mac Intel iosArm64() // Dispositivo iOS físico iosSimulatorArm64()// Simulador iOS en Mac Apple Silicon (M1/M2/M3/M4) // Opcional: Web (WASM o JS) // wasmJs { browser() } // Opcional: Desktop (JVM) // jvm("desktop") sourceSets { // commonMain: código compartido por TODAS las plataformas commonMain.dependencies { // Coroutines multiplataforma implementation(libs.kotlinx.coroutines.core) // Serialización JSON implementation(libs.kotlinx.serialization.json) // Cliente HTTP multiplataforma implementation(libs.ktor.client.core) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.json) // Inyección de dependencias implementation(libs.koin.core) // Logging implementation(libs.napier) } // androidMain: solo para Android androidMain.dependencies { implementation(libs.ktor.client.android) // Motor HTTP para Android implementation(libs.koin.android) } // iosMain: solo para iOS iosMain.dependencies { implementation(libs.ktor.client.darwin) // Motor HTTP para iOS (Darwin) } // commonTest: tests compartidos commonTest.dependencies { implementation(libs.kotlin.test) implementation(libs.kotlinx.coroutines.test) } }}
8.3 Un repositorio KMP completo paso a paso
kotlin
// shared/src/commonMain/kotlin/data/remote/ApiService.ktimport io.ktor.client.*import io.ktor.client.call.*import io.ktor.client.plugins.contentnegotiation.*import io.ktor.client.request.*import io.ktor.serialization.kotlinx.json.*import kotlinx.serialization.Serializableimport kotlinx.serialization.json.Json// Modelo de datos serializable@Serializabledata class Post( val id: Int, val userId: Int, val title: String, val body: String)// El cliente HTTP se crea en commonMain// pero el motor (engine) es específico de cada plataformaclass ApiService(private val httpClient: HttpClient) { companion object { const val BASE_URL = "https://jsonplaceholder.typicode.com" } suspend fun obtenerPosts(): Result<List<Post>> = runCatching { httpClient.get("$BASE_URL/posts").body() } suspend fun obtenerPost(id: Int): Result<Post> = runCatching { httpClient.get("$BASE_URL/posts/$id").body() }}// Factory del HttpClient en commonMainfun crearHttpClient(): HttpClient = HttpClient { install(ContentNegotiation) { json(Json { ignoreUnknownKeys = true // Ignora campos extra del JSON isLenient = true // Más permisivo con el formato prettyPrint = false }) }}
kotlin
// shared/src/commonMain/kotlin/domain/PostRepository.kt// Repositorio que abstrae la fuente de datosclass PostRepository(private val apiService: ApiService) { suspend fun obtenerTodosPosts(): Result<List<Post>> { return apiService.obtenerPosts() } suspend fun obtenerPostPorId(id: Int): Result<Post> { return apiService.obtenerPost(id) }}
kotlin
// shared/src/commonMain/kotlin/presentation/PostViewModel.kt// Este ViewModel funciona en TODAS las plataformasimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.flow.MutableStateFlowimport kotlinx.coroutines.flow.StateFlowimport kotlinx.coroutines.flow.asStateFlowimport kotlinx.coroutines.launch// Estado de la UI (sealed class)sealed class PostsUiState { object Cargando : PostsUiState() data class Exitoso(val posts: List<Post>) : PostsUiState() data class Error(val mensaje: String) : PostsUiState()}class PostViewModel( private val repository: PostRepository, private val scope: CoroutineScope // Inyectamos el scope para testabilidad) { private val _uiState = MutableStateFlow<PostsUiState>(PostsUiState.Cargando) val uiState: StateFlow<PostsUiState> = _uiState.asStateFlow() fun cargarPosts() { scope.launch { _uiState.value = PostsUiState.Cargando repository.obtenerTodosPosts() .onSuccess { posts -> _uiState.value = PostsUiState.Exitoso(posts) } .onFailure { error -> _uiState.value = PostsUiState.Error( error.message ?: "Error desconocido" ) } } }}
Sección 9 · Librerías esenciales del ecosistema KMP (2026)
Este es el stack de librerías que usamos en proyectos KMP de producción en 2026:
9.1 Networking: Ktor Client
Ktor es el framework HTTP oficial de JetBrains, completamente multiplataforma:
kotlin
// Versión en libs.versions.toml (catálogo de versiones de Gradle)// [versions]// ktor = "3.1.0"//// [libraries]// ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }// ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" }// ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }// Ejemplo completo con autenticación y manejo de erroresval client = HttpClient { install(ContentNegotiation) { json() } install(HttpTimeout) { requestTimeoutMillis = 15_000 connectTimeoutMillis = 5_000 } install(Logging) { level = LogLevel.BODY // Para desarrollo; usa NONE en producción } defaultRequest { url("https://api.miapp.com/v1/") header("Authorization", "Bearer ${tokenProvider.getToken()}") }}
9.2 Serialización: kotlinx.serialization
kotlin
import kotlinx.serialization.*import kotlinx.serialization.json.*@Serializabledata class Config( val apiUrl: String, val timeout: Int = 30, @SerialName("max_retries") val maxReintentos: Int = 3, // Nombre diferente en JSON val features: List<String> = emptyList())val json = Json { prettyPrint = true; ignoreUnknownKeys = true }fun main() { val config = Config(apiUrl = "https://api.ejemplo.com", timeout = 15) // Serializar a JSON string val jsonString = json.encodeToString(config) println(jsonString) // Deserializar de JSON string val configDesdeJson = json.decodeFromString<Config>(jsonString) println(configDesdeJson.apiUrl)}
9.3 Base de datos: SQLDelight 2.x
SQLDelight genera Kotlin type-safe code a partir de queries SQL:
sql
-- shared/src/commonMain/sqldelight/database/Post.sqCREATE TABLE post ( id INTEGER NOT NULL PRIMARY KEY, userId INTEGER NOT NULL, title TEXT NOT NULL, body TEXT NOT NULL);selectAll:SELECT * FROM post;selectById:SELECT * FROM post WHERE id = :id;insertOrReplace:INSERT OR REPLACE INTO post (id, userId, title, body)VALUES (?, ?, ?, ?);deleteAll:DELETE FROM post;
kotlin
// Uso del DAO generadoclass PostLocalDataSource(private val database: AppDatabase) { fun obtenerTodosPosts(): List<Post> = database.postQueries.selectAll().executeAsList() .map { it.toPost() } fun guardarPosts(posts: List<Post>) { database.transaction { database.postQueries.deleteAll() posts.forEach { post -> database.postQueries.insertOrReplace( id = post.id.toLong(), userId = post.userId.toLong(), title = post.title, body = post.body ) } } }}
9.4 Inyección de dependencias: Koin
kotlin
// shared/src/commonMain/kotlin/di/SharedModule.ktimport org.koin.dsl.moduleval sharedModule = module { single { crearHttpClient() } single { ApiService(get()) } single { PostRepository(get()) } factory { params -> PostViewModel(get(), params.get()) }}// En androidMainval androidModule = module { includes(sharedModule) // Dependencias específicas de Android single<Context> { androidContext() }}// Inicialización en Android Application class:// startKoin { androidContext(this); modules(androidModule) }
9.5 Tabla completa del stack KMP 2026
Categoría
Librería
Versión
Notas
HTTP Client
io.ktor:ktor-client-core
3.1.x
Multiplataforma nativa
Serialización
org.jetbrains.kotlinx:kotlinx-serialization-json
1.8.x
JSON, CBOR, ProtoBuf
Base de datos
app.cash.sqldelight
2.0.x
SQL type-safe
DI
io.insert-koin:koin-core
4.0.x
Sencillo y KMP-ready
Coroutines
org.jetbrains.kotlinx:kotlinx-coroutines-core
1.9.x
Base para async
Settings/Prefs
com.russhwolf:multiplatform-settings
1.2.x
SharedPreferences/UserDefaults
Fechas/Tiempo
org.jetbrains.kotlinx:kotlinx-datetime
0.6.x
DateTime multiplataforma
Logging
io.github.aakira:napier
2.7.x
Logger KMP
UI (opcional)
org.jetbrains.compose
1.7.x
Compose Multiplatform
Testing
io.mockk:mockk
1.14.x
Mocking para tests
PARTE VI — NIVEL EXPERTO
Sección 10 · Patrones avanzados, Testing y CI/CD
10.1 Clean Architecture en KMP
La arquitectura más usada en proyectos KMP grandes es Clean Architecture adaptada:
shared/
└── src/commonMain/kotlin/
├── domain/ ← Capa de dominio (pura Kotlin, sin frameworks)
│ ├── model/ ← Entidades de negocio
│ │ └── Post.kt
│ ├── repository/ ← Interfaces (contratos)
│ │ └── IPostRepository.kt
│ └── usecase/ ← Casos de uso
│ └── ObtenerPostsUseCase.kt
├── data/ ← Capa de datos
│ ├── remote/ ← Fuentes remotas
│ │ ├── dto/ ← Data Transfer Objects
│ │ └── ApiService.kt
│ ├── local/ ← Fuentes locales (base de datos)
│ └── repository/ ← Implementaciones concretas
│ └── PostRepositoryImpl.kt
└── presentation/ ← ViewModels y estados de UI
└── posts/
├── PostsViewModel.kt
└── PostsUiState.kt
kotlin
// domain/repository/IPostRepository.ktinterface IPostRepository { suspend fun obtenerPosts(): Result<List<Post>> suspend fun obtenerPostLocal(id: Int): Post? suspend fun guardarPosts(posts: List<Post>)}// domain/usecase/ObtenerPostsUseCase.ktclass ObtenerPostsUseCase(private val repository: IPostRepository) { suspend operator fun invoke(): Result<List<Post>> { return repository.obtenerPosts() .onSuccess { posts -> repository.guardarPosts(posts) } }}// data/repository/PostRepositoryImpl.ktclass PostRepositoryImpl( private val apiService: ApiService, private val localDataSource: PostLocalDataSource) : IPostRepository { override suspend fun obtenerPosts(): Result<List<Post>> { return apiService.obtenerPosts() } override suspend fun obtenerPostLocal(id: Int): Post? { return localDataSource.obtenerPorId(id) } override suspend fun guardarPosts(posts: List<Post>) { localDataSource.guardarTodos(posts) }}
10.2 Testing en KMP
kotlin
// shared/src/commonTest/kotlin/domain/ObtenerPostsUseCaseTest.ktimport kotlinx.coroutines.test.runTest // Para testear coroutinesimport kotlin.test.*class ObtenerPostsUseCaseTest { // Fake repository (implementación de prueba) private val fakeRepository = object : IPostRepository { var deberiaFallar = false val postsFake = listOf( Post(1, 1, "Título Test", "Cuerpo Test") ) override suspend fun obtenerPosts(): Result<List<Post>> { return if (deberiaFallar) { Result.failure(Exception("Error de red simulado")) } else { Result.success(postsFake) } } override suspend fun obtenerPostLocal(id: Int) = null override suspend fun guardarPosts(posts: List<Post>) {} } private val useCase = ObtenerPostsUseCase(fakeRepository) @Test fun `cuando la red funciona, devuelve posts correctamente`() = runTest { val resultado = useCase() assertTrue(resultado.isSuccess) assertEquals(1, resultado.getOrNull()?.size) assertEquals("Título Test", resultado.getOrNull()?.first()?.title) } @Test fun `cuando la red falla, devuelve error`() = runTest { fakeRepository.deberiaFallar = true val resultado = useCase() assertTrue(resultado.isFailure) assertEquals("Error de red simulado", resultado.exceptionOrNull()?.message) }}
Usa @Immutable y @Stable en Compose para evitar recomposiciones innecesarias
Prefiere StateFlow sobre LiveData en código compartido KMP
Usa Dispatchers.IO para I/O y Dispatchers.Default para cómputo
Con SQLDelight, usa transacciones para inserciones masivas
Calidad de código:
Configura Detekt para análisis estático de Kotlin
Usa Konsist para verificar que tu arquitectura se respeta en tests
Activa el modo estricto de null-safety en el compilador
Productividad:
Instala el plugin Kotlin Multiplatform en Android Studio
Usa el Kotlin Notebook para prototipar ideas rápidamente
Configura alias en Gradle para builds más rápidos
Sección 11 · Compose Multiplatform — UI compartida en todas las plataformas
¿Qué es Compose Multiplatform?
Jetpack Compose es el toolkit de UI moderno declarativo de Google para Android. Compose Multiplatform (CMP), desarrollado por JetBrains, lleva ese mismo paradigma a iOS, Desktop (Windows/macOS/Linux) y Web (WASM).
La idea es simple pero poderosa: escribes tu UI en Kotlin con Compose una vez, y funciona en todas las plataformas.
┌────────────────────────────────────────────────────────┐
│ Compose Multiplatform (CMP) │
│ │
│ @Composable fun PantallaInicio() { ... } │
│ ↓ UN SOLO CÓDIGO │
├──────────┬──────────┬──────────┬───────────────────────┤
│ Android │ iOS │ Desktop │ Web (WASM) │
│ (estable)│ (estable)│ (estable)│ (experimental) │
└──────────┴──────────┴──────────┴───────────────────────┘
💡 Importante en 2026: Compose Multiplatform para iOS ya es estable y lo usa en producción empresas como Instabug, Touchlab y decenas de startups. La API es prácticamente idéntica a Jetpack Compose para Android, así que si ya sabes Compose Android, aprender CMP es casi inmediato.
11.1 Configuración del proyecto CMP
Añade los plugins y dependencias en tu build.gradle.kts:
// src/main/kotlin/com/miapi/plugins/StatusPages.kt// Manejo global de erroresimport io.ktor.http.*import io.ktor.server.application.*import io.ktor.server.plugins.statuspages.*import io.ktor.server.response.*import kotlinx.serialization.Serializable@Serializabledata class ErrorResponse( val codigo: Int, val mensaje: String, val detalle: String? = null)fun Application.configureStatusPages() { install(StatusPages) { // Maneja excepciones personalizadas exception<RecursoNoEncontradoException> { call, cause -> call.respond( HttpStatusCode.NotFound, ErrorResponse(404, cause.message ?: "Recurso no encontrado") ) } exception<NoAutorizadoException> { call, _ -> call.respond( HttpStatusCode.Unauthorized, ErrorResponse(401, "No autorizado") ) } exception<ValidacionException> { call, cause -> call.respond( HttpStatusCode.UnprocessableEntity, ErrorResponse(422, "Error de validación", cause.mensaje) ) } // Catch-all para errores no controlados exception<Throwable> { call, cause -> call.application.log.error("Error no controlado", cause) call.respond( HttpStatusCode.InternalServerError, ErrorResponse(500, "Error interno del servidor") ) } // Estado HTTP no manejado status(HttpStatusCode.NotFound) { call, status -> call.respond(status, ErrorResponse(404, "Ruta no encontrada")) } }}// Excepciones personalizadasclass RecursoNoEncontradoException(mensaje: String) : Exception(mensaje)class NoAutorizadoException : Exception("No autorizado")class ValidacionException(val mensaje: String) : Exception(mensaje)
12.4 Base de datos con Exposed ORM
Exposed es el ORM oficial de JetBrains para Kotlin. Tiene dos APIs: DSL (type-safe SQL) y DAO (estilo ActiveRecord):
kotlin
// src/main/kotlin/com/miapi/database/tables/Users.ktimport org.jetbrains.exposed.sql.Tableimport org.jetbrains.exposed.kotlin.datetime.timestamp// Definición de la tabla (DSL API)object Users : Table("users") { val id = integer("id").autoIncrement() val nombre = varchar("nombre", 100) val email = varchar("email", 255).uniqueIndex() val passwordHash = varchar("password_hash", 255) val activo = bool("activo").default(true) val creadoEn = timestamp("creado_en") override val primaryKey = PrimaryKey(id)}object Posts : Table("posts") { val id = integer("id").autoIncrement() val userId = integer("user_id").references(Users.id) val titulo = varchar("titulo", 200) val contenido = text("contenido") val publicado = bool("publicado").default(false) val creadoEn = timestamp("creado_en") override val primaryKey = PrimaryKey(id)}
kotlin
// src/main/kotlin/com/miapi/database/DatabaseFactory.ktimport com.zaxxer.hikari.HikariConfigimport com.zaxxer.hikari.HikariDataSourceimport kotlinx.coroutines.Dispatchersimport org.jetbrains.exposed.sql.*import org.jetbrains.exposed.sql.transactions.experimental.newSuspendedTransactionimport org.jetbrains.exposed.sql.transactions.transactionobject DatabaseFactory { fun init() { val config = HikariConfig().apply { jdbcUrl = System.getenv("DATABASE_URL") ?: "jdbc:postgresql://localhost:5432/midb" driverClassName = "org.postgresql.Driver" username = System.getenv("DB_USER") ?: "postgres" password = System.getenv("DB_PASSWORD") ?: "postgres" maximumPoolSize = 10 isAutoCommit = false transactionIsolation = "TRANSACTION_REPEATABLE_READ" validate() } Database.connect(HikariDataSource(config)) // Crear tablas si no existen transaction { SchemaUtils.create(Users, Posts) } } // Función helper para ejecutar queries de forma suspendida suspend fun <T> dbQuery(block: suspend Transaction.() -> T): T = newSuspendedTransaction(Dispatchers.IO) { block() }}
12.5 Repository pattern con Exposed
kotlin
12.6 Rutas REST completas
kotlin
12.7 Test de integración con Ktor TestEngine
kotlin
// src/test/kotlin/com/miapi/routes/UserRoutesTest.ktimport io.ktor.client.request.*import io.ktor.client.statement.*import io.ktor.http.*import io.ktor.server.testing.*import kotlinx.serialization.json.Jsonimport kotlin.test.*class UserRoutesTest { // Fake repository para tests private val fakeRepo = FakeUserRepository() @Test fun `GET users devuelve lista vacía cuando no hay usuarios`() = testApplication { application { module(userRepository = fakeRepo) } val response = client.get("/api/v1/users") assertEquals(HttpStatusCode.OK, response.status) val body = Json.decodeFromString<PaginatedResponse<UserDto>>(response.bodyAsText()) assertEquals(0, body.total) } @Test fun `POST users crea usuario correctamente`() = testApplication { application { module(userRepository = fakeRepo) } val response = client.post("/api/v1/users") { contentType(ContentType.Application.Json) setBody("""{"nombre":"Test","email":"test@test.com","password":"12345678"}""") } assertEquals(HttpStatusCode.Created, response.status) val usuario = Json.decodeFromString<UserDto>(response.bodyAsText()) assertEquals("Test", usuario.nombre) } @Test fun `GET users por id inválido devuelve 422`() = testApplication { application { module(userRepository = fakeRepo) } val response = client.get("/api/v1/users/abc") assertEquals(HttpStatusCode.UnprocessableEntity, response.status) }}
PARTE IX — ARQUITECTURA AVANZADA
Sección 13 · Arquitectura MVI con KMP
¿Qué es MVI?
MVI (Model-View-Intent) es un patrón de arquitectura unidireccional (UDF — Unidirectional Data Flow). Es el patrón recomendado en 2026 para apps con Compose porque encaja perfectamente con la naturaleza inmutable y reactiva de Compose.
┌──────────────────────────────────────────────────────┐
│ UI (View) │
│ │
│ Observa State ←──────────── Envía Intent ──────→ │
└──────────────────┬───────────────────────────────────┘
│ State ↑ Intent
│ │
┌──────────────────▼───────────────────────────────────┐
│ ViewModel / Store │
│ │
│ State = reduce(State_anterior + Intent) │
└──────────────────────────────────────────────────────┘
State: El estado completo e inmutable de la pantalla
Intent: Las acciones que puede realizar el usuario
ViewModel: Procesa los intents y emite nuevos estados
UI: Solo muestra el estado y emite intents
13.1 Implementación MVI completa
kotlin
// shared/src/commonMain/kotlin/presentation/base/MviViewModel.ktimport kotlinx.coroutines.CoroutineScopeimport kotlinx.coroutines.flow.*import kotlinx.coroutines.launch// ViewModel base genérico para MVIabstract class MviViewModel<S : Any, I : Any, E : Any>( private val initialState: S, protected val scope: CoroutineScope) { // Estado actual de la UI (inmutable hacia afuera) private val _state = MutableStateFlow(initialState) val state: StateFlow<S> = _state.asStateFlow() // Eventos de un solo uso (navegación, snackbars, etc.) private val _events = MutableSharedFlow<E>(extraBufferCapacity = 10) val events: SharedFlow<E> = _events.asSharedFlow() // Punto de entrada para los intents del usuario fun onIntent(intent: I) { scope.launch { processIntent(intent) } } // Cada ViewModel implementa su propia lógica protected abstract suspend fun processIntent(intent: I) // Helper para actualizar el estado de forma segura protected fun updateState(transform: S.() -> S) { _state.update { it.transform() } } // Helper para emitir eventos de un solo uso protected suspend fun emitEvent(event: E) { _events.emit(event) }}
kotlin
// shared/src/commonMain/kotlin/presentation/posts/PostsMvi.ktimport kotlinx.serialization.Serializable// === STATE ===// El estado completo de la pantalla de postsdata class PostsState( val posts: List<Post> = emptyList(), val estaCargando: Boolean = false, val error: String? = null, val busqueda: String = "", val paginaActual: Int = 1, val hasMasPaginas: Boolean = true) { // Estado derivado: posts filtrados por búsqueda val postsFiltrados: List<Post> get() = if (busqueda.isBlank()) posts else posts.filter { it.title.contains(busqueda, ignoreCase = true) || it.body.contains(busqueda, ignoreCase = true) }}// === INTENT ===// Todas las acciones posibles del usuariosealed class PostsIntent { object CargarPosts : PostsIntent() object CargarMasPosts : PostsIntent() object Recargar : PostsIntent() data class BuscarPosts(val query: String) : PostsIntent() data class ClickPost(val postId: Int) : PostsIntent() data class EliminarPost(val postId: Int) : PostsIntent()}// === EVENT ===// Efectos de un solo uso (no son estado persistente)sealed class PostsEvent { data class NavegateToDetail(val postId: Int) : PostsEvent() data class MostrarSnackbar(val mensaje: String) : PostsEvent() object ScrollToTop : PostsEvent()}
kotlin
13.2 UI con MVI en Compose
kotlin
// shared/src/commonMain/kotlin/ui/screens/PostsMviScreen.kt@Composablefun PostsMviScreen( viewModel: PostsMviViewModel, navController: NavController) { val state by viewModel.state.collectAsState() val snackbarHostState = remember { SnackbarHostState() } // Manejar eventos de un solo uso LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { is PostsEvent.NavegateToDetail -> navController.navigate(Ruta.DetallePost.conId(event.postId)) is PostsEvent.MostrarSnackbar -> snackbarHostState.showSnackbar(event.mensaje) is PostsEvent.ScrollToTop -> { /* scroll to top */ } } } } Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding -> Column(modifier = Modifier.padding(padding)) { // Barra de búsqueda SearchBar( query = state.busqueda, onQueryChange = { viewModel.onIntent(PostsIntent.BuscarPosts(it)) } ) // Lista de posts filtrados LazyColumn { items(state.postsFiltrados, key = { it.id }) { post -> PostCard( post = post, onClick = { viewModel.onIntent(PostsIntent.ClickPost(post.id)) } ) } // Indicador de carga al final para paginación if (state.estaCargando) { item { Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { CircularProgressIndicator(modifier = Modifier.padding(16.dp)) } } } } } }}
PARTE X — INTEROPERABILIDAD
Sección 14 · Interoperabilidad con Swift
Una de las preguntas más frecuentes en KMP es: ¿cómo uso el código Kotlin desde Swift? Esta sección responde esa pregunta en profundidad.
14.1 ¿Qué expone KMP a Swift automáticamente?
KMP compila el código commonMain e iosMain a un framework Objective-C/Swift (.framework). El compilador Kotlin genera cabeceras Objective-C que Swift puede importar directamente.
Código Kotlin (KMP) Framework iOS generado
────────────────────── ──────────────────────
class PostViewModel → @objc class PostViewModel
fun cargarPosts() → func cargarPosts()
data class Post → @objc class Post (con propiedades)
suspend fun x() → PROBLEMA → necesita adaptación
Flow<T> → PROBLEMA → necesita adaptación
El principal reto de la interoperabilidad es que Coroutines y Flow no tienen equivalente directo en Swift. Hay dos soluciones:
14.2 Solución 1: SKIE (la recomendada en 2026)
SKIE (Swift Kotlin Interface Enhancer) de Touchlab es la librería estándar de facto para la interoperabilidad Swift-Kotlin en 2026. Convierte automáticamente Flow en AsyncSequence de Swift y suspend fun en async de Swift:
kotlin
// shared/build.gradle.ktsplugins { id("co.touchlab.skie") version "0.9.0"}
kotlin
// Con SKIE, esto en Kotlin:class PostViewModel { val posts: StateFlow<PostsState> = _posts.asStateFlow() suspend fun cargarPosts() { ... }}
swift
// Se convierte automáticamente en esto en Swift:// (sin ningún código de adaptación extra)let viewModel = PostViewModel()// Observar un StateFlow como AsyncSequenceTask { for await state in viewModel.posts { self.state = state }}// Llamar a suspend fun como async/awaitTask { await viewModel.cargarPosts()}
Si prefieres no añadir dependencias, puedes crear wrappers que expongan callbacks en lugar de coroutines:
kotlin
// shared/src/iosMain/kotlin/presentation/PostViewModelWrapper.kt// Esta clase es solo para iOS; Android usa el ViewModel directamenteimport kotlinx.coroutines.*import kotlinx.coroutines.flow.*// Wrapper que expone callbacks en lugar de Flowsclass PostViewModelIos( private val viewModel: PostsMviViewModel) { private val scope = MainScope() // Scope vinculado al hilo principal de iOS // Expone el estado como callback en lugar de Flow fun observarEstado(onState: (PostsState) -> Unit): () -> Unit { val job = scope.launch { viewModel.state.collect { estado -> onState(estado) } } // Devuelve función de cancelación return { job.cancel() } } fun cargarPosts() { scope.launch { viewModel.onIntent(PostsIntent.CargarPosts) } } // Importante: cancelar el scope cuando el ViewModel se destruye fun onDestroy() { scope.cancel() }}
swift
// Uso en SwiftUI (sin SKIE)class PostsSwiftViewModel: ObservableObject { @Published var state = PostsState() private let viewModel = PostViewModelIos( viewModel: PostsMviViewModel(/* ... */) ) private var cancelObserver: (() -> Void)? init() { cancelObserver = viewModel.observarEstado { [weak self] newState in DispatchQueue.main.async { self?.state = newState } } viewModel.cargarPosts() } func cargarPosts() { viewModel.cargarPosts() } deinit { cancelObserver?() viewModel.onDestroy() }}struct PostsView: View { @StateObject private var viewModel = PostsSwiftViewModel() var body: some View { List(viewModel.state.posts, id: \.id) { post in Text(post.title) } .onAppear { viewModel.cargarPosts() } }}
14.4 @ObjCName y visibilidad desde Swift
Puedes controlar cómo se expone tu código Kotlin en Swift:
kotlin
import kotlin.native.ObjCName// Cambia el nombre en Swift/Objective-C@ObjCName("PostItem", exact = true)data class Post(val id: Int, val title: String, val body: String)// Oculta una clase del framework Swift@HiddenFromObjCinternal class ClaseInternaDeSoloKotlin(...)// Hacer que una interfaz sea más amigable para Swift@ObjCName("PostRepositoryProtocol")interface IPostRepository { @ObjCName("fetchPosts") suspend fun obtenerPosts(): Result<List<Post>>}
14.5 Llamar código Swift/Objective-C desde Kotlin
El flujo inverso también es posible:
kotlin
// En iosMain: usar APIs de iOS desde Kotlinimport platform.UIKit.*import platform.Foundation.*import platform.CoreLocation.*actual class LocationService { private val locationManager = CLLocationManager() actual fun solicitarPermisos() { locationManager.requestWhenInUseAuthorization() } actual fun obtenerUbicacion(): Pair<Double, Double>? { val loc = locationManager.location ?: return null return Pair(loc.coordinate.useContents { latitude }, loc.coordinate.useContents { longitude }) }}// Mostrar un UIAlertController desde Kotlinfun mostrarAlertaIOS(titulo: String, mensaje: String) { val alert = UIAlertController.alertControllerWithTitle( title = titulo, message = mensaje, preferredStyle = UIAlertControllerStyleAlert ) alert.addAction( UIAlertAction.actionWithTitle("OK", style = UIAlertActionStyleDefault, handler = null) ) UIApplication.sharedApplication.keyWindow?.rootViewController ?.presentViewController(alert, animated = true, completion = null)}
PARTE XI — RENDIMIENTO
Sección 15 · Optimización, profiling y debugging avanzado
15.1 Profiling con Android Studio
Android Studio tiene herramientas integradas de profiling que funcionan con proyectos KMP:
CPU Profiler: Detecta métodos lentos y cuellos de botella
Memory Profiler: Detecta memory leaks y uso excesivo de memoria
Energy Profiler: Analiza consumo de batería
kotlin
// Tracing manual para el profilerimport androidx.tracing.tracefun operacionCritica() { trace("MiOperacion") { // Aparece como sección en el profiler // ... código a medir }}
15.2 Optimización de Compose
El rendimiento de Compose depende de evitar recomposiciones innecesarias:
kotlin
// ❌ MALO: Se recompone CADA VEZ que cambia cualquier parte del estado@Composablefun PantallaCompleta(viewModel: MiViewModel) { val estadoCompleto by viewModel.state.collectAsState() ItemCaro(estadoCompleto.contador) // Se recompone si cambia CUALQUIER campo del estado}// ✅ BUENO: Solo se recompone cuando cambia el contador@Composablefun PantallaCompleta(viewModel: MiViewModel) { val contador by viewModel.state .map { it.contador } // Seleccionamos solo lo que necesitamos .collectAsState(initial = 0) ItemCaro(contador)}// Usar remember para cálculos costosos@Composablefun ListaFiltrada(items: List<Item>, query: String) { // ✅ Solo recalcula cuando items o query cambian val filtrados = remember(items, query) { items.filter { it.nombre.contains(query, ignoreCase = true) } } // ... usar filtrados}// Usar key en LazyColumn para reutilizar composables@Composablefun Lista(items: List<Item>) { LazyColumn { items( items = items, key = { it.id } // ✅ Evita recrear composables cuando cambia el orden ) { item -> ItemComposable(item) } }}// @Stable y @Immutable para tipos que Compose no puede inferir como estables@Immutabledata class ConfiguracionUI( val colorPrimario: Color, val mostrarAyuda: Boolean)
15.3 Optimización de Coroutines
kotlin
// ❌ MALO: lanzar demasiadas coroutines individualessuspend fun procesarLista(items: List<Item>) { items.forEach { item -> launch { procesarItem(item) } // Una coroutine por item puede ser demasiado }}// ✅ BUENO: usar chunking o limitación de concurrenciaimport kotlinx.coroutines.sync.Semaphoresuspend fun procesarListaOptimizado(items: List<Item>) { val semaforo = Semaphore(permits = 4) // Máximo 4 concurrent items.map { item -> async { semaforo.withPermit { procesarItem(item) } } }.awaitAll()}// Usar channelFlow para producers complejosfun datosDesdeMultiplesFuentes(): Flow<Dato> = channelFlow { launch { fuente1.collect { send(it) } } launch { fuente2.collect { send(it) } }}// Compartir un Flow caro entre múltiples colectoresclass MiRepository(private val api: ApiService) { // Sin shareIn: cada colector hace una llamada de red independiente // Con shareIn: todos comparten la misma llamada de red val datosCompartidos = api.obtenerDatos() .shareIn( scope = repositoryScope, started = SharingStarted.WhileSubscribed(5000), // Para 5s después del último colector replay = 1 // Emite el último valor a nuevos colectores )}
15.4 Reducir el tamaño del binario iOS
kotlin
// shared/build.gradle.kts — Configuración de optimización para iOS releasekotlin { iosArm64 { binaries.framework("shared") { // En release, activa optimizaciones if (!debuggable) { freeCompilerArgs += listOf( "-Xbinary=bundleId=com.miapp.shared", // Optimización de tamaño de binario "-opt-in=kotlin.native.internal.InternalForKotlinNative" ) } } }}
Consejo para reducir el tamaño del framework iOS:
Usa linkOnly = true para librerías que solo necesitas en tests
Activa el modo de compilación incremental: kotlin.incremental.native=true en gradle.properties
Usa @HiddenFromObjC en clases que no necesitan ser visibles desde Swift
15.5 Debugging en iOS con Xcode
Para debugear el código Kotlin que corre en iOS puedes usar el plugin LLDB que JetBrains incluye:
bash
# En Xcode, cuando el app está pausado en un breakpoint de Kotlin,# puedes usar el LLDB con comandos Kotlin:# (lldb) expression -- import shared# (lldb) po miVariable
También puedes usar Napier (el logger KMP) para trazas detalladas:
kotlin
// shared/src/commonMain/kotlin/utils/Logger.ktimport io.github.aakira.napier.Napierimport io.github.aakira.napier.DebugAntilog// Inicialización (solo en debug)fun initNapier() { Napier.base(DebugAntilog())}// Uso en cualquier parte del código compartidoclass PostRepository(private val api: ApiService) { suspend fun obtenerPosts(): Result<List<Post>> { Napier.d("Iniciando carga de posts", tag = "PostRepository") return api.obtenerPosts() .onSuccess { Napier.i("Posts cargados: ${it.size}", tag = "PostRepository") } .onFailure { Napier.e("Error cargando posts", it, tag = "PostRepository") } }}
PARTE XII — PUBLICACIÓN
Sección 16 · Publicar en Google Play y App Store desde KMP
# proguard-rules.pro
# Mantener clases de kotlinx.serialization
-keepattributes *Annotation*, InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keepclassmembers class kotlinx.serialization.json.** { *** Companion; }
-keepclasseswithmembers class kotlinx.serialization.json.** { kotlinx.serialization.KSerializer serializer(...); }
# Mantener data classes serializables
-keep @kotlinx.serialization.Serializable class * { *; }
# Ktor
-keep class io.ktor.** { *; }
-keep class kotlinx.coroutines.** { *; }
# SQLDelight
-keep class app.cash.sqldelight.** { *; }
3. Generar AAB (Android App Bundle):
bash
./gradlew :androidApp:bundleRelease# El archivo .aab estará en: androidApp/build/outputs/bundle/release/
16.2 Preparar la app iOS para producción
1. Configurar el build en Gradle para generar el XCFramework:
kotlin
// shared/build.gradle.ktskotlin { // Genera un XCFramework unificado para todos los targets iOS val xcfName = "shared" targets.withType<org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget> { binaries.framework { baseName = xcfName isStatic = true // Framework estático (recomendado para iOS) } }}
bash
# Generar el XCFramework para distribución./gradlew :shared:assembleSharedXCFramework# El XCFramework estará en:# shared/build/XCFrameworks/release/shared.xcframework
2. Integrar en Xcode con Swift Package Manager (SPM) — método recomendado 2026:
Crea un archivo Package.swift en la raíz del proyecto:
// build.gradle.kts (nivel raíz)// Gestiona la versión de forma centralizadaobject AppVersion { const val MAJOR = 1 const val MINOR = 3 const val PATCH = 0 val name get() = "$MAJOR.$MINOR.$PATCH" val code get() = MAJOR * 10000 + MINOR * 100 + PATCH // Ej: 10300}
versionCode incrementado (cada release necesita un code mayor)
versionName actualizado (ej: "1.3.0")
ProGuard configurado y testeado
Permisos del AndroidManifest.xml revisados (solo los necesarios)
Target SDK actualizado al más reciente requerido por Google Play
Screenshots y descripción en Play Console actualizados
Testeado en dispositivos físicos y emuladores
iOS:
CFBundleVersion y CFBundleShortVersionString actualizados
Info.plist con descripciones de permisos en todos los idiomas
Screenshots para todos los tamaños de pantalla requeridos
Testeado en dispositivos físicos (especialmente iPad si aplica)
App Privacy Report completado en App Store Connect
Política de privacidad URL configurada
Compartido:
Todos los tests pasan en CI
No hay logs de debug en producción (Napier configurado solo en debug)
URLs de producción configuradas (no staging/dev)
Analytics y crashlytics configurados
PARTE VII — ANIMACIONES AVANZADAS
Sección 17 · Animaciones avanzadas en Compose Multiplatform
Las animaciones en Compose son declarativas: defines el estado de destino y Compose calcula la transición. En CMP 1.7.0+ las APIs de animación son idénticas a Jetpack Compose y funcionan en todas las plataformas.
📌 Importante: En el release de Compose Multiplatform para web (CMP 1.9.0, Beta), se añadió soporte de Frame Rate Configuration para pantallas ProMotion (120Hz), así que tus animaciones van a verse fluidas en dispositivos modernos en todas las plataformas.
17.1 animate*AsState — Animaciones de valor simple
La forma más sencilla de animar una propiedad es con animate*AsState. Le dices el valor de destino y Compose anima suavemente hasta él:
kotlin
import androidx.compose.animation.core.*import androidx.compose.foundation.backgroundimport androidx.compose.foundation.clickableimport androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.MaterialThemeimport androidx.compose.material3.Textimport androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.draw.clipimport androidx.compose.ui.draw.scaleimport androidx.compose.ui.unit.dp@Composablefun BotonAnimado(texto: String, onClick: () -> Unit) { var presionado by remember { mutableStateOf(false) } // Anima el tamaño: cuando presionado=true va a 0.95f, cuando false vuelve a 1f val escala by animateFloatAsState( targetValue = if (presionado) 0.95f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, // Efecto rebote stiffness = Spring.StiffnessLow ), label = "escala_boton" // Label requerida para debugging en Android Studio ) // Anima el color de fondo val colorFondo by animateColorAsState( targetValue = if (presionado) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.primary, animationSpec = tween(durationMillis = 200), label = "color_boton" ) // Anima el radio de las esquinas val radio by animateDpAsState( targetValue = if (presionado) 24.dp else 12.dp, animationSpec = tween(durationMillis = 150), label = "radio_boton" ) Box( modifier = Modifier .scale(escala) .clip(RoundedCornerShape(radio)) .background(colorFondo) .clickable { presionado = !presionado onClick() } .padding(horizontal = 24.dp, vertical = 12.dp) ) { Text( text = texto, color = MaterialTheme.colorScheme.onPrimary, style = MaterialTheme.typography.labelLarge ) }}
Tipos de animate*AsState disponibles:
Función
Tipo animado
animateFloatAsState
Float
animateDpAsState
Dp
animateColorAsState
Color
animateSizeAsState
Size
animateOffsetAsState
Offset
animateIntAsState
Int
animateIntOffsetAsState
IntOffset
animateValueAsState<T>
Cualquier tipo con TwoWayConverter
17.2 AnimationSpec — Cómo se mueve la animación
El AnimationSpec define cómo se produce la transición, no el valor. Es uno de los conceptos más importantes:
kotlin
// spring(): simula física de resorte. El más naturalval especResorte = spring<Float>( dampingRatio = Spring.DampingRatioMediumBouncy, // Cuánto rebota stiffness = Spring.StiffnessLow // Qué tan rígido (rápido))// dampingRatio: // NoBouncy (1.0) = sin rebote// LowBouncy (0.75) = poco rebote// MediumBouncy (0.5) = rebote medio (el más usado)// HighBouncy (0.2) = mucho rebote// stiffness:// VeryLow = muy lento, flojo// Low = lento// Medium = velocidad normal// High = rápido// VeryHigh = muy rápido// tween(): duración fija con easingval especTween = tween<Float>( durationMillis = 300, delayMillis = 100, // Espera antes de empezar easing = FastOutSlowInEasing // Aceleración → deceleración (Material Design) // Otros easings: // LinearEasing, LinearOutSlowInEasing, FastOutLinearInEasing // CubicBezierEasing(0.4f, 0f, 0.2f, 1f) → personalizado)// keyframes(): define valores intermedios exactosval especKeyframes = keyframes<Float> { durationMillis = 500 0f at 0 // En el ms 0, valor = 0 0.8f at 200 // En el ms 200, valor = 0.8 0.95f at 350 // En el ms 350, valor = 0.95 1f at 500 // En el ms 500, valor = 1}// repeatable(): repite la animaciónval especRepetible = repeatable<Float>( iterations = 3, // Número de repeticiones (o Infinite) animation = tween(500), repeatMode = RepeatMode.Reverse // Reverse = ida y vuelta, Restart = reinicia)// infiniteRepeatable(): repite indefinidamente (para loaders, pulsos, etc.)val especInfinito = infiniteRepeatable<Float>( animation = tween(1000, easing = LinearEasing), repeatMode = RepeatMode.Reverse)
Perfecto para indicadores de carga, efectos de "pulsación" y fondos animados:
kotlin
17.4 AnimatedVisibility — Mostrar/Ocultar con animación
kotlin
17.5 AnimatedContent — Transiciones entre contenidos
AnimatedContent anima la transición cuando cambia el contenido (no solo la visibilidad):
kotlin
import androidx.compose.animation.*import androidx.compose.material3.Textimport androidx.compose.runtime.*@Composablefun ContadorAnimado(contador: Int) { AnimatedContent( targetState = contador, transitionSpec = { // initialState es el valor anterior, targetState el nuevo if (targetState > initialState) { // Subiendo: nuevo entra desde arriba, viejo sale por abajo slideInVertically { height -> -height } + fadeIn() togetherWith slideOutVertically { height -> height } + fadeOut() } else { // Bajando: nuevo entra desde abajo, viejo sale por arriba slideInVertically { height -> height } + fadeIn() togetherWith slideOutVertically { height -> -height } + fadeOut() }.using( SizeTransform(clip = false) // No recorta el contenido durante la transición ) }, label = "contador_animado" ) { valorActual -> Text( text = "$valorActual", style = MaterialTheme.typography.displayLarge ) }}// AnimatedContent para estados de carga@Composablefun ContentConEstados(estado: EstadoCarga) { AnimatedContent( targetState = estado, transitionSpec = { fadeIn(tween(300)) togetherWith fadeOut(tween(150)) }, label = "estado_carga" ) { estadoActual -> when (estadoActual) { EstadoCarga.CARGANDO -> CircularProgressIndicator() EstadoCarga.EXITO -> Text("✅ ¡Listo!") EstadoCarga.ERROR -> Text("❌ Error") } }}enum class EstadoCarga { CARGANDO, EXITO, ERROR }
17.6 Shared Element Transitions — La estrella de CMP 1.7+
Las transiciones de elemento compartido crean la ilusión de que un elemento "viaja" de una pantalla a otra. Son el tipo de animación más impactante visualmente. Desde CMP 1.7.0 son estables y funcionan en todas las plataformas.
💡 Novedad de abril 2026: El release de Compose Multiplatform de abril 2026 añadió LookaheadAnimationVisualDebugging para ver exactamente qué está haciendo la animación por debajo — muy útil cuando las transiciones no se comportan como esperas.
kotlin
🔑 Cómo funciona: La clave (key) del sharedElement debe ser idéntica en la lista y en el detalle. Compose usa esa clave para identificar qué elemento "es el mismo" en ambas pantallas y calcula automáticamente la animación de posición, tamaño y forma. No necesitas calcular coordenadas manualmente.
Cuando necesitas animar varias propiedades en sincronía en respuesta al mismo cambio de estado, usa updateTransition:
kotlin
import androidx.compose.animation.core.*import androidx.compose.foundation.backgroundimport androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.draw.clipimport androidx.compose.ui.graphics.Colorimport androidx.compose.ui.unit.dpenum class EstadoTarjeta { COLAPSADA, EXPANDIDA }@Composablefun TarjetaExpandible(contenido: String) { var estado by remember { mutableStateOf(EstadoTarjeta.COLAPSADA) } // updateTransition sincroniza todas las animaciones al mismo estado val transicion = updateTransition( targetState = estado, label = "tarjeta_expandible" ) // Cada propiedad tiene su propia spec pero están sincronizadas val altura by transicion.animateDp( transitionSpec = { spring(dampingRatio = Spring.DampingRatioMediumBouncy) }, label = "altura_tarjeta" ) { estadoActual -> when (estadoActual) { EstadoTarjeta.COLAPSADA -> 80.dp EstadoTarjeta.EXPANDIDA -> 240.dp } } val colorFondo by transicion.animateColor( transitionSpec = { tween(300) }, label = "color_fondo_tarjeta" ) { estadoActual -> when (estadoActual) { EstadoTarjeta.COLAPSADA -> MaterialTheme.colorScheme.surfaceVariant EstadoTarjeta.EXPANDIDA -> MaterialTheme.colorScheme.primaryContainer } } val radio by transicion.animateDp( transitionSpec = { tween(300) }, label = "radio_tarjeta" ) { estadoActual -> when (estadoActual) { EstadoTarjeta.COLAPSADA -> 12.dp EstadoTarjeta.EXPANDIDA -> 24.dp } } Box( modifier = Modifier .fillMaxWidth() .height(altura) .clip(RoundedCornerShape(radio)) .background(colorFondo) .clickable { estado = when (estado) { EstadoTarjeta.COLAPSADA -> EstadoTarjeta.EXPANDIDA EstadoTarjeta.EXPANDIDA -> EstadoTarjeta.COLAPSADA } } .padding(16.dp) ) { Text(contenido) }}
PARTE VIII — MULTIPLATAFORMA EXTENDIDA
Sección 18 · KMP + Wear OS y tvOS
18.1 KMP para Wear OS
Wear OS es la plataforma de Google para relojes inteligentes. Aunque Compose for Wear OS es específico de Android, puedes compartir toda la lógica de negocio con KMP y añadir el módulo de Wear como target adicional.
⚠️ Estado actual 2026: Compose for Wear OS es un target Android separado, no un target KMP independiente. Compartes la lógica en commonMain o androidMain y la UI la haces en el módulo Wear con androidx.wear.compose.
Configuración del módulo Wear:
kotlin
// wearApp/build.gradle.ktsplugins { alias(libs.plugins.androidApplication) alias(libs.plugins.kotlinAndroid)}android { namespace = "com.miapp.wear" compileSdk = 35 defaultConfig { minSdk = 30 // Wear OS 3.0+ targetSdk = 35 }}dependencies { // Accede al módulo shared de KMP implementation(project(":shared")) // Compose for Wear OS implementation(libs.wear.compose.material) implementation(libs.wear.compose.foundation) implementation(libs.wear.compose.navigation) // Horologist: librería oficial de Google para Wear OS con Compose // Proporciona componentes optimizados: listas con scroll circular, etc. implementation(libs.horologist.compose.layout) implementation(libs.horologist.compose.material) implementation(libs.horologist.data.core) // Lifecycle y ViewModel para Wear implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)}
// wearApp/src/main/kotlin/com/miapp/wear/presentation/WearMainScreen.ktimport androidx.compose.foundation.layout.*import androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport androidx.wear.compose.material3.*import com.google.android.horologist.compose.layout.*import com.miapp.shared.presentation.PostsState // ← Clase del módulo KMP compartidoimport com.miapp.shared.presentation.PostsMviViewModel@Composablefun WearMainScreen(viewModel: PostsMviViewModel) { // Reusa el mismo ViewModel KMP en Wear OS val estado by viewModel.state.collectAsState() // ScalingLazyColumn: la lista circular optimizada para relojes AppScaffold { ScalingLazyColumn( modifier = Modifier.fillMaxSize(), columnState = rememberColumnState(), horizontalAlignment = Alignment.CenterHorizontally ) { item { ListHeader { Text("Posts", style = MaterialTheme.typography.titleSmall) } } when (val s = estado) { is PostsState.Cargando -> item { CircularProgressIndicator() } is PostsState.Exitoso -> items( count = s.posts.size.coerceAtMost(10) // Máximo 10 en reloj ) { index -> val post = s.posts[index] // Chip: el componente de lista principal en Wear OS Chip( label = { Text( post.title.take(30) + if (post.title.length > 30) "…" else "", style = MaterialTheme.typography.bodySmall ) }, onClick = { /* navegar */ }, modifier = Modifier.fillMaxWidth() ) } is PostsState.Error -> item { Text(s.mensaje, style = MaterialTheme.typography.bodySmall) } } } }}
Clave del patrón KMP + Wear:
El PostsMviViewModel está en commonMain. Lo mismo que usa la app Android y la app iOS lo usa el reloj. Cero duplicación de lógica.
18.2 KMP para tvOS
tvOS es el sistema operativo de Apple TV. Desde Compose Multiplatform 1.8.0, el soporte para tvOS incluye respuesta a los botones del Siri Remote (Seleccionar, Menú, Play/Pause).
Añadir tvOS como target en KMP:
kotlin
// shared/build.gradle.ktskotlin { androidTarget() // iOS iosX64(); iosArm64(); iosSimulatorArm64() // tvOS — añade estas líneas tvosX64() tvosArm64() tvosSimulatorArm64() sourceSets { // commonMain compartido por iOS y tvOS (tienen APIs similares) val appleMain by creating { dependsOn(commonMain.get()) } val iosMain by getting { dependsOn(appleMain) } val tvosMain by getting { dependsOn(appleMain) } // Dependencias específicas de tvOS tvosMain.dependencies { // Las mismas dependencias que iOS generalmente funcionan implementation(libs.ktor.client.darwin) } }}
Manejo del Siri Remote en tvOS:
kotlin
// shared/src/tvosMain/kotlin/platform/TvRemoteHandler.ktimport platform.TVUIKit.*import platform.UIKit.*// En tvOS, la navegación se basa en el foco (focus navigation)// El Siri Remote envía gestos y botones al sistema de foco de UIKitactual class PlatformRemoteHandler { // En CMP 1.8+, el soporte de Siri Remote está integrado en Compose // No necesitas código nativo para los casos básicos de navegación // Para casos avanzados, puedes añadir reconocedores de gestos fun configurarGestos(viewController: UIViewController) { // Swipe arriba val swipeUp = UISwipeGestureRecognizer( target = viewController, action = NSSelectorFromString("handleSwipeUp:") ).apply { direction = UISwipeGestureRecognizerDirectionUp } viewController.view.addGestureRecognizer(swipeUp) }}
App Compose para tvOS:
kotlin
// tvApp/src/tvosMain/kotlin/TvMainViewController.ktimport androidx.compose.foundation.layout.*import androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dpimport moe.tlaster.precompose.PreComposeApp// El EntryPoint para tvOS es el mismo que iOSfun TvMainViewController() = ComposeUIViewController { AppTheme { TvPantallaInicio() }}@Composablefun TvPantallaInicio() { // En tvOS conviene usar layouts horizontales con navegación por foco Row( modifier = Modifier .fillMaxSize() .padding(48.dp), // tvOS tiene márgenes más grandes (TV Safe Area) horizontalArrangement = Arrangement.spacedBy(32.dp) ) { // Sidebar de navegación Column( modifier = Modifier.width(300.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Text("Mi App TV", style = MaterialTheme.typography.headlineLarge) Spacer(Modifier.height(16.dp)) // Botones enfocables con el Siri Remote listOf("Inicio", "Posts", "Favoritos", "Configuración").forEach { item -> FilledTonalButton( onClick = { /* navegar */ }, modifier = Modifier.fillMaxWidth() ) { Text(item) } } } // Contenido principal Box(modifier = Modifier.weight(1f)) { Text("Contenido principal aquí") } }}
PARTE IX — KOTLIN EN EL NAVEGADOR
Sección 19 · Kotlin/Wasm — Kotlin en el navegador
¿Qué es Kotlin/Wasm?
Kotlin/Wasm compila tu código Kotlin a WebAssembly (Wasm), el estándar de bajo nivel del navegador que permite ejecutar código a velocidades cercanas al nativo. Desde Kotlin 2.2.20 el target wasmJs es Beta y Compose para Web (usando Wasm) alcanzó Beta en CMP 1.9.0.
Estado en 2026:
wasmJs → Beta (recomendado para proyectos nuevos)
js → sigue disponible como fallback para compatibilidad con navegadores más antiguos
CMP 1.9.0 introduce modo de compatibilidad JS como fallback automático para navegadores sin soporte Wasm GC
💡 Soporte de navegadores: WebAssembly GC (necesario para Kotlin/Wasm) lo soportan Chrome 119+, Firefox 120+, Safari 17.4+ y Edge 119+. En 2026 esto cubre >93% de los usuarios globales.
19.1 Configurar el target wasmJs
kotlin
// shared/build.gradle.ktskotlin { androidTarget() iosX64(); iosArm64(); iosSimulatorArm64() jvm("desktop") // Target Web con Wasm wasmJs { browser { commonWebpackConfig { devServer = KotlinWebpackConfig.DevServer( port = 8080, open = mapOf("app" to mapOf("name" to "google-chrome")) ) } } binaries.executable() // Genera un archivo ejecutable en el navegador } sourceSets { commonMain.dependencies { implementation(compose.runtime) implementation(compose.foundation) implementation(compose.material3) implementation(compose.ui) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) } // Código compartido entre JS y Wasm (web en general) val webMain by creating { dependsOn(commonMain.get()) } // En Kotlin 2.2.20+ hay un source set webMain compartido para js y wasmJs val wasmJsMain by getting { dependsOn(webMain) dependencies { implementation(libs.ktor.client.js) // Motor HTTP para Wasm/JS } } }}
19.2 Punto de entrada para la web
kotlin
// shared/src/wasmJsMain/kotlin/main.ktimport androidx.compose.ui.ExperimentalComposeUiApiimport androidx.compose.ui.window.CanvasBasedWindow// CanvasBasedWindow: renderiza Compose en un elemento <canvas> del HTML@OptIn(ExperimentalComposeUiApi::class)fun main() { CanvasBasedWindow( title = "Mi App KMP Web", canvasElementId = "composeCanvas" // ID del canvas en index.html ) { AppTheme { AppNavigation(viewModelFactory = WebViewModelFactory()) } }}
Kotlin/Wasm te permite llamar a APIs del navegador y librerías JavaScript:
kotlin
// Llamar APIs del DOM desde Kotlin/Wasmimport kotlinx.browser.documentimport kotlinx.browser.windowimport org.w3c.dom.*fun obtenerParametroUrl(nombre: String): String? { val urlParams = URLSearchParams(window.location.search) return urlParams.get(nombre)}fun copiarAlPortapapeles(texto: String) { window.navigator.clipboard.writeText(texto)}// Llamar a una función JavaScript externa@JsModule("./miLibreria.js")@JsNonModuleexternal fun funcionDeJavaScript(param: String): String// Exponer código Kotlin a JavaScript con @JsExport@JsExportfun funcionParaJavaScript(valor: Int): Int = valor * 2
kotlin
// Composable específico para Web con interop HTML// CMP 1.9.0 introduce WebElementView para incrustar HTML nativo en Compose// (Experimental en la versión actual)import androidx.compose.runtime.Composable@Composablefun MapaInteractivo(latitud: Double, longitud: Double) { // WebElementView permite incrustar un elemento HTML nativo en Compose Web // Es útil para mapas, videos, iframes, etc. // Nota: Solo disponible en el target wasmJs/js // En Android/iOS usarías WebView o MapKit nativo}
19.4 Diferencias específicas de la plataforma web
Algunas cosas son específicas de la web y requieren expect/actual:
kotlin
// commonMainexpect fun abrirUrl(url: String)expect fun compartir(titulo: String, texto: String, url: String)expect fun descargarArchivo(nombre: String, contenido: ByteArray)// wasmJsMain (implementación web)actual fun abrirUrl(url: String) { window.open(url, "_blank")}actual fun compartir(titulo: String, texto: String, url: String) { // Web Share API (disponible en móviles y Chrome de escritorio) val shareData = js("({ title: titulo, text: texto, url: url })") window.navigator.share(shareData)}actual fun descargarArchivo(nombre: String, contenido: ByteArray) { val blob = Blob(arrayOf(contenido.toJsArray())) val url = URL.createObjectURL(blob) val link = document.createElement("a") as HTMLAnchorElement link.href = url link.download = nombre link.click() URL.revokeObjectURL(url)}// androidMainactual fun abrirUrl(url: String) { // Usa el Intent del sistema Android val intent = android.content.Intent(android.content.Intent.ACTION_VIEW) intent.data = android.net.Uri.parse(url) // ...}// iosMainactual fun abrirUrl(url: String) { platform.UIKit.UIApplication.sharedApplication .openURL(platform.Foundation.NSURL(string = url))}
19.5 Construir y desplegar la app web
bash
# Desarrollo local con hot-reload./gradlew :shared:wasmJsBrowserDevelopmentRun# Build de producción (genera archivos estáticos)./gradlew :shared:wasmJsBrowserDistribution# Los archivos están en: shared/build/dist/wasmJs/productionExecutable/# Puedes subirlos a cualquier CDN (Netlify, Vercel, GitHub Pages, Cloudflare Pages)
Sección 20 · K2 Compiler y optimizaciones de rendimiento
20.1 ¿Qué es K2 y por qué importa?
El compilador K2 es la reescritura completa del compilador de Kotlin. Lleva siendo el predeterminado desde Kotlin 2.0.0 y en 2026 todos los proyectos nuevos lo usan. Sus ventajas principales:
K1 (viejo) K2 (nuevo, actual)
───────────────────── ──────────────────────────
Frontend separado por Frontend unificado para todas
target (JVM, JS, Native) las plataformas
~45s compilación grande ~22s compilación grande (2x más rápido)
Inferencia de tipos limitada Inferencia mejorada, más errores en compile-time
Sin soporte multi-módulo opt. Gradle Isolated Projects compatible
kapt para procesadores KSP2 (procesamiento nativo K2)
En Kotlin 2.3.0, las builds de Kotlin/Native release son hasta un 40% más rápidas gracias a un nuevo pase de inlining optimizado.
20.2 KSP2 — El reemplazante de kapt
kapt (Kotlin Annotation Processing Tool) era el sistema de procesamiento de anotaciones heredado de Java. KSP2 (Kotlin Symbol Processing 2) es su sucesor nativo para K2:
kotlin
// build.gradle.kts — Configurar KSP2 en lugar de kaptplugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.ksp) // com.google.devtools.ksp // NO uses: id("kotlin-kapt") ← DEPRECATED}kotlin { sourceSets { commonMain.dependencies { // Room KMP con KSP2 implementation(libs.room.runtime) implementation(libs.room.ktx) } }}// Configuración de KSP para cada targetdependencies { // KSP genera código para Room en cada plataforma add("kspAndroid", libs.room.compiler) add("kspIosArm64", libs.room.compiler) add("kspIosX64", libs.room.compiler) add("kspIosSimulatorArm64", libs.room.compiler) add("kspJvm", libs.room.compiler) // Para desktop}
⚠️ Importante: La versión de KSP siempre debe estar sincronizada con la versión de Kotlin. El formato es {kotlin_version}-{ksp_patch}. Por ejemplo, para Kotlin 2.3.0, usa KSP 2.3.0-1.0.31.
20.3 Room KMP — Base de datos oficial de Google en KMP
Desde Room 2.7.0+, la librería oficial de base de datos de Android funciona en KMP (Android, iOS, Desktop):
kotlin
// shared/src/commonMain/kotlin/data/local/AppDatabase.ktimport androidx.room.*import kotlinx.coroutines.flow.Flow// Entidad@Entity(tableName = "posts")data class PostEntity( @PrimaryKey val id: Int, val userId: Int, val title: String, val body: String, val guardadoEn: Long = System.currentTimeMillis())// DAO@Daointerface PostDao { @Query("SELECT * FROM posts ORDER BY guardadoEn DESC") fun observarTodos(): Flow<List<PostEntity>> // Flow para reactividad @Query("SELECT * FROM posts WHERE id = :id") suspend fun obtenerPorId(id: Int): PostEntity? @Upsert // INSERT OR REPLACE — más moderno que @Insert(onConflict = REPLACE) suspend fun guardarTodos(posts: List<PostEntity>) @Query("DELETE FROM posts") suspend fun eliminarTodos() @Transaction suspend fun reemplazarTodos(posts: List<PostEntity>) { eliminarTodos() guardarTodos(posts) }}// Base de datos@Database( entities = [PostEntity::class], version = 1, exportSchema = true // Exporta el esquema para migraciones)@TypeConverters(Converters::class)abstract class AppDatabase : RoomDatabase() { abstract fun postDao(): PostDao}// Converters para tipos no soportados por SQLiteclass Converters { @TypeConverter fun listAString(lista: List<String>): String = lista.joinToString(",") @TypeConverter fun stringAList(valor: String): List<String> = if (valor.isEmpty()) emptyList() else valor.split(",")}
kotlin
// Factory de la base de datos — expect/actual para cada plataforma// commonMainexpect fun crearBaseDeDatos(): AppDatabase// androidMainactual fun crearBaseDeDatos(): AppDatabase { return Room.databaseBuilder( context = applicationContext, klass = AppDatabase::class, name = "miapp.db" ).build()}// iosMainactual fun crearBaseDeDatos(): AppDatabase { val dbPath = NSFileManager.defaultManager.URLForDirectory( directory = NSDocumentDirectory, inDomain = NSUserDomainMask, appropriateForURL = null, create = true, error = null )!!.path + "/miapp.db" return Room.databaseBuilder<AppDatabase>( name = dbPath ).build()}
Desde ViewModel 2.9.4, el ViewModel de Android funciona en KMP:
kotlin
// commonMain — usa ViewModel directamente, sin wrappers propiosimport androidx.lifecycle.ViewModelimport androidx.lifecycle.viewModelScope // CoroutineScope automáticoclass PostsViewModel( private val useCase: ObtenerPostsUseCase) : ViewModel() { // viewModelScope se cancela automáticamente cuando el ViewModel se destruye // Funciona igual en Android, iOS y Desktop private val _state = MutableStateFlow<PostsUiState>(PostsUiState.Cargando) val state: StateFlow<PostsUiState> = _state.asStateFlow() init { cargarPosts() } fun cargarPosts() { viewModelScope.launch { _state.value = PostsUiState.Cargando useCase() .onSuccess { _state.value = PostsUiState.Exitoso(it) } .onFailure { _state.value = PostsUiState.Error(it.message ?: "Error") } } } // onCleared() se llama automáticamente cuando el ViewModel se destruye override fun onCleared() { super.onCleared() // Limpieza si necesaria }}
kotlin
// Integración con Koin 4.x para inyección de ViewModel en KMPimport org.koin.core.module.dsl.viewModel // API moderna de Koin 4import org.koin.dsl.moduleval viewModelModule = module { viewModel { PostsViewModel(get()) } viewModel { params -> DetalleViewModel(params.get(), get()) }}
20.5 Gradle Isolated Projects — El futuro de builds ultrarrápidas
Gradle Isolated Projects es una feature experimental de Gradle que permite builds en paralelo masivo. Kotlin 2.1.20+ es compatible:
kotlin
// gradle.properties — Activar optimizaciones de build# K2 Compiler (predeterminado desde Kotlin 2.0, pero puedes forzarlo)kotlin.incremental=truekotlin.incremental.multiplatform=true# Compilación incremental de Kotlin/Nativekotlin.incremental.native=true# Gradle Isolated Projects (experimental, pero muy impactante en proyectos grandes)org.gradle.unsafe.isolated-projects=true# Build cache (reutiliza resultados de compilación entre builds)org.gradle.caching=true# JVM para Gradle daemon con más memoriaorg.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=1g -XX:+UseParallelGC# Kotlin Daemonkotlin.daemon.jvm.options=-Xmx3g -Xms512m
PARTE XI — SEGURIDAD
Sección 21 · Seguridad: cifrado, certificate pinning y ofuscación
La seguridad en aplicaciones móviles multiplataforma tiene varias capas. Esta sección cubre las más importantes de forma práctica.
21.1 Ktor 3.1.1+: El parche de seguridad crítico
En octubre de 2025 se publicó CVE-2025-29904, una vulnerabilidad de HTTP Request Smuggling en Ktor antes de la versión 3.1.1. La versión actual (3.3.0) ya incluye el fix.
Acción requerida: Si tu proyecto usa Ktor < 3.1.1, actualiza inmediatamente.
toml
# gradle/libs.versions.toml — Asegúrate de usar 3.1.1+[versions]ktor = "3.3.0" # Nunca uses < 3.1.1
21.2 Certificate Pinning en Ktor KMP
El certificate pinning protege contra ataques Man-in-the-Middle forzando al cliente a aceptar solo certificados específicos de tu servidor:
kotlin
// shared/src/androidMain/kotlin/network/SecureHttpClient.android.ktimport io.ktor.client.*import io.ktor.client.engine.okhttp.*import okhttp3.CertificatePinnerimport okhttp3.OkHttpClientimport java.util.concurrent.TimeUnitactual fun crearHttpClientSeguro(config: SecurityConfig): HttpClient { // OkHttp tiene soporte nativo para certificate pinning val certificatePinner = CertificatePinner.Builder() .add( config.host, // SHA-256 del Subject Public Key Info (SPKI) del certificado // Obtén estos hashes así: openssl s_client -connect tudominio.com:443 | // openssl x509 -pubkey -noout | // openssl rsa -pubin -outform DER | // openssl dgst -sha256 -binary | base64 "sha256/${config.pinPrincipal}", "sha256/${config.pinBackup}" // Siempre incluye un backup pin ) .build() val okHttpClient = OkHttpClient.Builder() .certificatePinner(certificatePinner) .connectTimeout(10, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) .build() return HttpClient(OkHttp) { engine { preconfigured = okHttpClient } configurarPluginsComunes() }}
kotlin
// shared/src/iosMain/kotlin/network/SecureHttpClient.ios.ktimport io.ktor.client.*import io.ktor.client.engine.darwin.*import platform.Foundation.*actual fun crearHttpClientSeguro(config: SecurityConfig): HttpClient { return HttpClient(Darwin) { engine { // Darwin (NSURLSession) usa el sistema de certificados de iOS // Para pinning, implementamos el challenge handler handleChallenge { session, task, challenge, completionHandler -> val serverTrust = challenge.protectionSpace.serverTrust ?: return@handleChallenge completionHandler( NSURLSessionAuthChallengeCancelAuthenticationChallenge, null ) // Validar el certificado manualmente val certificadoValido = validarCertificado( serverTrust = serverTrust, host = challenge.protectionSpace.host, pinsEsperados = listOf(config.pinPrincipal, config.pinBackup) ) if (certificadoValido) { val credencial = NSURLCredential.credentialForTrust(serverTrust) completionHandler(NSURLSessionAuthChallengeUseCredential, credencial) } else { completionHandler(NSURLSessionAuthChallengeCancelAuthenticationChallenge, null) } } } configurarPluginsComunes() }}private fun validarCertificado( serverTrust: SecTrustRef, host: String, pinsEsperados: List<String>): Boolean { // Extraer la clave pública del certificado del servidor val certificado = SecTrustGetCertificateAtIndex(serverTrust, 0) ?: return false val clavePublica = SecCertificateCopyKey(certificado) ?: return false val datosClavePublica = SecKeyCopyExternalRepresentation(clavePublica, null) ?: return false // Calcular SHA-256 y comparar con los pins val hashBytes = (datosClavePublica as NSData).toByteArray() val hashBase64 = calcularSHA256Base64(hashBytes) return hashBase64 in pinsEsperados}
kotlin
// commonMain — Modelo de configuración de seguridaddata class SecurityConfig( val host: String, val pinPrincipal: String, // Hash SHA-256 del certificado principal val pinBackup: String // Hash SHA-256 del certificado de backup)// NUNCA hardcodees los pines en el código fuente directamente// Cárgalos desde un archivo de configuración cifrado o variables de entorno// En desarrollo/debug, puedes desactivar el pinning con un flag:val securityConfig = if (BuildConfig.DEBUG) { SecurityConfig("api.miapp.com", "", "") // Sin pinning en debug} else { SecurityConfig( host = "api.miapp.com", pinPrincipal = BuildConfig.CERT_PIN_PRIMARY, // Valor de secrets.properties pinBackup = BuildConfig.CERT_PIN_BACKUP )}
21.3 Almacenamiento seguro con multiplatform-settings
Para guardar tokens, preferencias sensibles y credenciales de forma segura en todas las plataformas:
// shared/src/commonMain/kotlin/data/storage/SecureTokenStore.ktimport com.russhwolf.settings.Settingsimport com.russhwolf.settings.coroutines.FlowSettingsimport com.russhwolf.settings.coroutines.toFlowSettingsimport kotlinx.coroutines.flow.Flow// Interfaz para el almacenamiento de tokensinterface TokenStore { fun guardarAccessToken(token: String) fun obtenerAccessToken(): String? fun guardarRefreshToken(token: String) fun obtenerRefreshToken(): String? fun observarToken(): Flow<String?> // Reactivo fun limpiar()}class TokenStoreImpl( private val settings: Settings, // Inyectado: SecureSettings en prod, normal en tests) : TokenStore { companion object { private const val KEY_ACCESS_TOKEN = "access_token" private const val KEY_REFRESH_TOKEN = "refresh_token" } private val flowSettings: FlowSettings = settings.toFlowSettings() override fun guardarAccessToken(token: String) { settings.putString(KEY_ACCESS_TOKEN, token) } override fun obtenerAccessToken(): String? = settings.getStringOrNull(KEY_ACCESS_TOKEN) override fun guardarRefreshToken(token: String) { settings.putString(KEY_REFRESH_TOKEN, token) } override fun obtenerRefreshToken(): String? = settings.getStringOrNull(KEY_REFRESH_TOKEN) // Flow que emite cuando el token cambia override fun observarToken(): Flow<String?> = flowSettings.getStringOrNullFlow(KEY_ACCESS_TOKEN) override fun limpiar() { settings.remove(KEY_ACCESS_TOKEN) settings.remove(KEY_REFRESH_TOKEN) }}
kotlin
// androidMain — Usa EncryptedSharedPreferences (AES-256-GCM)import android.content.Contextimport androidx.security.crypto.EncryptedSharedPreferencesimport androidx.security.crypto.MasterKeyimport com.russhwolf.settings.SharedPreferencesSettingsactual fun crearSettingsSeguro(context: Context): Settings { val masterKey = MasterKey.Builder(context) .setKeyScheme(MasterKey.KeyScheme.AES256_GCM) // Cifrado AES-256-GCM .build() val sharedPreferences = EncryptedSharedPreferences.create( context, "secure_prefs", masterKey, EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM ) return SharedPreferencesSettings(sharedPreferences)}
kotlin
// iosMain — Usa el Keychain del sistema (el lugar más seguro en iOS)import com.russhwolf.settings.KeychainSettingsactual fun crearSettingsSeguro(): Settings { // KeychainSettings almacena en el Keychain de iOS/macOS // Es el equivalente al llavero de tu Mac — accesible solo por tu app return KeychainSettings(service = "com.miapp.secure")}
21.4 Cifrado de datos con expect/actual
Para cifrar datos en tránsito o en reposo más allá de simples key-value:
kotlin
// commonMaininterface CifradoService { fun cifrar(datos: ByteArray, clave: ByteArray): ByteArray fun descifrar(datosCifrados: ByteArray, clave: ByteArray): ByteArray fun generarClave(): ByteArray // Genera clave AES-256 aleatoria}// androidMain — AES-256-GCM via JCA (Java Cryptography Architecture)import javax.crypto.Cipherimport javax.crypto.KeyGeneratorimport javax.crypto.spec.GCMParameterSpecimport javax.crypto.spec.SecretKeySpecclass CifradoServiceAndroid : CifradoService { companion object { private const val ALGORITMO = "AES/GCM/NoPadding" private const val TAG_LENGTH_BIT = 128 private const val IV_LENGTH_BYTE = 12 } override fun cifrar(datos: ByteArray, clave: ByteArray): ByteArray { val cipher = Cipher.getInstance(ALGORITMO) val iv = ByteArray(IV_LENGTH_BYTE).also { java.security.SecureRandom().nextBytes(it) } cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(clave, "AES"), GCMParameterSpec(TAG_LENGTH_BIT, iv)) val datosCifrados = cipher.doFinal(datos) // Devuelve IV + datos cifrados concatenados return iv + datosCifrados } override fun descifrar(datosCifrados: ByteArray, clave: ByteArray): ByteArray { val cipher = Cipher.getInstance(ALGORITMO) val iv = datosCifrados.sliceArray(0 until IV_LENGTH_BYTE) val datos = datosCifrados.sliceArray(IV_LENGTH_BYTE until datosCifrados.size) cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(clave, "AES"), GCMParameterSpec(TAG_LENGTH_BIT, iv)) return cipher.doFinal(datos) } override fun generarClave(): ByteArray { val keyGen = KeyGenerator.getInstance("AES") keyGen.init(256, java.security.SecureRandom()) return keyGen.generateKey().encoded }}
kotlin
// iosMain — AES-256-GCM via CommonCrypto (nativo iOS/macOS)import platform.CoreCrypto.*import platform.Foundation.*import kotlin.experimental.ExperimentalNativeApiclass CifradoServiceIos : CifradoService { override fun cifrar(datos: ByteArray, clave: ByteArray): ByteArray { val iv = ByteArray(12).also { SecRandomCopyBytes(kSecRandomDefault, 12, it.refTo(0)) } // ... implementación con CCCrypt o CryptoKit via cinterop return iv + cifrarConAesGcm(datos, clave, iv) } override fun descifrar(datosCifrados: ByteArray, clave: ByteArray): ByteArray { val iv = datosCifrados.sliceArray(0 until 12) val datos = datosCifrados.sliceArray(12 until datosCifrados.size) return descifrarConAesGcm(datos, clave, iv) } override fun generarClave(): ByteArray { return ByteArray(32).also { SecRandomCopyBytes(kSecRandomDefault, 32, it.refTo(0)) } }}
21.5 Stack canaries en Kotlin/Native 2.2.20+
Desde Kotlin 2.2.20, puedes activar stack canaries en los binarios iOS/Native para proteger contra ataques de stack overflow:
22.2 Convention Plugins — La clave de la escalabilidad
Los Convention Plugins son plugins de Gradle personalizados que encapsulan la configuración repetida. En lugar de copiar 100 líneas de Gradle en cada módulo, defines la convención una vez y la aplicas:
// build-logic/convention/src/main/kotlin/KmpFeatureConventionPlugin.kt// Plugin específico para módulos de feature — extiende el de libreríaclass KmpFeatureConventionPlugin : Plugin<Project> { override fun apply(target: Project) = with(target) { // Aplica primero el plugin base de librería pluginManager.apply(KmpLibraryConventionPlugin::class) extensions.configure<KotlinMultiplatformExtension> { sourceSets { commonMain.dependencies { // Todos los features dependen de los módulos core implementation(project(":core:core-ui")) implementation(project(":core:core-network")) implementation(libs.findLibrary("koin-core").get()) implementation(libs.findLibrary("androidx-lifecycle-viewmodel").get()) implementation(libs.findLibrary("androidx-navigation-compose").get()) } } } }}
kotlin
// build-logic/convention/build.gradle.ktsplugins { `kotlin-dsl` // Permite escribir plugins en Kotlin DSL}group = "com.miapp.buildlogic"dependencies { // Los plugins de Gradle deben estar disponibles aquí compileOnly(libs.android.gradlePlugin) compileOnly(libs.kotlin.gradlePlugin) compileOnly(libs.compose.gradlePlugin)}// Registra todos los pluginsgradlePlugin { plugins { register("kmpLibrary") { id = "miapp.kmp.library" implementationClass = "KmpLibraryConventionPlugin" } register("kmpFeature") { id = "miapp.kmp.feature" implementationClass = "KmpFeatureConventionPlugin" } register("androidApp") { id = "miapp.android.app" implementationClass = "AndroidAppConventionPlugin" } }}
Uso en los módulos — así de limpio queda:
kotlin
// features/feature-posts/build.gradle.kts// En lugar de 80 líneas de configuración, solo esto:plugins { id("miapp.kmp.feature") // ← Todo encapsulado en el convention plugin}kotlin { sourceSets { commonMain.dependencies { // Solo las dependencias específicas de este feature implementation(project(":core:core-database")) } }}
22.3 Catálogo de versiones (libs.versions.toml) completo
toml
22.4 settings.gradle.kts para monorepo
kotlin
// settings.gradle.kts (raíz del monorepo)// Nombre del proyecto raízrootProject.name = "MiAppMonorepo"// Incluye el módulo de build logic primeropluginManagement { includeBuild("build-logic") // Los convention plugins repositories { google { content { includeGroupByRegex("com\\.android.*") includeGroupByRegex("com\\.google.*") includeGroupByRegex("androidx.*") } } mavenCentral() gradlePluginPortal() }}dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) repositories { google() mavenCentral() } // Vincula el catálogo de versiones versionCatalogs { create("libs") { from(files("gradle/libs.versions.toml")) } }}// Appsinclude(":apps:androidApp")include(":apps:iosApp") // No suele estar en Gradle, es un proyecto Xcodeinclude(":apps:desktopApp")include(":apps:webApp")// Featuresinclude(":features:feature-auth")include(":features:feature-posts")include(":features:feature-profile")include(":features:feature-settings")// Coreinclude(":core:core-network")include(":core:core-database")include(":core:core-ui")include(":core:core-testing")include(":core:core-analytics")
22.5 Testing avanzado con Turbine
Turbine de Cash App es la librería estándar para testear Flows en KMP. Hace el testing de Flows tan sencillo como el testing normal:
kotlin
// Añadir a commonTest// testImplementation("app.cash.turbine:turbine:1.2.0")import app.cash.turbine.testimport app.cash.turbine.turbineScopeimport kotlinx.coroutines.test.runTestimport kotlin.test.*class PostsViewModelTest { private val fakeRepo = FakePostRepository() private val viewModel by lazy { PostsViewModel(ObtenerPostsUseCase(fakeRepo)) } @Test fun `estado inicial es Cargando y luego Exitoso`() = runTest { viewModel.state.test { // awaitItem() espera el siguiente valor emitido assertEquals(PostsUiState.Cargando, awaitItem()) assertEquals(PostsUiState.Exitoso(fakeRepo.posts), awaitItem()) // cancelAndIgnoreRemainingEvents() cancela la colección limpiamente cancelAndIgnoreRemainingEvents() } } @Test fun `cuando la red falla, emite estado Error`() = runTest { fakeRepo.deberiaFallar = true viewModel.state.test { awaitItem() // Cargando val estadoError = awaitItem() assertIs<PostsUiState.Error>(estadoError) assertTrue(estadoError.mensaje.isNotEmpty()) cancelAndIgnoreRemainingEvents() } } @Test fun `múltiples flows en paralelo con turbineScope`() = runTest { turbineScope { val flow1 = viewModel.state.testIn(backgroundScope) val flow2 = viewModel.eventos.testIn(backgroundScope) assertEquals(PostsUiState.Cargando, flow1.awaitItem()) // Verificar ambos flows en el mismo test cancelAndIgnoreRemainingEvents() } }}
22.6 Dokka — Documentación automática para librerías KMP
Si publicas módulos de tu monorepo como librerías:
kotlin
// build-logic/convention/src/main/kotlin/KmpLibraryPlugin.kt// Añadir al convention plugin:pluginManager.apply("org.jetbrains.dokka")// La documentación se genera con:// ./gradlew dokkaHtml// Resultado en: build/dokka/html/index.html
Tutorial escrito con Kotlin 2.1, KMP 1.x estable y el ecosistema de 2026. Si encuentras algo desactualizado o quieres que profundice en alguna sección, deja un comentario. ¡Happy coding! 🎉
data class Producto(val nombre: String, val precio: Double, val categoria: String)fun main() { val productos = listOf( Producto("Laptop", 999.99, "Electrónica"), Producto("Teclado", 79.99, "Electrónica"), Producto("Libro Kotlin", 35.00, "Libros"), Producto("Auriculares", 149.99, "Electrónica"), Producto("Libro Clean Code", 28.00, "Libros"), Producto("Ratón", 45.00, "Electrónica") ) // filter: filtra elementos que cumplen una condición val electrónica = productos.filter { it.categoria == "Electrónica" } println("Electrónica: ${electrónica.size} productos") // 4 // map: transforma cada elemento val nombres = productos.map { it.nombre } println(nombres) // [Laptop, Teclado, Libro Kotlin, ...] // map + filter encadenados val nombresCaros = productos .filter { it.precio > 100 } .map { it.nombre } println(nombresCaros) // [Laptop, Auriculares] // sortedBy / sortedByDescending val ordenados = productos.sortedBy { it.precio } println(ordenados.first().nombre) // "Libro Clean Code" (más barato) // groupBy: agrupa en un Map val porCategoria = productos.groupBy { it.categoria } porCategoria.forEach { (categoria, items) -> println("$categoria: ${items.size} productos") } // Electrónica: 4 productos // Libros: 2 productos // sumOf / maxOf / minOf / averageOf val precioTotal = productos.sumOf { it.precio } val masBarato = productos.minByOrNull { it.precio } val precioPromedio = productos.map { it.precio }.average() println("Total: $precioTotal") println("Más barato: ${masBarato?.nombre}") println("Promedio: $precioPromedio") // any / all / none: comprobaciones booleanas println(productos.any { it.precio > 1000 }) // false (ninguno > 1000) println(productos.all { it.precio > 0 }) // true println(productos.none { it.nombre.isEmpty() }) // true // find / firstOrNull: encontrar el primero val primerLibro = productos.find { it.categoria == "Libros" } println(primerLibro?.nombre) // "Libro Kotlin" // flatMap: aplana una lista de listas val etiquetas = listOf( listOf("kotlin", "jvm"), listOf("android", "mobile"), listOf("backend", "jvm") ) val todasEtiquetas = etiquetas.flatten() // o flatMap { it } println(todasEtiquetas.distinct()) // sin duplicados: [kotlin, jvm, android, mobile, backend] // reduce / fold: acumular valores val suma = listOf(1, 2, 3, 4, 5).reduce { acc, elemento -> acc + elemento } println(suma) // 15 // fold con valor inicial val texto = listOf("Kotlin", "es", "genial").fold("") { acc, palabra -> if (acc.isEmpty()) palabra else "$acc $palabra" } println(texto) // "Kotlin es genial" // partition: divide en dos listas (los que cumplen y los que no) val (caros, baratos) = productos.partition { it.precio > 100 } println("Caros: ${caros.size}, Baratos: ${baratos.size}")}
// shared/src/commonMain/kotlin/ui/components/PostCard.ktimport androidx.compose.foundation.layout.*import androidx.compose.foundation.shape.RoundedCornerShapeimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.text.font.FontWeightimport androidx.compose.ui.text.style.TextOverflowimport androidx.compose.ui.unit.dpimport androidx.compose.ui.unit.sp// Este composable funciona igual en Android, iOS y Desktop@Composablefun PostCard( post: Post, onClick: () -> Unit, modifier: Modifier = Modifier) { Card( onClick = onClick, modifier = modifier.fillMaxWidth(), shape = RoundedCornerShape(12.dp), colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surfaceVariant ), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp) ) { Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp) ) { Row( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { // Badge con el ID del post Surface( color = MaterialTheme.colorScheme.primaryContainer, shape = RoundedCornerShape(20.dp) ) { Text( text = "#${post.id}", modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onPrimaryContainer, fontWeight = FontWeight.Bold ) } } Text( text = post.title.replaceFirstChar { it.uppercase() }, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis ) Text( text = post.body, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 3, overflow = TextOverflow.Ellipsis, lineHeight = 18.sp ) } }}
// shared/src/commonMain/kotlin/ui/screens/PostsScreen.ktimport androidx.compose.animation.*import androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material.icons.Iconsimport androidx.compose.material.icons.filled.Refreshimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Alignmentimport androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dp@OptIn(ExperimentalMaterial3Api::class)@Composablefun PostsScreen( viewModel: PostViewModel, onPostClick: (Int) -> Unit) { // Observamos el StateFlow del ViewModel val estado by viewModel.uiState.collectAsState() // LaunchedEffect: ejecuta código cuando entra en composición LaunchedEffect(Unit) { viewModel.cargarPosts() } Scaffold( topBar = { TopAppBar( title = { Text("Posts") }, actions = { IconButton(onClick = { viewModel.cargarPosts() }) { Icon(Icons.Default.Refresh, contentDescription = "Recargar") } }, colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.primaryContainer ) ) } ) { paddingValues -> Box( modifier = Modifier .fillMaxSize() .padding(paddingValues), contentAlignment = Alignment.Center ) { // AnimatedContent: anima la transición entre estados AnimatedContent( targetState = estado, transitionSpec = { fadeIn() togetherWith fadeOut() } ) { estadoActual -> when (estadoActual) { is PostsUiState.Cargando -> { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp) ) { CircularProgressIndicator( color = MaterialTheme.colorScheme.primary ) Text( "Cargando posts...", style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } } is PostsUiState.Error -> { ErrorContent( mensaje = estadoActual.mensaje, onReintentar = { viewModel.cargarPosts() } ) } is PostsUiState.Exitoso -> { ListaPosts( posts = estadoActual.posts, onPostClick = onPostClick ) } } } } }}@Composableprivate fun ListaPosts( posts: List<Post>, onPostClick: (Int) -> Unit) { LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp) ) { // Header item { Text( text = "${posts.size} posts encontrados", style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.primary ) } // Lista de posts items( items = posts, key = { it.id } // key mejora el rendimiento de LazyColumn ) { post -> PostCard( post = post, onClick = { onPostClick(post.id) } ) } // Footer con espacio item { Spacer(modifier = Modifier.height(16.dp)) } }}@Composableprivate fun ErrorContent( mensaje: String, onReintentar: () -> Unit) { Column( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(32.dp) ) { Text( text = "😕", style = MaterialTheme.typography.displayLarge ) Text( text = "Algo salió mal", style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.error ) Text( text = mensaje, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) Button(onClick = onReintentar) { Text("Reintentar") } }}
// src/main/kotlin/com/miapi/data/UserRepository.ktimport kotlinx.serialization.Serializableimport org.jetbrains.exposed.sql.*import org.jetbrains.exposed.sql.SqlExpressionBuilder.eqimport kotlinx.datetime.Clock@Serializabledata class UserDto(val id: Int, val nombre: String, val email: String)@Serializabledata class CreateUserRequest(val nombre: String, val email: String, val password: String)interface UserRepository { suspend fun obtenerTodos(): List<UserDto> suspend fun obtenerPorId(id: Int): UserDto? suspend fun obtenerPorEmail(email: String): UserDto? suspend fun crear(request: CreateUserRequest): UserDto suspend fun actualizar(id: Int, nombre: String): UserDto? suspend fun eliminar(id: Int): Boolean}class UserRepositoryImpl : UserRepository { // Convierte una fila de la DB a un DTO private fun resultRowToUser(row: ResultRow) = UserDto( id = row[Users.id], nombre = row[Users.nombre], email = row[Users.email] ) override suspend fun obtenerTodos(): List<UserDto> = DatabaseFactory.dbQuery { Users.selectAll() .where { Users.activo eq true } .orderBy(Users.creadoEn, SortOrder.DESC) .map(::resultRowToUser) } override suspend fun obtenerPorId(id: Int): UserDto? = DatabaseFactory.dbQuery { Users.selectAll() .where { Users.id eq id } .map(::resultRowToUser) .singleOrNull() } override suspend fun obtenerPorEmail(email: String): UserDto? = DatabaseFactory.dbQuery { Users.selectAll() .where { Users.email eq email } .map(::resultRowToUser) .singleOrNull() } override suspend fun crear(request: CreateUserRequest): UserDto = DatabaseFactory.dbQuery { val passwordHash = BCrypt.hashpw(request.password, BCrypt.gensalt()) val id = Users.insertAndGetId { it[nombre] = request.nombre it[email] = request.email it[passwordHash] = passwordHash it[creadoEn] = Clock.System.now() } Users.selectAll() .where { Users.id eq id } .map(::resultRowToUser) .single() } override suspend fun actualizar(id: Int, nombre: String): UserDto? = DatabaseFactory.dbQuery { Users.update({ Users.id eq id }) { it[Users.nombre] = nombre } obtenerPorId(id) } override suspend fun eliminar(id: Int): Boolean = DatabaseFactory.dbQuery { // Soft delete: marcamos como inactivo en lugar de borrar Users.update({ Users.id eq id }) { it[activo] = false } > 0 }}
// src/main/kotlin/com/miapi/routes/UserRoutes.ktimport io.ktor.http.*import io.ktor.server.application.*import io.ktor.server.auth.*import io.ktor.server.auth.jwt.*import io.ktor.server.request.*import io.ktor.server.response.*import io.ktor.server.routing.*import kotlinx.serialization.Serializable@Serializabledata class PaginatedResponse<T>( val datos: List<T>, val total: Int, val pagina: Int, val porPagina: Int)fun Route.userRoutes(userRepository: UserRepository) { route("/api/v1/users") { // GET /api/v1/users — Listar todos (público) get { val pagina = call.request.queryParameters["pagina"]?.toIntOrNull() ?: 1 val porPagina = call.request.queryParameters["porPagina"]?.toIntOrNull() ?: 20 val usuarios = userRepository.obtenerTodos() val paginados = usuarios.drop((pagina - 1) * porPagina).take(porPagina) call.respond( PaginatedResponse( datos = paginados, total = usuarios.size, pagina = pagina, porPagina = porPagina ) ) } // GET /api/v1/users/{id} — Obtener uno (público) get("{id}") { val id = call.parameters["id"]?.toIntOrNull() ?: throw ValidacionException("ID inválido") val usuario = userRepository.obtenerPorId(id) ?: throw RecursoNoEncontradoException("Usuario con ID $id no encontrado") call.respond(usuario) } // POST /api/v1/users — Crear usuario (público) post { val request = call.receive<CreateUserRequest>() // Validaciones if (request.nombre.isBlank()) throw ValidacionException("El nombre es obligatorio") if (!request.email.contains("@")) throw ValidacionException("Email inválido") if (request.password.length < 8) throw ValidacionException("Password mínimo 8 caracteres") // Verificar si el email ya existe if (userRepository.obtenerPorEmail(request.email) != null) { throw ValidacionException("El email ya está en uso") } val nuevoUsuario = userRepository.crear(request) call.respond(HttpStatusCode.Created, nuevoUsuario) } // Rutas protegidas con JWT authenticate("auth-jwt") { // PUT /api/v1/users/{id} — Actualizar (autenticado) put("{id}") { val principal = call.principal<JWTPrincipal>() val tokenUserId = principal?.payload?.getClaim("userId")?.asString()?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull() ?: throw ValidacionException("ID inválido") // Solo puede editar su propio perfil if (tokenUserId != id) throw NoAutorizadoException() @Serializable data class UpdateRequest(val nombre: String) val request = call.receive<UpdateRequest>() val actualizado = userRepository.actualizar(id, request.nombre) ?: throw RecursoNoEncontradoException("Usuario no encontrado") call.respond(actualizado) } // DELETE /api/v1/users/{id} — Eliminar (autenticado) delete("{id}") { val principal = call.principal<JWTPrincipal>() val tokenUserId = principal?.payload?.getClaim("userId")?.asString()?.toIntOrNull() val id = call.parameters["id"]?.toIntOrNull() ?: throw ValidacionException("ID inválido") if (tokenUserId != id) throw NoAutorizadoException() val eliminado = userRepository.eliminar(id) if (!eliminado) throw RecursoNoEncontradoException("Usuario no encontrado") call.respond(HttpStatusCode.NoContent) } } }}
// shared/src/commonMain/kotlin/presentation/posts/PostsMviViewModel.ktclass PostsMviViewModel( private val obtenerPostsUseCase: ObtenerPostsUseCase, scope: CoroutineScope) : MviViewModel<PostsState, PostsIntent, PostsEvent>( initialState = PostsState(), scope = scope) { init { // Carga inicial automática onIntent(PostsIntent.CargarPosts) } override suspend fun processIntent(intent: PostsIntent) { when (intent) { is PostsIntent.CargarPosts -> cargarPosts() is PostsIntent.Recargar -> { updateState { copy(paginaActual = 1, posts = emptyList()) } cargarPosts() emitEvent(PostsEvent.ScrollToTop) } is PostsIntent.CargarMasPosts -> { if (!state.value.estaCargando && state.value.hasMasPaginas) { cargarMasPosts() } } is PostsIntent.BuscarPosts -> { updateState { copy(busqueda = intent.query) } } is PostsIntent.ClickPost -> { emitEvent(PostsEvent.NavegateToDetail(intent.postId)) } is PostsIntent.EliminarPost -> eliminarPost(intent.postId) } } private suspend fun cargarPosts() { updateState { copy(estaCargando = true, error = null) } obtenerPostsUseCase() .onSuccess { posts -> updateState { copy( posts = posts, estaCargando = false, hasMasPaginas = posts.size >= 20 ) } } .onFailure { error -> updateState { copy( estaCargando = false, error = error.message ?: "Error desconocido" ) } emitEvent(PostsEvent.MostrarSnackbar("Error al cargar posts")) } } private suspend fun cargarMasPosts() { updateState { copy(estaCargando = true) } val siguientePagina = state.value.paginaActual + 1 // ... lógica de paginación } private suspend fun eliminarPost(id: Int) { // Optimistic update: eliminamos de la UI antes de confirmar val postOriginal = state.value.posts.find { it.id == id } updateState { copy(posts = posts.filter { it.id != id }) } // Aquí iría la llamada al repositorio // Si falla, restauramos val fallo = false // simula resultado if (fallo && postOriginal != null) { updateState { copy(posts = posts + postOriginal) } emitEvent(PostsEvent.MostrarSnackbar("No se pudo eliminar el post")) } }}