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):
En Windows:
Descarga el instalador desde adoptium.net → elige Temurin 21 LTS.
En Linux (Debian/Ubuntu):
Verifica la instalación:
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í:
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
💡 ¿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)
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:
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:
¿Cómo trabajar con valores nulables?
🔑 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:
3.5 Condicionales: if, when
En Kotlin, if y when son expresiones (devuelven un valor), no solo sentencias:
3.6 Bucles: for, while, repeat
3.7 Funciones
Las funciones en Kotlin son ciudadanos de primera clase y tienen muchas características potentes:
3.8 Tipos de retorno: Unit y Nothing
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
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:
💡 ¿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:
4.4 Interfaces
Las interfaces en Kotlin son más potentes que en Java: pueden tener implementaciones por defecto:
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:
🔑 ¿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
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:
💡 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
5.2 Colecciones: List, Set, Map
Kotlin distingue entre colecciones inmutables (solo lectura) y mutables:
5.3 Operaciones sobre colecciones
Kotlin tiene una API de colecciones increíblemente rica. Estas son las operaciones más usadas:
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:
💡 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:
6.2 Conceptos clave
Añade la dependencia en build.gradle.kts:
6.3 launch vs async / await
6.4 Coroutine Scope y Context
Los Dispatchers determinan en qué hilo o pool de hilos corre la coroutine:
6.5 Flow — Streams reactivos con Coroutines
Flow es la respuesta de Kotlin a los streams reactivos (similar a RxJava pero más simple):
6.6 Manejo de errores en Coroutines
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:
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
8.3 Un repositorio KMP completo paso a paso
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:
9.2 Serialización: kotlinx.serialization
9.3 Base de datos: SQLDelight 2.x
SQLDelight genera Kotlin type-safe code a partir de queries SQL:
9.4 Inyección de dependencias: Koin
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
10.2 Testing en KMP
10.3 CI/CD con GitHub Actions
10.4 Consejos finales para el nivel experto
Rendimiento:
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:
11.2 Tu primer Composable compartido
11.3 Sistema de diseño: Tema compartido
Crear un tema personalizado es fundamental para tener coherencia visual entre plataformas:
11.4 Pantalla completa con LazyColumn y estados
11.5 Navegación con Navigation Compose Multiplatform
En 2026, androidx.navigation:navigation-composeya es multiplataforma (soporta Android, iOS y Desktop):
11.6 Recursos compartidos: imágenes, fuentes y strings
Con compose.components.resources puedes compartir assets entre plataformas:
Sección 12 · Ktor Server — Backend completo en Kotlin
Ktor no es solo un cliente HTTP; también es un framework de servidor muy potente. Es asíncrono, ligero, y perfecto para microservicios y APIs REST.
12.1 Configurar un proyecto Ktor Server
12.2 Configuración de la aplicación (Application.kt)
12.3 Plugins de configuración
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):
12.5 Repository pattern con Exposed
12.6 Rutas REST completas
12.7 Test de integración con Ktor TestEngine
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
13.2 UI con MVI en Compose
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:
Si prefieres no añadir dependencias, puedes crear wrappers que expongan callbacks en lugar de coroutines:
14.4 @ObjCName y visibilidad desde Swift
Puedes controlar cómo se expone tu código Kotlin en Swift:
14.5 Llamar código Swift/Objective-C desde Kotlin
El flujo inverso también es posible:
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
15.2 Optimización de Compose
El rendimiento de Compose depende de evitar recomposiciones innecesarias:
15.3 Optimización de Coroutines
15.4 Reducir el tamaño del binario iOS
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:
También puedes usar Napier (el logger KMP) para trazas detalladas:
PARTE XII — PUBLICACIÓN
Sección 16 · Publicar en Google Play y App Store desde KMP
16.1 Preparar la app Android para producción
1. Configurar el signing:
2. Reglas ProGuard para 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):
16.2 Preparar la app iOS para producción
1. Configurar el build en Gradle para generar el 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:
3. Configurar signing en Xcode:
Abre iosApp.xcodeproj en Xcode
Ve a Signing & Capabilities
Selecciona tu Team (requiere Apple Developer Account, €99/año)
Configura el Bundle Identifier (debe ser único, ej: com.miempresa.miapp)
4. Crear archivo de distribución:
En Xcode: Product → Archive
Una vez archivado: Window → Organizer → Distribute App
Selecciona App Store Connect
Sigue el asistente
16.3 Automatizar con Fastlane
Fastlane es la herramienta estándar para automatizar el proceso de publicación:
16.4 Versionado semántico en KMP
🏁 Checklist de publicación KMP
Antes de publicar, verifica esta lista:
Android:
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:
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:
Perfecto para indicadores de carga, efectos de "pulsación" y fondos animados:
17.4 AnimatedVisibility — Mostrar/Ocultar con animación
17.5 AnimatedContent — Transiciones entre contenidos
AnimatedContent anima la transición cuando cambia el contenido (no solo la visibilidad):
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.
🔑 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:
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:
Pantalla principal del reloj:
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:
Manejo del Siri Remote en tvOS:
App Compose para tvOS:
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
19.2 Punto de entrada para la web
19.3 Interoperabilidad con JavaScript
Kotlin/Wasm te permite llamar a APIs del navegador y librerías JavaScript:
19.4 Diferencias específicas de la plataforma web
Algunas cosas son específicas de la web y requieren expect/actual:
19.5 Construir y desplegar la app web
Despliegue en GitHub Pages:
PARTE X — RENDIMIENTO CON K2
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:
⚠️ 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):
Desde ViewModel 2.9.4, el ViewModel de Android funciona en KMP:
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:
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.
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:
21.3 Almacenamiento seguro con multiplatform-settings
Para guardar tokens, preferencias sensibles y credenciales de forma segura en todas las plataformas:
21.4 Cifrado de datos con expect/actual
Para cifrar datos en tránsito o en reposo más allá de simples key-value:
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:
O en el build.gradle.kts:
21.6 Ofuscación con R8 en Android
R8 es el shrinker/obfuscator predeterminado en Android (reemplazó a ProGuard). Produce binarios más pequeños y ofusca el código:
# proguard-rules.pro — Reglas para R8 en proyectos KMP 2026
# ============================================================
# KOTLIN
# ============================================================
# Mantener metadata de Kotlin (necesaria para reflection y serialización)
-keepattributes *Annotation*
-keepattributes RuntimeVisibleAnnotations
-keepattributes AnnotationDefault
# Mantener companion objects de Kotlin
-keepclassmembers class ** {
** Companion;
}
# ============================================================
# KOTLINX SERIALIZATION
# ============================================================
-keepattributes InnerClasses
-dontnote kotlinx.serialization.AnnotationsKt
-keep class kotlinx.serialization.** { *; }
-keepclassmembers @kotlinx.serialization.Serializable class ** {
*** Companion;
*** serializer();
kotlinx.serialization.KSerializer serializer(...);
}
# Mantener tus data classes serializables
-keep @kotlinx.serialization.Serializable class com.miapp.** { *; }
# ============================================================
# KTOR
# ============================================================
-keep class io.ktor.** { *; }
-keep class kotlinx.coroutines.** { *; }
-dontwarn io.ktor.**
# ============================================================
# ROOM
# ============================================================
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-keep @androidx.room.Dao class *
-dontwarn androidx.room.**
# ============================================================
# KOIN
# ============================================================
-keep class org.koin.** { *; }
-keepclassmembers class * {
@org.koin.core.annotation.* <fields>;
@org.koin.core.annotation.* <methods>;
}
# ============================================================
# GENERAL
# ============================================================
# Mantener enums (R8 puede eliminarlos si no los detecta)
-keepclassmembers enum * {
public static **[] values();
public static ** valueOf(java.lang.String);
}
# Mantener Parcelables
-keep class * implements android.os.Parcelable {
public static final android.os.Parcelable$Creator *;
}
PARTE XII — PROYECTOS GRANDES
Sección 22 · Monorepo KMP: Gestión de proyectos grandes en equipos
22.1 ¿Por qué un monorepo para KMP?
En equipos grandes, tener todas las apps y módulos en un mismo repositorio tiene ventajas enormes:
Cambios atómicos (un commit modifica shared + android + ios)
Versiones sincronizadas automáticamente
Refactoring cross-módulo con IDE support completo
CI/CD unificado
Estructura típica de un monorepo KMP en producción:
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:
Uso en los módulos — así de limpio queda:
22.3 Catálogo de versiones (libs.versions.toml) completo
22.4 settings.gradle.kts para monorepo
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:
22.6 Dokka — Documentación automática para librerías KMP
Si publicas módulos de tu monorepo como librerías:
22.7 Detekt — Análisis estático de código
Detekt detecta code smells y mantiene la calidad del código:
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! 🎉
bash
brew install --cask temurin@21
bash
sudo apt updatesudo apt install temurin-21-jdk
bash
java -version# Salida esperada: openjdk version "21.x.x" ...
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;
# 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
bash
./gradlew :androidApp:bundleRelease# El archivo .aab estará en: androidApp/build/outputs/bundle/release/
bash
# Generar el XCFramework para distribución./gradlew :shared:assembleSharedXCFramework# El XCFramework estará en:# shared/build/XCFrameworks/release/shared.xcframework
# 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)
// 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)}
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}
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}
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}
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
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"}
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") }}
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") }}
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()}
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)}
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}
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")}
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}
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}
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)}
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"}
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}
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]}
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}}
kotlin
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}")}
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}
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
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)}
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")}
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)}
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()}
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()
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) } }}
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" ) } } }}
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()}") }}
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)}
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 ) } } }}
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) }
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) }}
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) }}
// 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)
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() }}
kotlin
// 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 }}
kotlin
// 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) } } }}
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) }}
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
// 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")) } }}
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)) } } } } } }}
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() { ... }}
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() }}
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>>}
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)}
kotlin
// Tracing manual para el profilerimport androidx.tracing.tracefun operacionCritica() { trace("MiOperacion") { // Aparece como sección en el profiler // ... código a medir }}
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)
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 )}
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" ) } } }}
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") } }}
// 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) } }}
kotlin
// 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}
import androidx.compose.animation.*import androidx.compose.foundation.layout.*import androidx.compose.material3.Buttonimport androidx.compose.material3.Textimport androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dp@Composablefun EjemploAnimatedVisibility() { var visible by remember { mutableStateOf(false) } Column( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp) ) { Button(onClick = { visible = !visible }) { Text(if (visible) "Ocultar" else "Mostrar") } // AnimatedVisibility con transiciones personalizadas AnimatedVisibility( visible = visible, enter = slideInVertically( // La animación de entrada viene desde arriba initialOffsetY = { fullHeight -> -fullHeight }, animationSpec = spring(dampingRatio = Spring.DampingRatioLowBouncy) ) + fadeIn( animationSpec = tween(300) ), exit = slideOutVertically( targetOffsetY = { fullHeight -> -fullHeight }, animationSpec = tween(200) ) + fadeOut() ) { androidx.compose.material3.Card( modifier = Modifier.fillMaxWidth() ) { Text("¡Contenido animado!", modifier = Modifier.padding(16.dp)) } } // expandIn / shrinkOut para contenido que "crece/encoge" AnimatedVisibility( visible = visible, enter = expandIn(expandFrom = Alignment.TopCenter), exit = shrinkOut(shrinkTowards = Alignment.TopCenter) ) { Box( modifier = Modifier .fillMaxWidth() .height(100.dp) .background(MaterialTheme.colorScheme.secondaryContainer) ) } }}// Modificador de animación DENTRO del contenido de AnimatedVisibility@Composablefun AnimatedVisibilityAvanzado() { var visible by remember { mutableStateOf(true) } AnimatedVisibility( visible = visible, enter = EnterTransition.None, // Sin animación a nivel de contenedor exit = ExitTransition.None ) { Box( modifier = Modifier .fillMaxWidth() .animateEnterExit( // Cada hijo puede tener su propia animación enter = slideInHorizontally { it }, exit = slideOutHorizontally { -it } ) .background(MaterialTheme.colorScheme.errorContainer) .padding(16.dp) ) { Text("Este elemento tiene su propia animación de entrada/salida") } }}
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 }
kotlin
import androidx.compose.animation.*import androidx.compose.foundation.clickableimport androidx.compose.foundation.layout.*import androidx.compose.foundation.lazy.LazyColumnimport androidx.compose.foundation.lazy.itemsimport androidx.compose.material3.*import androidx.compose.runtime.*import androidx.compose.ui.Modifierimport androidx.compose.ui.unit.dp// IMPORTANTE: SharedTransitionLayout debe envolver tanto la lista como el detalle// Normalmente lo pones alrededor de tu NavHost@OptIn(ExperimentalSharedTransitionApi::class)@Composablefun AppConSharedTransitions() { // SharedTransitionLayout es el contenedor raíz necesario para shared elements SharedTransitionLayout { var seleccionado: Post? by remember { mutableStateOf(null) } // Pasamos el scope como parámetro a los composables hijos if (seleccionado == null) { ListaPostsConTransicion( posts = postsEjemplo, sharedTransitionScope = this, onPostClick = { seleccionado = it } ) } else { DetallePostConTransicion( post = seleccionado!!, sharedTransitionScope = this, onVolver = { seleccionado = null } ) } }}@OptIn(ExperimentalSharedTransitionApi::class)@Composablefun ListaPostsConTransicion( posts: List<Post>, sharedTransitionScope: SharedTransitionScope, onPostClick: (Post) -> Unit) { // animatedVisibilityScope viene de AnimatedContent o NavHost automáticamente // cuando usas Navigation Compose. Aquí lo simulamos con AnimatedContent. AnimatedContent( targetState = posts, label = "lista_posts" ) { postsList -> with(sharedTransitionScope) { LazyColumn(contentPadding = PaddingValues(16.dp)) { items(postsList, key = { it.id }) { post -> Card( modifier = Modifier .fillMaxWidth() .padding(vertical = 6.dp) .clickable { onPostClick(post) } ) { Row(modifier = Modifier.padding(12.dp)) { // Este Text "viaja" a la pantalla de detalle Text( text = "#${post.id}", modifier = Modifier.sharedElement( state = rememberSharedContentState(key = "post_id_${post.id}"), animatedVisibilityScope = this@AnimatedContent ), style = MaterialTheme.typography.titleMedium ) Spacer(Modifier.width(12.dp)) Text( text = post.title, modifier = Modifier .weight(1f) .sharedElement( state = rememberSharedContentState(key = "post_title_${post.id}"), animatedVisibilityScope = this@AnimatedContent ), style = MaterialTheme.typography.bodyMedium ) } } } } } }}
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) }}
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)}
kotlin
// 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) } } } }}
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) } }}
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) }}
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í") } }}
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 } } }}
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
// 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}
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))}
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}
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()}
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()) }}
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
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 )}
kotlin
// 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")}
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)) } }}
// 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" } }}
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")) } }}
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")
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() } }}
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
import SwiftUIimport shared // El framework KMP compiladostruct ContentView: View { var body: some View { // ComposeUIViewController integra Compose en SwiftUI ComposeView() .ignoresSafeArea(.keyboard) }}// MainViewController.kt (en iosMain)// Esta función la llama Swiftfun MainViewController() = ComposeUIViewController { AppNavigation(viewModelFactory = AppViewModelFactory())}
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()}
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() } }}