Categoría: Aprendiento KMP

  • Unidad 4 — Tema 1: Compose Multiplatform: Diseña tu interfaz una vez, ejecútala en todas partes

    Unidad 4 — Tema 1: Compose Multiplatform: Diseña tu interfaz una vez, ejecútala en todas partes

    En el artículo anterior de la serie (Unidad 3 — Tema 3), logramos el gran hito de conectar todas las piezas de nuestra arquitectura: integramos Ktor para la red y Room para la persistencia offline-first bajo un grafo de dependencias unificado con Koin, moviendo toda la lógica a un ViewModel compartido. Ahora que los cimientos de la lógica de negocio y del estado están firmemente establecidos en el código común, es hora de abordar la capa de presentación visual. En este artículo daremos el salto a Compose Multiplatform para diseñar las pantallas de nuestra aplicación una única vez y ejecutarlas de forma nativa tanto en Android como en iOS.

    Al final de este artículo habrás construido un flujo completo de interfaz de usuario compartida en commonMain que incluye una pantalla de Login con estados reactivos y validación de credenciales, junto con un rediseño premium en formato tarjetas para nuestra lista de noticias de News Explorer, todo ello integrado bajo el contenedor nativo de iOS ComposeUIViewController. La app no solo compartirá datos; compartirá la experiencia visual al completo.

    1. El motor de Compose Multiplatform: Skia y Skiko bajo el capó

    Para comprender la potencia de Compose Multiplatform en iOS, es vital analizar cómo funciona su ciclo de renderizado a nivel arquitectónico. A diferencia de tecnologías como React Native o las vistas nativas de Android, que mapean componentes lógicos a vistas del sistema operativo (un Button de Compose se traduce a un android.widget.Button tradicional en Android clásico).

    JetBrains utiliza Skia (la biblioteca gráfica en C++ de código abierto que motoriza a Chrome, Android y Flutter) en combinación con Skiko (Kotlin bindings para Skia) para pintar cada píxel de la interfaz directamente en un lienzo acelerado por hardware. En iOS, Skiko crea un CAMetalLayer y utiliza la API gráfica de Metal de Apple para obtener un rendimiento óptimo de 60 o 120 FPS. Cuando escribes un Text o un Button en commonMain, el compilador no genera un elemento UILabel o UIButton de UIKit; en su lugar, el árbol de composición calcula la geometría y Skia dibuja las formas vectoriales de forma directa sobre la capa de metal del dispositivo.

    En Android, sin embargo, Compose Multiplatform no necesita Skia embebido, ya que utiliza el propio motor de renderizado y el pipeline gráfico de la JVM del sistema operativo Android, garantizando que el peso de la app no aumente innecesariamente. Esta arquitectura mixta combina lo mejor de ambos mundos: rendimiento nativo directo en Android y fidelidad visual matemática de pixel-perfect en iOS.

    Hay además un requisito práctico en iPhone: añade la clave CADisableMinimumFrameDurationOnPhone al Info.plist de la app iOS. La propia documentación de JetBrains la marca como necesaria para habilitar correctamente el renderizado de Compose Multiplatform en dispositivos iPhone; si no está presente, el arranque puede fallar.

    <key>CADisableMinimumFrameDurationOnPhone</key>
    <true/>

    2. Maquetando la pantalla de Login con estados y validaciones

    El primer paso para dotar de interactividad a nuestra aplicación es crear una pantalla de acceso que valide el formato del email y la longitud de la contraseña. Crearemos este archivo en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/ui/login/LoginScreen.kt:

    package dev.asiles.newsexplorer.ui.login
    
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.text.KeyboardOptions
    import androidx.compose.material3.*
    import androidx.compose.runtime.*
    import androidx.compose.runtime.saveable.rememberSaveable
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.text.input.KeyboardType
    import androidx.compose.ui.text.input.PasswordVisualTransformation
    import androidx.compose.ui.unit.dp
    
    @Composable
    fun LoginScreen(onLoginSuccess: () -> Unit) {
        var email by rememberSaveable { mutableStateOf("") }
        var password by rememberSaveable { mutableStateOf("") }
        var isLoading by rememberSaveable { mutableStateOf(false) }
        
        val isEmailValid = remember(email) {
            email.contains("@") && email.contains(".")
        }
        val isPasswordValid = remember(password) {
            password.length >= 6
        }
        val isFormValid = isEmailValid && isPasswordValid
    
        Box(
            modifier = Modifier
                .fillMaxSize()
                .padding(24.dp),
            contentAlignment = Alignment.Center
        ) {
            if (isLoading) {
                CircularProgressIndicator()
            } else {
                Column(
                    modifier = Modifier.fillMaxWidth(),
                    verticalArrangement = Arrangement.spacedBy(16.dp),
                    horizontalAlignment = Alignment.CenterHorizontally
                ) {
                    Text(
                        text = "Welcome to News Explorer",
                        style = MaterialTheme.typography.headlineMedium,
                        color = MaterialTheme.colorScheme.primary
                    )
                    
                    Spacer(modifier = Modifier.height(16.dp))
    
                    OutlinedTextField(
                        value = email,
                        onValueChange = { email = it },
                        label = { Text("Email") },
                        isError = email.isNotEmpty() && !isEmailValid,
                        supportingText = {
                            if (email.isNotEmpty() && !isEmailValid) {
                                Text("Invalid email format")
                            }
                        },
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email),
                        modifier = Modifier.fillMaxWidth()
                    )
    
                    OutlinedTextField(
                        value = password,
                        onValueChange = { password = it },
                        label = { Text("Password") },
                        isError = password.isNotEmpty() && !isPasswordValid,
                        supportingText = {
                            if (password.isNotEmpty() && !isPasswordValid) {
                                Text("Password must be at least 6 characters")
                            }
                        },
                        visualTransformation = PasswordVisualTransformation(),
                        keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                        modifier = Modifier.fillMaxWidth()
                    )
    
                    Spacer(modifier = Modifier.height(8.dp))
    
                    Button(
                        onClick = {
                            isLoading = true
                            // Simulamos una petición de red asíncrona
                            onLoginSuccess()
                        },
                        enabled = isFormValid,
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Text("Sign In")
                    }
                }
            }
        }
    }

    En este bloque aplicamos el concepto de State Hoisting y la preservación del estado frente a la recreación de la vista o la rotación de pantalla mediante rememberSaveable. Las validaciones se optimizan con remember(state) para evitar recálculos en recomposiciones innecesarias.

    3. Diseño premium de la lista de noticias

    Una vez autenticado el usuario, debemos presentar el listado de artículos. Vamos a reescribir de forma estética la pantalla que creamos en la unidad anterior, convirtiendo la lista plana en un conjunto de tarjetas elegantes Material 3 que admiten scroll fluido. Crea o edita el archivo en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/ui/news/NewsListScreen.kt:

    package dev.asiles.newsexplorer.ui.news
    
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.items
    import androidx.compose.material3.*
    import androidx.compose.runtime.Composable
    import androidx.compose.runtime.getValue
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.text.style.TextOverflow
    import androidx.compose.ui.unit.dp
    import androidx.lifecycle.compose.collectAsStateWithLifecycle
    import dev.asiles.newsexplorer.domain.model.Article
    import dev.asiles.newsexplorer.presentation.NewsViewModel
    import org.koin.compose.viewmodel.koinViewModel
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun NewsListScreen(
        viewModel: NewsViewModel = koinViewModel(),
        onBackToLogin: () -> Unit
    ) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        Scaffold(
            topBar = {
                TopAppBar(
                    title = { Text("News Explorer") },
                    actions = {
                        TextButton(onClick = onBackToLogin) {
                            Text("Log Out", color = MaterialTheme.colorScheme.error)
                        }
                    }
                )
            }
        ) { padding ->
            Box(
                modifier = Modifier
                    .padding(padding)
                    .fillMaxSize()
                    .padding(horizontal = 16.dp)
            ) {
                if (uiState.isLoading && uiState.articles.isEmpty()) {
                    CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
                } else {
                    LazyColumn(
                        verticalArrangement = Arrangement.spacedBy(16.dp),
                        contentPadding = PaddingValues(vertical = 16.dp),
                        modifier = Modifier.fillMaxSize()
                    ) {
                        uiState.error?.let { error ->
                            item {
                                Card(
                                    colors = CardDefaults.cardColors(
                                        containerColor = MaterialTheme.colorScheme.errorContainer
                                    ),
                                    modifier = Modifier.fillMaxWidth()
                                ) {
                                    Text(
                                        text = error,
                                        color = MaterialTheme.colorScheme.onErrorContainer,
                                        modifier = Modifier.padding(16.dp),
                                        style = MaterialTheme.typography.bodyMedium
                                    )
                                }
                            }
                        }
                        
                        items(uiState.articles, key = { it.url }) { article ->
                            ArticleCard(article)
                        }
                    }
                }
            }
        }
    }
    
    @Composable
    private fun ArticleCard(article: Article) {
        Card(
            elevation = CardDefaults.cardElevation(defaultElevation = 2.dp),
            modifier = Modifier.fillMaxWidth()
        ) {
            Column(modifier = Modifier.padding(16.dp)) {
                Row(
                    modifier = Modifier.fillMaxWidth(),
                    horizontalArrangement = Arrangement.spaceBetween
                ) {
                    Text(
                        text = article.source,
                        style = MaterialTheme.typography.labelMedium,
                        color = MaterialTheme.colorScheme.primary
                    )
                    Text(
                        text = article.publishedAt.take(10),
                        style = MaterialTheme.typography.labelSmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
                Spacer(modifier = Modifier.height(8.dp))
                Text(
                    text = article.title,
                    style = MaterialTheme.typography.titleMedium,
                    maxLines = 2,
                    overflow = TextOverflow.Ellipsis
                )
                if (article.description.isNotBlank()) {
                    Spacer(modifier = Modifier.height(6.dp))
                    Text(
                        text = article.description,
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        maxLines = 3,
                        overflow = TextOverflow.Ellipsis
                    )
                }
            }
        }
    }

    La estructura del proyecto en commonMain se simplifica al unificar la navegación. Vamos a actualizar la composición raíz en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/ui/App.kt para gestionar el intercambio de pantallas de forma declarativa con una máquina de estados sencilla:

    package dev.asiles.newsexplorer.ui
    
    import androidx.compose.material3.MaterialTheme
    import androidx.compose.runtime.*
    import androidx.compose.runtime.saveable.rememberSaveable
    import dev.asiles.newsexplorer.ui.login.LoginScreen
    import dev.asiles.newsexplorer.ui.news.NewsListScreen
    
    sealed interface Screen {
        object Login : Screen
        object NewsList : Screen
    }
    
    @Composable
    fun App() {
        var currentScreen by rememberSaveable { mutableStateOf<Screen>(Screen.Login) }
    
        MaterialTheme {
            when (currentScreen) {
                is Screen.Login -> LoginScreen(
                    onLoginSuccess = { currentScreen = Screen.NewsList }
                )
                is Screen.NewsList -> NewsListScreen(
                    onBackToLogin = { currentScreen = Screen.Login }
                )
            }
        }
    }

    4. Configuración del contenedor nativo en iOS: ComposeUIViewController

    En el mundo Apple, la UI se gestiona a través de controladores de vista (View Controllers). Para poder incrustar nuestro lienzo de Compose Multiplatform dentro de un ecosistema Swift nativo, JetBrains expone la función de fachada ComposeUIViewController, que hereda directamente del UIViewController del framework UIKit de Apple.

    Primero, en shared/src/iosMain/kotlin/dev/asiles/newsexplorer/MainViewController.kt instanciamos el punto de entrada de la UI unificada:

    package dev.asiles.newsexplorer
    
    import androidx.compose.ui.window.ComposeUIViewController
    import dev.asiles.newsexplorer.data.local.IosDatabaseFactory
    import dev.asiles.newsexplorer.di.initKoin
    import dev.asiles.newsexplorer.ui.App
    import org.koin.dsl.module
    
    fun MainViewController() = ComposeUIViewController(
        configure = {
            initKoin(
                platformModules = listOf(
                    module { single<DatabaseFactory> { IosDatabaseFactory() } }
                )
            )
        }
    ) {
        App()
    }

    En la aplicación de iOS nativa escrita en Xcode (iosApp), este controlador de vista se expone como una clase nativa de Objective-C/Swift. Mediante el protocolo UIViewControllerRepresentable de SwiftUI, convertimos el controlador gráfico de Compose en un componente compatible que SwiftUI sabe maquetar en su jerarquía declarativa:

    import SwiftUI
    import shared
    
    struct ContentView: View {
        var body: some View {
            ComposeView()
                .ignoresSafeArea(.all, edges: .bottom) // Evitamos solapamientos con la barra del sistema
        }
    }
    
    struct ComposeView: UIViewControllerRepresentable {
        func makeUIViewController(context: Context) -> UIViewController {
            return MainViewControllerKt.MainViewController()
        }
        
        func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
            // Reservado para actualizaciones dinámicas desde SwiftUI hacia Compose
        }
    }

    5. Enfoque senior: Compose Multiplatform vs. Vistas nativas

    Compartir el 100% de la interfaz gráfica a través de Compose Multiplatform es una decisión arquitectónica muy atractiva que reduce drásticamente el «time-to-market», pero un desarrollador senior debe evaluar cuidadosamente sus costes e implicaciones técnicas antes de dar el paso en un proyecto corporativo de gran escala.

    • Rendimiento Gráfico y Metal: Al pintar directamente sobre CAMetalLayer en iOS usando Skia, Compose ofrece un rendimiento visual espectacular a 120 FPS en pantallas con ProMotion. Sin embargo, esto requiere instanciar e inicializar la máquina de Skia en tiempo de ejecución. La huella inicial de memoria (RAM) y el arranque de la app pueden aumentar levemente (entre 10 y 15 MB de sobrecarga en iOS) en comparación con una UI puramente escrita en SwiftUI nativo que use los widgets nativos provistos por el kernel de Apple.
    • Accesibilidad y Árbol Semántico: Skia renderiza vectores gráficos en un lienzo plano. Para que herramientas como VoiceOver o el acceso mediante teclado funcionen en iOS, Skiko traduce en tiempo real el árbol de composición semántico de Compose a un árbol de nodos de accesibilidad nativos de Apple (UIAccessibilityElement). Aunque JetBrains ha mejorado esta traducción enormemente, si tu aplicación tiene requisitos extremadamente rigurosos de accesibilidad gubernamental o médica, las vistas nativas de SwiftUI siguen teniendo una integración directa y libre de wrappers de traducción intermedios.
    • Integración con el Teclado y Ciclo de Vida: La gestión del teclado en iOS (desplazamientos de foco, layouts ajustables cuando el teclado aparece en pantalla) se comporta de forma diferente a Android. Aunque Compose Multiplatform ofrece APIs para gestionar esto (WindowInsets.ime), en ocasiones la interacción con los gestos nativos de navegación lateral de iOS (swipe back) requiere configuraciones extras para evitar conflictos con los gestos de deslizamiento horizontales internos de Compose.
    • La decisión senior: Usa Compose Multiplatform si buscas paridad absoluta de diseño entre plataformas, si tu equipo tiene bases sólidas de Jetpack Compose y si la UI se enfoca principalmente en consumo de datos, formularios y listados. Opta por una fachada de UI separada (Compose en Android, SwiftUI en iOS compartiendo solo el ViewModel de commonMain) si tu aplicación móvil requiere widgets interactivos profundos del sistema (como Live Activities), APIs visuales altamente específicas de Apple, o si el diseño del producto debe ajustarse milimétricamente al «Human Interface Guidelines» de Apple de manera radical.

    Para profundizar

    Conclusión

    El diseño de interfaces mediante Compose Multiplatform representa el punto álgido del desarrollo multiplataforma: compartir la lógica visual y de navegación sin sacrificar el rendimiento, gracias al uso directo de APIs de bajo nivel como Metal y Skia. Esta homogeneidad arquitectónica hace que mantener pantallas se convierta en una tarea de un solo esfuerzo.

    Sin embargo, el lienzo del Canvas de Skia puede sentirse en ocasiones como una celda aislada si necesitamos consumir componentes extremadamente específicos del sistema operativo de Apple, tales como mapas de alta precisión de Apple Maps (MapKit) o flujos de realidad aumentada (ARKit). En el próximo artículo, Unidad 4 — Tema 2: Integrando SwiftUI y componentes nativos en Compose Multiplatform, aprenderemos a romper el sandbox del lienzo de Skia e incrustar de manera transparente componentes visuales de SwiftUI y UIKit directamente en el flujo declarativo de nuestro código común.

  • Unidad 3 — Tema 3: ViewModel + Koin + Compose: la app funciona de principio a fin

    Unidad 3 — Tema 3: ViewModel + Koin + Compose: la app funciona de principio a fin

    En el artículo anterior de la serie (Unidad 3 — Tema 2), implementamos un patrón offline-first persistiendo nuestros artículos con Room Multiplatform y exponiéndolos como un flujo reactivo. Llegamos al último tema de la Unidad 3. El problema es que toda la inicialización de Ktor y Room está en MainActivity, ensuciando el código y sin separación de responsabilidades.

    Hoy lo arreglamos todo de una vez: Koin para gestionar dependencias, un ViewModel compartido en commonMain que orquesta el Repository, y una pantalla en Compose Multiplatform que vive también en commonMain y se ejecuta tanto en Android como en iOS sin cambiar una sola línea. Al final tendrás una app completamente funcional de principio a fin.

    Lo que vamos a construir

    1. Módulos Koin: grafo de dependencias en commonMain que conecta cliente HTTP, base de datos y repository.
    2. NewsViewModel: gestiona el estado de la pantalla y expone un StateFlow.
    3. NewsListScreen: pantalla Compose Multiplatform en commonMain, compartida entre Android e iOS.
    4. Puntos de entrada nativos: MainActivity (Android) y MainViewController (iOS), ambos mínimos.

    1. Añade Compose Multiplatform, Koin y el ViewModel

    Compose Multiplatform se activa con el plugin de JetBrains. Añade las entradas necesarias al catálogo de versiones:

    [versions]
    compose-multiplatform  = "1.11.0"
    koin                   = "4.2.1"
    androidx-lifecycle     = "2.10.0"
    
    [libraries]
    koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
    koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
    koin-compose = { module = "io.insert-koin:koin-compose", version.ref = "koin" }
    koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
    androidx-lifecycle-viewmodel = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel", version.ref = "androidx-lifecycle" }
    androidx-lifecycle-runtime-compose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
    
    [plugins]
    composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
    composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }

    En shared/build.gradle.kts, aplica los plugins y añade las dependencias en el source set correcto. La clave es que koin-compose y la UI de Compose van en commonMain: así la pantalla compilará igual para Android y para iOS.

    plugins {
        alias(libs.plugins.kotlinMultiplatform)
        alias(libs.plugins.kotlinxSerialization)
        alias(libs.plugins.ksp)
        alias(libs.plugins.room)
        alias(libs.plugins.composeMultiplatform)  // Compose Multiplatform
        alias(libs.plugins.composeCompiler)      // Compilador de Compose para Kotlin
    }
    
    kotlin {
        sourceSets {
            commonMain.dependencies {
                // ... dependencias de Ktor y Room de los temas anteriores ...
                implementation(libs.compose.runtime)
                implementation(libs.compose.foundation)
                implementation(libs.compose.material3)
                implementation(libs.compose.ui)
                implementation(libs.compose.components.resources)
                implementation(libs.compose.uiToolingPreview)
                implementation(libs.koin.core)
                implementation(libs.koin.compose)           // koinViewModel() in commonMain
                implementation(libs.koin.compose.viewmodel)
                implementation(libs.androidx.lifecycle.viewmodel)
                implementation(libs.androidx.lifecycle.runtime.compose)
            }
        }
    }
    dependencies {
        implementation(libs.koin.android) // KoinAndroidContext para la clase Application
        implementation(libs.compose.uiToolingPreview)
        debugImplementation(libs.compose.uiTooling)
    }

    La diferencia respecto a Jetpack Compose puro: compose.runtime, compose.material3, etc., son alias del plugin de Compose Multiplatform que JetBrains resuelve automáticamente para cada target. No hay que especificar artefactos Maven distintos por plataforma.

    2. Define los módulos de Koin

    package dev.asiles.newsexplorer.di
    
    import dev.asiles.newsexplorer.data.NewsRepository
    import dev.asiles.newsexplorer.data.local.DatabaseFactory
    import dev.asiles.newsexplorer.data.local.NewsDatabase
    import dev.asiles.newsexplorer.data.remote.NewsApiService
    import dev.asiles.newsexplorer.data.remote.createHttpClient
    import dev.asiles.newsexplorer.presentation.NewsViewModel
    import org.koin.core.module.dsl.viewModelOf
    import org.koin.dsl.module
    
    val networkModule = module {
        single { createHttpClient(apiKey = "TU_API_KEY") }
        single { NewsApiService(client = get()) }
    }
    
    val databaseModule = module {
        single { get<DatabaseFactory>().create().build() }
        single { get<NewsDatabase>().articleDao() }
    }
    
    val repositoryModule = module {
        single { NewsRepository(apiService = get(), dao = get()) }
    }
    
    val viewModelModule = module {
        viewModelOf(::NewsViewModel)
    }
    
    val appModules = listOf(networkModule, databaseModule, repositoryModule, viewModelModule)
    package dev.asiles.newsexplorer.di
    
    import org.koin.core.context.startKoin
    import org.koin.core.module.Module
    import org.koin.dsl.KoinAppDeclaration
    
    fun initKoin(platformModules: List<Module> = emptyList(), appDeclaration: KoinAppDeclaration = {}) {
        startKoin {
            appDeclaration()
            modules(appModules + platformModules)
        }
    }

    3. Implementa el ViewModel compartido

    package dev.asiles.newsexplorer.presentation
    
    import androidx.lifecycle.ViewModel
    import androidx.lifecycle.viewModelScope
    import dev.asiles.newsexplorer.data.NewsRepository
    import dev.asiles.newsexplorer.domain.model.Article
    import kotlinx.coroutines.flow.*
    import kotlinx.coroutines.launch
    
    data class NewsUiState(
        val articles: List<Article> = emptyList(),
        val isLoading: Boolean = false,
        val error: String? = null
    )
    
    class NewsViewModel(private val repository: NewsRepository) : ViewModel() {
    
        private val _uiState = MutableStateFlow(NewsUiState(isLoading = true))
        val uiState: StateFlow<NewsUiState> = _uiState.asStateFlow()
    
        init {
            repository.articles
                .onEach { articles ->
                    _uiState.update { it.copy(articles = articles, isLoading = false, error = null) }
                }
                .catch { e ->
                    _uiState.update { it.copy(isLoading = false, error = e.message) }
                }
                .launchIn(viewModelScope)
    
            refresh()
        }
    
        fun refresh() {
            viewModelScope.launch {
                _uiState.update { it.copy(isLoading = true, error = null) }
                try {
                    repository.refresh()
                } catch (e: Exception) {
                    _uiState.update { it.copy(isLoading = false, error = "Sin conexión. Mostrando datos en caché.") }
                }
            }
        }
    }

    El ViewModel lanza dos corrutinas en el init: un colector permanente del Flow de Room (cualquier escritura en la BD actualiza la UI automáticamente) y una llamada a refresh() para traer datos frescos de la red. Si la red falla, los datos cacheados permanecen visibles.

    4. Construye la pantalla compartida con Compose Multiplatform

    Esta es la diferencia clave respecto a Jetpack Compose: NewsListScreen vive en commonMain. No hay un archivo en androidApp ni en iosApp; la misma pantalla compila para ambas plataformas.

    package dev.asiles.newsexplorer.ui
    
    import androidx.compose.foundation.layout.*
    import androidx.compose.foundation.lazy.LazyColumn
    import androidx.compose.foundation.lazy.items
    import androidx.compose.material3.*
    import androidx.compose.runtime.*
    import androidx.compose.ui.Alignment
    import androidx.compose.ui.Modifier
    import androidx.compose.ui.unit.dp
    import androidx.lifecycle.compose.collectAsStateWithLifecycle
    import dev.asiles.newsexplorer.domain.model.Article
    import dev.asiles.newsexplorer.presentation.NewsViewModel
    import org.koin.compose.viewmodel.koinViewModel
    
    @Composable
    fun NewsListScreen(viewModel: NewsViewModel = koinViewModel()) {
        val uiState by viewModel.uiState.collectAsStateWithLifecycle()
    
        Scaffold(
            topBar = { TopAppBar(title = { Text("News Explorer") }) }
        ) { padding ->
            Box(modifier = Modifier.padding(padding).fillMaxSize()) {
                if (uiState.isLoading && uiState.articles.isEmpty()) {
                    CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
                } else {
                    LazyColumn {
                        uiState.error?.let { error ->
                            item {
                                Text(
                                    text = error,
                                    color = MaterialTheme.colorScheme.error,
                                    modifier = Modifier.padding(16.dp)
                                )
                            }
                        }
                        items(uiState.articles, key = { it.url }) { article ->
                            ArticleItem(article)
                        }
                    }
                }
            }
        }
    }
    
    @Composable
    private fun ArticleItem(article: Article) {
        Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
            Text(
                text = article.source,
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.primary
            )
            Spacer(modifier = Modifier.height(4.dp))
            Text(text = article.title, style = MaterialTheme.typography.titleSmall)
            if (article.description.isNotBlank()) {
                Spacer(modifier = Modifier.height(4.dp))
                Text(
                    text = article.description,
                    style = MaterialTheme.typography.bodySmall,
                    color = MaterialTheme.colorScheme.onSurfaceVariant,
                    maxLines = 2
                )
            }
            HorizontalDivider(modifier = Modifier.padding(top = 12.dp))
        }
    }

    Crea también un composable raíz App() en commonMain. Este es el punto de entrada de la UI compartida que invocarán tanto Android como iOS:

    package dev.asiles.newsexplorer.ui
    
    import androidx.compose.material3.MaterialTheme
    import androidx.compose.runtime.Composable
    
    @Composable
    fun App() {
        MaterialTheme {
            NewsListScreen()
        }
    }

    5. Puntos de entrada nativos

    Cada plataforma necesita su propio arranque: inicializar Koin con el módulo de base de datos específico e invocar App(). Nada más.

    Android — la clase Application inicializa Koin con KoinAndroidContext para que Koin pueda proveer el Context de Android internamente:

    package dev.asiles.newsexplorer
    
    import android.app.Application
    import dev.asiles.newsexplorer.data.local.AndroidDatabaseFactory
    import dev.asiles.newsexplorer.di.initKoin
    import org.koin.android.ext.koin.androidContext
    import org.koin.dsl.module
    
    class NewsExplorerApp : Application() {
        override fun onCreate() {
            super.onCreate()
            initKoin(
                platformModules = listOf(
                    module { single<DatabaseFactory> { AndroidDatabaseFactory(this@NewsExplorerApp) } }
                )
            ) {
                androidContext(this@NewsExplorerApp) // Necesario para koin-android
            }
        }
    }
    package dev.asiles.newsexplorer
    
    import android.os.Bundle
    import androidx.activity.ComponentActivity
    import androidx.activity.compose.setContent
    import dev.asiles.newsexplorer.ui.App
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
            setContent { App() }
        }
    }

    Registra la clase en AndroidManifest.xml:

    <application android:name=".NewsExplorerApp" ...>

    iOS — Compose Multiplatform genera un UIViewController que SwiftUI puede integrar directamente. Crea el archivo en iosMain:

    package dev.asiles.newsexplorer
    
    import androidx.compose.ui.window.ComposeUIViewController
    import dev.asiles.newsexplorer.data.local.IosDatabaseFactory
    import dev.asiles.newsexplorer.di.initKoin
    import dev.asiles.newsexplorer.ui.App
    import org.koin.dsl.module
    
    fun MainViewController() = ComposeUIViewController(
        configure = {
            initKoin(
                platformModules = listOf(
                    module { single<DatabaseFactory> { IosDatabaseFactory() } }
                )
            )
        }
    ) {
        App()
    }

    En el proyecto Xcode, el ContentView.swift generado por la plantilla KMP ya apunta a esta función:

    import SwiftUI
    import shared
    
    struct ContentView: View {
        var body: some View {
            ComposeView().ignoresSafeArea(.keyboard)
        }
    }
    
    struct ComposeView: UIViewControllerRepresentable {
        func makeUIViewController(context: Context) -> UIViewController {
            MainViewControllerKt.MainViewController()
        }
        func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
    }

    En iOS hay un detalle de configuración imprescindible: añade la clave CADisableMinimumFrameDurationOnPhone al Info.plist de la app anfitriona. La documentación oficial de JetBrains la exige para Compose Multiplatform en iPhone; si falta, el arranque puede fallar en tiempo de ejecución.

    <key>CADisableMinimumFrameDurationOnPhone</key>
    <true/>

    Resultado final

    Ejecuta la app en Android o en el simulador de iOS. Verás el mismo spinner de carga, el mismo listado de noticias y el mismo mensaje de error offline: mismo código, misma experiencia. Si activas el modo avión y reinicias, las noticias cacheadas seguirán ahí.

    Esto es la estructura completa de lo construido en la Unidad 3:

    commonMain
    ├── data/
    │   ├── remote/NewsApiService.kt        (Ktor — Tema 1)
    │   ├── local/NewsDatabase.kt           (Room — Tema 2)
    │   ├── local/DatabaseFactory.kt
    │   └── NewsRepository.kt               (offline-first — Tema 2)
    ├── domain/model/Article.kt
    ├── presentation/NewsViewModel.kt      (Tema 3)
    ├── ui/
    │   ├── App.kt                          (raíz UI compartida)
    │   └── NewsListScreen.kt              (Compose Multiplatform — Tema 3)
    └── di/AppModule.kt                    (Koin — Tema 3)
    
    androidMain
    ├── AndroidDatabaseFactory.kt
    ├── NewsExplorerApp.kt
    └── MainActivity.kt
    
    iosMain
    └── MainViewController.kt

    6. Enfoque senior: Compose Multiplatform vs UI nativa

    Compartir la UI es la decisión con más impacto en la arquitectura del proyecto. Vale la pena entender los compromisos:

    • Usa Compose Multiplatform cuando la paridad visual entre plataformas es un requisito, el equipo domina Compose, o la app tiene una UI principalmente de contenido (listas, formularios, dashboards). El coste de mantenimiento de una sola pantalla frente a dos es significativamente menor.
    • Considera UI nativa cuando necesites integraciones profundas con el sistema operativo (widgets, Live Activities en iOS, notificaciones enriquecidas) o cuando el diseño de producto exige comportamientos claramente diferenciados por plataforma.
    • Cuidado con expect/actual en la UI: Compose Multiplatform cubre Material 3 completo, pero algunos componentes del sistema (date pickers nativos, menus contextuales) no tienen equivalente multiplataforma. En esos casos tendrás que usar expect/actual con una implementación Compose para Android y un wrapper de SwiftUI para iOS.
    • Rendimiento: Compose Multiplatform en iOS usa Skia como motor de renderizado en lugar de UIKit. En la mayoría de casos el rendimiento es indistinguible, pero animaciones muy complejas o listas con miles de ítems pueden necesitar optimización extra con key y remember.

    Para profundizar

    Conclusión

    Hemos llegado al punto donde toda la aplicación — red, persistencia, estado y UI — vive en commonMain. Los únicos archivos que no se comparten son los puntos de entrada nativos: MainActivity en Android y MainViewController en iOS, ambos con menos de diez líneas de código.

    Esta es la propuesta real de valor de Kotlin Multiplatform con Compose: no es solo compartir lógica de negocio, es compartir la experiencia completa cuando tiene sentido, y reservar el código nativo para lo que realmente necesita serlo.

  • Unidad 3 — Tema 2: Offline-first con Room: caché local y Repository pattern

    Unidad 3 — Tema 2: Offline-first con Room: caché local y Repository pattern

    En el artículo anterior de la serie (Unidad 3 — Tema 1), logramos conectar con éxito nuestra infraestructura a internet consumiendo una API real de noticias con Ktor Client. Pero si cierras la app y la abres sin conexión, no verás nada. Hoy lo arreglamos.

    El patrón que vamos a implementar se llama offline-first: la app siempre muestra primero lo que tiene en la base de datos local, luego intenta refrescar desde la red, y si falla, el usuario sigue viendo los datos cacheados. Al final del artículo tendrás un NewsRepository que combina Ktor y Room, y que emite noticias como un Flow reactivo.

    Lo que vamos a construir

    1. La base de datos Room: entidad, DAO y database en commonMain.
    2. Los builders nativos: cómo inicializar Room en Android e iOS (rutas diferentes).
    3. El Repository: combina red y caché con una estrategia offline-first.
    4. La fachada NewsSdk ampliada: expone el acceso offline-first a androidApp sin filtrar clases internas.

    1. Añade Room y KSP

    Room Multiplatform usa KSP para generar código en tiempo de compilación. Añade esto a gradle/libs.versions.toml:

    [versions]
    ksp  = "2.3.8"
    room = "2.8.4"
    sqlite = "2.6.2"
    
    [libraries]
    room-runtime  = { module = "androidx.room:room-runtime",  version.ref = "room" }
    room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
    sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
    
    [plugins]
    ksp  = { id = "com.google.devtools.ksp", version.ref = "ksp"  }
    room = { id = "androidx.room",           version.ref = "room" }

    En shared/build.gradle.kts, aplica los plugins y configura KSP para cada target:

    plugins {
        alias(libs.plugins.kotlinMultiplatform)
        alias(libs.plugins.kotlinxSerialization)
        alias(libs.plugins.ksp)
        alias(libs.plugins.room)
    }
    
    room {
        schemaDirectory("$projectDir/schemas")
    }
    
    kotlin {
        sourceSets {
            commonMain.dependencies {
                // ... dependencias de Ktor del tema anterior ...
                implementation(libs.room.runtime)
                implementation(libs.sqlite.bundled)
            }
        }
    }
    
    dependencies {
        add("kspCommonMainMetadata", libs.room.compiler)
        add("kspAndroid",            libs.room.compiler)
        add("kspIosArm64",           libs.room.compiler)
        add("kspIosSimulatorArm64", libs.room.compiler)
        add("kspIosX64",             libs.room.compiler)
    }

    2. Define la entidad, el DAO y la base de datos

    package dev.asiles.newsexplorer.data.local
    
    import androidx.room.*
    import kotlinx.coroutines.flow.Flow
    
    @Entity(tableName = "articles")
    data class ArticleEntity(
        @PrimaryKey val url: String,
        val title: String,
        val description: String,
        val imageUrl: String,
        val source: String,
        val publishedAt: String,
        val cachedAt: Long = System.currentTimeMillis()
    )
    
    @Dao
    interface ArticleDao {
        @Query("SELECT * FROM articles ORDER BY publishedAt DESC")
        fun getAll(): Flow<List<ArticleEntity>>
    
        @Insert(onConflict = OnConflictStrategy.REPLACE)
        suspend fun insertAll(articles: List<ArticleEntity>)
    
        @Query("DELETE FROM articles")
        suspend fun clearAll()
    }
    
    @Database(entities = [ArticleEntity::class], version = 1)
    @ConstructedBy(NewsDatabaseConstructor::class)
    abstract class NewsDatabase : RoomDatabase() {
        abstract fun articleDao(): ArticleDao
    }
    
    @Suppress("KotlinNoActualForExpect")
    expect object NewsDatabaseConstructor : RoomDatabaseConstructor<NewsDatabase> {
        override fun initialize(): NewsDatabase
    }

    Usamos la URL como @PrimaryKey: si la API devuelve el mismo artículo dos veces, REPLACE lo actualiza en lugar de duplicarlo. El expect object NewsDatabaseConstructor delega la construcción del archivo .db a cada plataforma; KSP genera los actual automáticamente.

    3. Inicializa la base de datos en cada plataforma

    package dev.asiles.newsexplorer.data.local
    
    import androidx.room.RoomDatabase
    
    interface DatabaseFactory {
        fun create(): RoomDatabase.Builder<NewsDatabase>
    }
    package dev.asiles.newsexplorer.data.local
    
    import android.content.Context
    import androidx.room.Room
    import androidx.room.RoomDatabase
    import androidx.sqlite.driver.bundled.BundledSQLiteDriver
    
    class AndroidDatabaseFactory(private val context: Context) : DatabaseFactory {
        override fun create(): RoomDatabase.Builder<NewsDatabase> {
            val dbFile = context.getDatabasePath("news_explorer.db")
            return Room.databaseBuilder<NewsDatabase>(
                context = context,
                name = dbFile.absolutePath
            ).setDriver(BundledSQLiteDriver())
        }
    }
    package dev.asiles.newsexplorer.data.local
    
    import androidx.room.Room
    import androidx.room.RoomDatabase
    import androidx.sqlite.driver.bundled.BundledSQLiteDriver
    import platform.Foundation.NSHomeDirectory
    
    class IosDatabaseFactory : DatabaseFactory {
        override fun create(): RoomDatabase.Builder<NewsDatabase> {
            val dbPath = "${NSHomeDirectory()}/Documents/news_explorer.db"
            return Room.databaseBuilder<NewsDatabase>(name = dbPath)
                .setDriver(BundledSQLiteDriver())
        }
    }

    En Room Multiplatform no basta con crear el builder: hay que fijar explícitamente BundledSQLiteDriver en ambos targets. Ese driver usa la implementación SQLite empaquetada por AndroidX y es la configuración recomendada para que el mismo esquema funcione de forma consistente en Android e iOS.

    4. Crea el modelo de dominio

    package dev.asiles.newsexplorer.domain.model
    
    data class Article(
        val url: String,
        val title: String,
        val description: String,
        val imageUrl: String,
        val source: String,
        val publishedAt: String
    )

    5. Implementa el Repository offline-first

    package dev.asiles.newsexplorer.data
    
    import dev.asiles.newsexplorer.data.local.ArticleDao
    import dev.asiles.newsexplorer.data.local.ArticleEntity
    import dev.asiles.newsexplorer.data.remote.NewsApiService
    import dev.asiles.newsexplorer.data.remote.model.ArticleDto
    import dev.asiles.newsexplorer.domain.model.Article
    import kotlinx.coroutines.flow.Flow
    import kotlinx.coroutines.flow.map
    
    class NewsRepository(
        private val apiService: NewsApiService,
        private val dao: ArticleDao
    ) {
        // La UI siempre observa este Flow. Cada vez que la BD cambia, emite automáticamente.
        val articles: Flow<List<Article>> = dao.getAll().map { entities ->
            entities.map { it.toDomain() }
        }
    
        // Llamar para refrescar desde la red. Si falla, los datos cacheados siguen visibles.
        suspend fun refresh() {
            val response = apiService.getTopHeadlines()
            val entities = response.articles
                .filter { it.title != "[Removed]" }
                .map { it.toEntity() }
            dao.clearAll()
            dao.insertAll(entities)
        }
    }
    
    private fun ArticleEntity.toDomain() = Article(
        url = url,
        title = title, 
        description = description,
        imageUrl = imageUrl,
        source = source,
        publishedAt = publishedAt
    )
    
    private fun ArticleDto.toEntity() = ArticleEntity(
        url = url,
        title = title,
        description = description.orEmpty(),
        imageUrl = imageUrl.orEmpty(),
        source = source.name,
        publishedAt = publishedAt
    )

    articles es un Flow que siempre lee de Room. Cuando llamas a refresh(), Room detecta el cambio y emite automáticamente la nueva lista. La UI no sabe si los datos vienen de la red o del disco.

    6. Amplía la fachada NewsSdk

    Recuerda la regla del proyecto: androidApp no puede importar clases de librerías de terceros directamente. Room y Ktor viven en shared. Para que MainActivity pueda usar el Repository sin tocar ninguna de esas clases, ampliamos la clase NewsSdk que creamos en el Tema 1:

    package dev.asiles.newsexplorer
    
    import dev.asiles.newsexplorer.data.NewsRepository
    import dev.asiles.newsexplorer.data.local.DatabaseFactory
    import dev.asiles.newsexplorer.data.remote.NewsApiService
    import dev.asiles.newsexplorer.data.remote.createHttpClient
    import dev.asiles.newsexplorer.domain.model.Article
    import kotlinx.coroutines.flow.Flow
    
    // Fachada pública del módulo compartido.
    // androidApp solo conoce esta clase; Room, Ktor y el Repository quedan encapsulados.
    class NewsSdk(apiKey: String, databaseFactory: DatabaseFactory) {
    
        private val repository = NewsRepository(
            apiService = NewsApiService(createHttpClient(apiKey)),
            dao = databaseFactory.create().build().articleDao()
        )
    
        // Flow de artículos cacheados — emite cada vez que Room actualiza la BD
        val articles: Flow<List<Article>> = repository.articles
    
        // Descarga noticias frescas y actualiza la caché
        suspend fun refresh() = repository.refresh()
    }

    DatabaseFactory es la única abstracción que sí puede cruzar la frontera hacia androidApp: es una interfaz definida en commonMain, sin dependencias de terceros. AndroidDatabaseFactory implementa esa interfaz en androidMain, donde Room sí es accesible.

    7. Pruébalo desde Android

    Ahora MainActivity solo importa clases del módulo compartido:

    import dev.asiles.newsexplorer.NewsSdk
    import dev.asiles.newsexplorer.data.local.AndroidDatabaseFactory
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.launch
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            val sdk = NewsSdk(
                apiKey = "TU_API_KEY",
                databaseFactory = AndroidDatabaseFactory(this)
            )
    
            CoroutineScope(Dispatchers.IO).launch {
                sdk.articles.collect { articles ->
                    articles.forEach { println("📰 ${it.title}") }
                }
            }
            CoroutineScope(Dispatchers.IO).launch {
                sdk.refresh()
            }
        }
    }

    Ninguna clase de Ktor ni de Room aparece en androidApp. AndroidDatabaseFactory implementa DatabaseFactory, que es una interfaz de commonMain: el compilador sí la ve desde androidApp. Ejecuta la app, verifica que aparecen noticias en Logcat, luego activa el modo avión y vuelve a ejecutar: las noticias cacheadas seguirán ahí.

    8. Enfoque senior: Room vs SQLDelight en KMP

    Room Multiplatform es la opción más cómoda si tu equipo viene del mundo Android, pero hay escenarios donde merece evaluar SQLDelight:

    • Usa Room si el equipo es principalmente Android y quieres la menor curva de aprendizaje. KSP genera los constructores actual automáticamente.
    • Considera SQLDelight si necesitas control total sobre SQL en compilación (SQLDelight valida las queries y genera tipos estrictos), o si tu proyecto incluye targets Desktop o Web.
    • Migraciones: guarda siempre el directorio schemas/ en control de versiones. Las migraciones hay que aplicarlas en ambos targets.
    • El patrón clearAll + insertAll es válido para listas sin estado local. Si el usuario puede marcar favoritos o leer artículos, necesitas una estrategia de merge basada en IDs únicos remotos.

    Para profundizar

    Conclusión

    El patrón offline-first no es solo una mejora de UX: es una decisión arquitectónica que define cómo fluye la verdad en tu aplicación. Al hacer de Room la única fuente de verdad y tratar la red como un mecanismo de actualización, el código se vuelve predecible: la UI siempre muestra lo que hay en la BD, y la BD siempre refleja lo más reciente disponible.

    Lo que falta es estructura: la inicialización manual en MainActivity escala mal. En el próximo artículo, Unidad 3 — Tema 3: ViewModel + Koin + Compose: la app funciona de principio a fin, conectaremos todas las piezas con Koin, moveremos la lógica a un ViewModel compartido en commonMain y mostraremos las noticias en una pantalla Compose Multiplatform real. La app estará completa de principio a fin.

  • Unidad 3 — Tema 1: Consumiendo una API real con Ktor en KMP

    Unidad 3 — Tema 1: Consumiendo una API real con Ktor en KMP

    En el artículo anterior de la serie (Unidad 2 — Tema 2), pusimos en práctica la interoperabilidad nativa implementando un lector de telemetría de hardware directamente sobre las APIs de Android SDK y CocoaTouch. Una vez consolidados los cimientos del lenguaje y la comunicación bidireccional, es hora de dar el salto al desarrollo de infraestructuras complejas y construir algo real: vamos a conectar News Explorer a una API de noticias real y ver los datos en pantalla.

    Al final de este artículo tendrás una función en commonMain que consulta la API de NewsAPI y devuelve un listado de artículos reales que podrás imprimir en consola desde Android. Pequeño, concreto, funcional.

    Lo que vamos a construir

    Una capa de red compartida con tres piezas:

    1. Los modelos de datos (DTOs): las clases que representan la respuesta JSON de la API.
    2. El cliente HTTP: configurado con Ktor en commonMain, con motores nativos para Android e iOS.
    3. El servicio de red: una clase que expone una función suspend que devuelve noticias reales.

    Nada de repositorios todavía, nada de base de datos, nada de ViewModels. Eso viene en los siguientes temas. Hoy, solo red.

    0. Consigue tu API Key

    Regístrate gratis en newsapi.org/register y copia tu API key. La necesitarás en unos minutos.

    1. Añade las dependencias

    Abre gradle/libs.versions.toml y añade las versiones y librerías de Ktor:

    [versions]
    ktor = "3.5.0"
    
    [libraries]
    ktor-client-core                = { module = "io.ktor:ktor-client-core",                    version.ref = "ktor" }
    ktor-client-okhttp              = { module = "io.ktor:ktor-client-okhttp",                   version.ref = "ktor" }
    ktor-client-darwin              = { module = "io.ktor:ktor-client-darwin",                   version.ref = "ktor" }
    ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation",     version.ref = "ktor" }
    ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json",     version.ref = "ktor" }
    
    [plugins]
    kotlinxSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }

    Ahora abre shared/build.gradle.kts y aplica el plugin de serialización y las dependencias según la plataforma:

    plugins {
        alias(libs.plugins.kotlinMultiplatform)
        alias(libs.plugins.kotlinxSerialization) // Necesario para @Serializable
    }
    
    kotlin {
        androidTarget()
        listOf(iosX64(), iosArm64(), iosSimulatorArm64()).forEach {
            it.binaries.framework { baseName = "shared"; isStatic = true }
        }
    
        sourceSets {
            commonMain.dependencies {
                implementation(libs.ktor.client.core)
                implementation(libs.ktor.client.content.negotiation)
                implementation(libs.ktor.serialization.kotlinx.json)
            }
            androidMain.dependencies {
                implementation(libs.ktor.client.okhttp)   // Motor HTTP para Android
            }
            iosMain.dependencies {
                implementation(libs.ktor.client.darwin)   // Motor HTTP nativo de iOS (NSURLSession)
            }
        }
    }

    La razón de separar los motores por plataforma es simple: en Android usamos OkHttp (el estándar de la JVM), y en iOS usamos el motor Darwin que se integra directamente con NSURLSession de Apple, respetando sus políticas de batería y red. El código que escribimos en commonMain no sabe nada de esto; Ktor lo gestiona internamente.

    2. Define los modelos de datos

    Crea los siguientes archivos en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/data/remote/model/.

    Primero, la respuesta raíz que devuelve la API:

    package dev.asiles.newsexplorer.data.remote.model
    
    import kotlinx.serialization.SerialName
    import kotlinx.serialization.Serializable
    
    @Serializable
    data class NewsResponseDto(
        @SerialName("status")       val status: String,
        @SerialName("totalResults") val totalResults: Int,
        @SerialName("articles")     val articles: List<ArticleDto>
    )

    Cada artículo individual:

    package dev.asiles.newsexplorer.data.remote.model
    
    import kotlinx.serialization.SerialName
    import kotlinx.serialization.Serializable
    
    @Serializable
    data class ArticleDto(
        @SerialName("source")      val source: SourceDto,
        @SerialName("author")      val author: String?,
        @SerialName("title")       val title: String,
        @SerialName("description") val description: String?,
        @SerialName("url")         val url: String,
        @SerialName("urlToImage")  val imageUrl: String?,
        @SerialName("publishedAt") val publishedAt: String,
        @SerialName("content")     val content: String?
    )
    
    @Serializable
    data class SourceDto(
        @SerialName("id")   val id: String?,
        @SerialName("name") val name: String
    )

    Nota el uso de String? en campos como author o description. La API de NewsAPI devuelve null en muchos de ellos, y si los declarásemos como String, la deserialización fallaría en tiempo de ejecución. Usar nullable types aquí es correctitud, no pesimismo.

    El plugin @Serializable genera en tiempo de compilación un serializador estático para cada clase. Esto significa que no hay reflexión en tiempo de ejecución, algo especialmente importante en iOS donde la reflexión de la JVM no existe.

    3. Crea el cliente HTTP y el servicio

    Crea el archivo NewsApiService.kt en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/data/remote/:

    package dev.asiles.newsexplorer.data.remote
    
    import dev.asiles.newsexplorer.data.remote.model.NewsResponseDto
    import io.ktor.client.HttpClient
    import io.ktor.client.call.body
    import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
    import io.ktor.client.plugins.defaultRequest
    import io.ktor.client.request.get
    import io.ktor.client.request.header
    import io.ktor.client.request.parameter
    import io.ktor.serialization.kotlinx.json.json
    import kotlinx.serialization.json.Json
    
    private const val BASE_URL = "https://newsapi.org/v2/"
    
    fun createHttpClient(apiKey: String): HttpClient {
        return HttpClient {
            install(ContentNegotiation) {
                json(Json {
                    ignoreUnknownKeys = true  // Si la API añade campos nuevos, no rompe la app
                    isLenient = true
                })
            }
            defaultRequest {
                url(BASE_URL)
                header("X-Api-Key", apiKey)
            }
        }
    }
    
    class NewsApiService(private val client: HttpClient) {
    
        suspend fun getTopHeadlines(category: String = "technology"): NewsResponseDto {
            return client.get("top-headlines") {
                parameter("category", category)
                parameter("country", "us")
            }.body()
        }
    }

    La función createHttpClient recibe la API key como parámetro en lugar de tenerla hardcodeada. Esto facilita los tests (puedes pasarle una key de prueba o un mock) y deja la responsabilidad de gestionar secretos fuera del módulo compartido.

    4. Pruébalo: imprime noticias reales en Android

    Aquí hay un detalle importante de la arquitectura KMP: el módulo androidApp no debe importar clases de Ktor directamente. Ktor vive en shared, y androidApp solo ve la API pública del módulo compartido. Si intentas usar HttpClient o createHttpClient desde MainActivity, el compilador se quejará con un error de classpath.

    La solución es exponer una función de fachada desde shared que encapsule la llamada. Crea este archivo en shared/src/commonMain/kotlin/dev/asiles/newsexplorer/:

    package dev.asiles.newsexplorer
    
    import dev.asiles.newsexplorer.data.remote.NewsApiService
    import dev.asiles.newsexplorer.data.remote.createHttpClient
    
    // Punto de entrada público del SDK compartido.
    // androidApp solo necesita conocer esta clase, no las clases internas de Ktor.
    class NewsSdk(apiKey: String) {
        private val service = NewsApiService(createHttpClient(apiKey))
    
        suspend fun fetchHeadlines(): List<String> {
            return service.getTopHeadlines().articles.map { it.title }
        }
    }

    Ahora sí puedes llamarlo desde MainActivity sin ningún problema de classpath:

    import dev.asiles.newsexplorer.NewsSdk
    import kotlinx.coroutines.CoroutineScope
    import kotlinx.coroutines.Dispatchers
    import kotlinx.coroutines.launch
    
    class MainActivity : ComponentActivity() {
        override fun onCreate(savedInstanceState: Bundle?) {
            super.onCreate(savedInstanceState)
    
            // Prueba temporal — lo eliminaremos cuando añadamos el ViewModel en el Tema 3
            CoroutineScope(Dispatchers.IO).launch {
                val sdk = NewsSdk(apiKey = "TU_API_KEY")
                sdk.fetchHeadlines().forEach { title ->
                    println("📰 $title")
                }
            }
        }
    }

    Ejecuta la app en un emulador o dispositivo Android y abre Logcat. Deberías ver algo así:

    📰 Google announces Kotlin Multiplatform support in Android Studio
    📰 JetBrains releases Kotlin 2.2 with new compiler features
    📰 Apple unveils Swift 6.1 interoperability improvements
    ...

    Si ves un error de red, asegúrate de tener el permiso de internet en tu AndroidManifest.xml:

    <uses-permission android:name="android.permission.INTERNET" />

    Por qué funciona así

    El módulo androidApp depende de shared, pero no tiene acceso directo a las dependencias transitivas de shared (como Ktor). Esto es deliberado: cada módulo solo expone lo que decide exponer. NewsSdk actúa como la frontera pública del módulo compartido hacia el mundo Android. Internamente puede usar Ktor, Room, o cualquier otra librería; desde fuera solo se ve una API limpia.

    Este patrón se repite en el Tema 3 cuando Koin gestiona todas las dependencias internamente y MainActivity solo necesita conocer el ViewModel.

    5. Enfoque senior: cuándo usar Ktor, cuándo evitarlo

    Ktor es la opción natural para proyectos KMP nuevos porque es la única librería HTTP con soporte oficial de JetBrains para todos los targets (Android, iOS, Desktop, WASM). Sin embargo, hay casos donde merece la pena evaluar alternativas:

    • Usa Ktor cuando tu proyecto es KMP desde el inicio y quieres una única capa de red compartida. También si necesitas WebSockets, SSE o streaming, donde Ktor brilla especialmente.
    • Evalúa Retrofit si tienes un módulo Android existente con Retrofit y no quieres migrar la capa de red. Retrofit no funciona en iOS, pero puedes mantenerlo en androidMain y definir una interfaz común en commonMain.
    • Evita Ktor si tu proyecto es 100% Android y no tienes planes de compartir código con iOS. En ese caso Retrofit + OkHttp tiene mejor ecosistema de herramientas de debugging (interceptors, Chuck, etc.).
    • Cuidado con la gestión de errores: en Ktor Client, la validación por código HTTP se controla de forma nativa con expectSuccess. Por defecto está en false, así que un 404 o 500 no lanza excepción automáticamente. Si activas expectSuccess = true, Ktor lanzará excepciones para respuestas no 2xx; y si necesitas reglas más finas, puedes complementarlo con HttpResponseValidator.

    Para profundizar

    Conclusión

    La capa de red que hemos construido sigue un principio clave: el código compartido no sabe en qué plataforma se ejecuta. commonMain define el contrato (cliente, DTOs, servicio), y cada plataforma aporta el motor HTTP adecuado. Esta separación no es solo arquitectónica; es lo que permite que el mismo código compile en la JVM, en LLVM (iOS) y potencialmente en WASM sin modificaciones.

    El punto débil de lo que tenemos hoy es la volatilidad: si el usuario no tiene conexión, la app no mostrará nada. En el próximo artículo, Unidad 3 — Tema 2: Offline-first con Room: caché local y Repository pattern, resolveremos esto con Room Multiplatform, añadiendo una capa de persistencia local que convierta nuestra app en offline-first: los datos siempre estarán disponibles, independientemente del estado de la red.

  • Unidad 2 — Tema 2: Escribiendo código específico de plataforma: Obteniendo información del dispositivo en iOS y Android

    Unidad 2 — Tema 2: Escribiendo código específico de plataforma: Obteniendo información del dispositivo en iOS y Android

    En el artículo anterior de la serie (Unidad 2 — Tema 1), desglosamos las bases conceptuales y reglas de visibilidad que rigen al compilador a la hora de enlazar declaraciones expect y actual. Con la teoría bien afianzada, es momento de bajar al barro y construir una solución real y práctica que pondría en producción cualquier equipo sénior de desarrollo móvil.

    ¿Cuántas veces has lidiado con un plugin de Cordova, Flutter o React Native que fallaba porque la abstracción de JavaScript/Dart no envolvía correctamente una API nativa recién actualizada? En el desarrollo web, estamos acostumbrados a que el navegador unifique las APIs del sistema mediante Web APIs estándar. Sin embargo, en el mundo móvil, cada sistema operativo es un universo soberano.

    Kotlin Multiplatform (KMP) aborda este problema con un enfoque radicalmente distinto: resuelve el enlace de estas APIs en compilación en lugar de construir un puente de UI multiplataforma en tiempo de ejecución. En lugar de empaquetar un bridge serializado, KMP utiliza el mecanismo expect/actual. Es un contrato en tiempo de compilación. No estás creando un wrapper; estás firmando un acuerdo con el compilador de Kotlin para que, cuando el código se compile para Android (JVM) o iOS (Kotlin/Native), se enlace directamente con los símbolos nativos de cada plataforma.

    En esta unidad, vamos a ensuciarnos las manos implementando un lector de metadatos del sistema de archivos y del dispositivo. Veremos cómo invocar directamente android.os.Build y el UIDevice de Apple desde nuestro código compartido, entendiendo qué pasa por debajo a nivel de compilador.

    1. El Contrato de Compilación: Anatomía de Expect y Actual

    A diferencia de los patrones de diseño tradicionales en la capa de aplicación (como el patrón Adapter o la Inyección de Dependencias en tiempo de ejecución), expect y actual operan a nivel de enlace de símbolos en tiempo de compilación.

    Cuando declaras una función o una clase como expect en tu source set commonMain, le estás diciendo al compilador: «Oye, este símbolo existe conceptualmente, pero su firma se resolverá específicamente en cada target configurado».

    • expect (en commonMain): Define la firma del método, clase o propiedad. No puede contener lógica de ejecución. Actúa como una interfaz estricta a nivel de compilador.
    • actual (en androidMain, iosMain, etc.): Proporciona la implementación real utilizando las dependencias del SDK nativo correspondiente.

    Si defines un expect y olvidas compilar su respectivo actual en uno de los targets activos, el compilador generará un error antes de que se produzca cualquier binario. No hay sorpresas en runtime.

    2. Implementación Práctica: Diseñando el Contrato en commonMain

    Para nuestro ejemplo, crearemos un lector de información del dispositivo que exponga el modelo del hardware y la versión del sistema operativo. Este componente es vital si estás diseñando sistemas de telemetría, analíticas o sincronización de archivos local donde el almacenamiento dependa de las restricciones de la API del OS.

    Primero, definimos nuestra interfaz de datos y el componente expect en src/commonMain/kotlin/:

    // commonMain
    package dev.asiles.kmp.series.hardware
    
    data class DeviceInfo(
        val model: String,
        val osVersion: String
    )
    
    /**
     * Contrato de compilación para obtener telemetría del sistema operativo.
     * El compilador forzará la resolución de este objeto en cada plataforma.
     */
    expect class DeviceInfoReader() {
        fun getSystemInfo(): DeviceInfo
    }

    3. Resolviendo el Target de Android: Accediendo a la JVM y al SDK de Android

    En el source set de Android (androidMain), Kotlin se compila a bytecode de la JVM (o posterior bytecode Dalvik/ART). Aquí tenemos acceso directo y transparente a todo el ecosistema de Java y a las APIs de Android proporcionadas por el Android SDK.

    Implementamos el actual en src/androidMain/kotlin/:

    // androidMain
    package dev.asiles.kmp.series.hardware
    
    import android.os.Build
    
    actual class DeviceInfoReader actual constructor() {
        
        actual fun getSystemInfo(): DeviceInfo {
            // Acceso directo a las propiedades estáticas de la API de Android
            val manufacturer = Build.MANUFACTURER
            val model = Build.MODEL
            val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
            
            val fullModelName = if (model.startsWith(manufacturer, ignoreCase = true)) {
                model.replaceFirstChar { it.uppercase() }
            } else {
                "${manufacturer.replaceFirstChar { it.uppercase() }} $model"
            }
            
            return DeviceInfo(
                model = fullModelName,
                osVersion = osVersion
            )
        }
    }

    Detalle de bajo nivel: Para el compilador, DeviceInfoReader en Android es simplemente una clase Java normal tras la compilación, eliminando cualquier capa intermedia de traducción.

    4. Resolviendo el Target de iOS: El Milagro del Interop Bidireccional de Kotlin/Native

    Aquí es donde ocurre la magia real de KMP. En iosMain, el código no corre sobre una máquina virtual; se compila directamente a binario nativo (a través del backend de LLVM). Kotlin/Native pre-importa los frameworks de Apple como UIKit, Foundation y Platform.

    Podemos instanciar clases de Objective-C/Swift directamente usando sintaxis de Kotlin. Implementamos el actual en src/iosMain/kotlin/:

    // iosMain
    package dev.asiles.kmp.series.hardware
    
    import platform.UIKit.UIDevice
    
    actual class DeviceInfoReader actual constructor() {
        
        actual fun getSystemInfo(): DeviceInfo {
            // Interoperabilidad directa con las APIs de CocoaTouch / UIKit
            val currentDevice = UIDevice.currentDevice
            
            val deviceModel = currentDevice.model // p.ej., "iPhone" o "iPad"
            val systemName = currentDevice.systemName // p.ej., "iOS"
            val systemVersion = currentDevice.systemVersion // p.ej., "17.4"
            
            return DeviceInfo(
                model = deviceModel,
                osVersion = "$systemName $systemVersion"
            )
        }
    }

    Detalle de bajo nivel: Kotlin/Native mapea los tipos de Objective-C a tipos de Kotlin. NSString se convierte automáticamente en kotlin.String, y las llamadas a propiedades como .model invocan los selectores nativos de Objective-C. Eso sí: UIDevice.model devuelve una categoría genérica de dispositivo (iPhone, iPad, iPod touch), no el identificador exacto de hardware tipo iPhone15,3. Si necesitas ese nivel de precisión, debes consultar otras APIs del sistema.

    5. Cuándo Usar expect/actual (y Cuándo Huir de Él)

    Como desarrolladores senior, sabemos que toda herramienta es un arma de doble filo. El mecanismo expect/actual es potentísimo, pero debe usarse con criterio arquitectónico.

    Cuándo usarlo:

    • APIs muy acopladas al OS: Sensores, Bluetooth, sistemas de archivos nativos, criptografía de bajo nivel (Keystore/Keychain).
    • Optimización extrema: Cuando necesitas exprimir el rendimiento de una API nativa específica sin abstracciones intermedias.

    Cuándo evitarlo (y qué usar en su lugar):

    • Lógica de negocio dependiente de la plataforma: Si estás usando expect/actual para cambiar el comportamiento de las reglas de negocio según el OS, estás rompiendo el principio de inversión de dependencias.
    • Inyección de Dependencias (DI) clásica: En lugar de saturar tu código de expect class, es mucho más limpio definir una interfaz estándar de Kotlin (interface DeviceInfoProvider) en commonMain. Luego, implementas esa interfaz mediante clases normales en los módulos nativos e inyectas la implementación correcta usando herramientas de DI como Koin o Inject. Esto desacopla tu código shared del compilador y facilita radicalmente los tests unitarios mediante mocks.

    Para profundizar

    Para comprender a fondo cómo el compilador gestiona estas directivas y las novedades en la flexibilización de restricciones, te recomiendo revisar directamente los canales oficiales:

    Conclusión

    El mecanismo expect/actual de Kotlin Multiplatform demuestra por qué esta tecnología se diferencia de sus competidores. No intenta aislarte del sistema operativo subyacente a través de un sandbox conceptual; te da un puente directo de acero para comunicarte con él.

    Al delegar la resolución de firmas al proceso de compilación estática, mantenemos el control absoluto de la plataforma, preservando el rendimiento nativo y logrando que nuestro código compartido sea, verdaderamente, un ciudadano de primera clase tanto en la JVM como en los entornos nativos de Apple.

    Con este tema cerramos con broche de oro la Unidad 2 sobre interoperabilidad y hardware nativo. Con los cimientos del lenguaje consolidados y el acceso bidireccional a las plataformas dominado, estamos listos para adentrarnos en la arquitectura de red móvil fullstack. En el próximo artículo, Unidad 3 — Tema 1: Consumiendo una API real con Ktor en KMP, iniciaremos nuestro bloque de datos e infraestructura conectando la aplicación a internet para descargar y deserializar noticias en tiempo real.

  • Unidad 2 — Tema 1: Interoperabilidad Nativa: Cómo usar expect/actual en KMP

    Unidad 2 — Tema 1: Interoperabilidad Nativa: Cómo usar expect/actual en KMP

    En el artículo anterior de la serie (Unidad 1 — Tema 3), desglosamos el esqueleto físico del monorepo fullstack y organizamos la estructura modular de directorios. Con las carpetas en su sitio, el gran reto es lograr que el código compartido dialogue fluidamente con el hardware e infraestructura de cada sistema operativo sin pagar penalizaciones de rendimiento.

    En el desarrollo multiplataforma, el Santo Grial es maximizar la reutilización de código sin perder el acceso a las capacidades nativas de cada sistema operativo. Kotlin Multiplatform (KMP) aborda este desafío de una manera radicalmente distinta a otros frameworks que introducen capas de abstracción pesadas o puentes de serialización en tiempo de ejecución. KMP prefiere resolver la interoperabilidad en tiempo de compilación.

    Cuando el compilador de Kotlin genera los binarios para Android (JVM) e iOS (Apple Silicon/Intel), necesita un mecanismo seguro para enlazar el código común con las APIs nativas de cada plataforma. Aquí es donde entra el mecanismo de expect/actual. No se trata de un simple reflejo o una inyección de dependencias tradicional en tiempo de ejecución; es un contrato estricto a nivel de compilador que nos permite declarar «expectativas» en nuestro código compartido y exigir «realidades» en el código de la plataforma. En este artículo, analizaremos a fondo cómo funciona este ciclo, sus reglas a nivel de arquitectura y cómo implementarlo de forma eficiente.

    1. El Mecanismo Expect/Actual a Nivel de Compilador

    A diferencia de los patrones de diseño tradicionales donde las abstracciones se resuelven dinámicamente, el par expect/actual funciona como un enlace en tiempo de compilación. Cuando declaras un elemento con la palabra clave expect en el source set commonMain, estás definiendo un contrato topológico. El compilador de Kotlin no generará el artefacto final a menos que cada una de las plataformas destino (como androidMain e iosMain) provea una implementación idéntica marcada con actual.

    Es importante entender que expect no es una interfaz. Mientras que una interfaz define un comportamiento que una clase debe implementar en tiempo de ejecución, expect puede aplicarse a clases, funciones, propiedades, enumerados o funciones de extensión. El compilador valida que las firmas (nombre, parámetros, tipos de retorno y modificadores de visibilidad) sean compatibles entre la declaración expect y las declaraciones actual. Si hay una discrepancia no permitida, la compilación fallará antes de generar cualquier binario.

    2. Reglas Estrictas y Restricciones de Visibilidad

    Para dominar este mecanismo, es crucial comprender las restricciones que impone el compilador de Kotlin para garantizar la seguridad de tipos:

    • Coincidencia de Firmas: Los nombres de los parámetros y sus tipos deben ser exactamente los mismos. No se permite cambiar el nombre de un argumento en la implementación actual, ya que esto rompería la resolución de argumentos con nombre (named arguments) en el código común.
    • Modificadores de Visibilidad: La declaración actual debe tener una visibilidad compatible con la expect. En Kotlin actual, eso significa que puede mantener la misma visibilidad o incluso ser más permisiva que el contrato esperado; lo que no puede hacer es ser más restrictiva y dejar inaccesible algo que el código común esperaba usar.
    • Restricción de Paquetes: Tanto la declaración expect como la actual deben residir exactamente en el mismo paquete (package) dentro de sus respectivos source sets. El compilador utiliza el nombre completamente cualificado (FQCN) para emparejarlos durante la fase de enlace.

    3. Ejemplo Práctico: Un Identificador Único de Dispositivo

    Veamos un ejemplo genérico pero de arquitectura sénior: obtener un identificador único de plataforma o el nombre del sistema operativo. Esto requiere acceder a las APIs nativas de Android e iOS, pero queremos exponer una propiedad limpia en nuestro dominio compartido.

    En commonMain (El Contrato):

    package dev.asiles.blog.core
    
    /**
     * Define el contrato para obtener información del entorno nativo.
     * El compilador garantizará que cada plataforma resuelva esta propiedad.
     */
    public expect class PlatformInfo() {
        public val operatingSystemName: String
        
        public fun logDebugInfo(): Unit
    }

    En androidMain (La Realidad en Android):

    package dev.asiles.blog.core
    
    import android.os.Build
    
    public actual class PlatformInfo {
        // Usamos la API nativa de Android (SDK de Java/Android)
        public actual val operatingSystemName: String = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
    
        public actual fun logDebugInfo() {
            android.util.Log.d("PlatformInfo", "Running on $operatingSystemName")
        }
    }

    En iosMain (La Realidad en iOS):

    package dev.asiles.blog.core
    
    import platform.UIKit.UIDevice
    
    public actual class PlatformInfo {
        // Acceso directo a las APIs de Apple a través del interop nativo de Kotlin/Native
        public actual val operatingSystemName: String = "${UIDevice.currentDevice.systemName} ${UIDevice.currentDevice.systemVersion}"
    
        public actual fun logDebugInfo() {
            // En iOS usamos la función de impresión estándar de Kotlin/Native que mapea a la consola nativa
            println("PlatformInfo: Running on $operatingSystemName")
        }
    }

    4. Enfoque Arquitectónico: Cuándo Usarlo y Cuándo Evitarlo

    El mecanismo expect/actual es extremadamente potente, pero en la arquitectura de un sistema de nivel empresarial, debe ser tu última opción, no la primera.

    Cuándo usarlo:

    • Para construir wrappers muy delgados sobre tipos primitivos o firmas de funciones que dependen directamente de tipos de la plataforma (por ejemplo, dar formato a una fecha, interactuar con tipos atómicos o inicializar una base de datos local como SQLDelight).
    • Cuando necesitas instanciar una clase nativa cuyo constructor requiere un contexto específico de la plataforma.

    Cuándo evitarlo (y qué usar en su lugar):

    • No lo uses para lógica de negocio pesada. Si creas grandes clases expect, estarás duplicando lógica y rompiendo el propósito de KMP.
    • Alternativa superior (Inyección de Dependencias / Inversión de Control): Para componentes arquitectónicos complejos (como clientes HTTP personalizados, gestores de Bluetooth o sistemas de almacenamiento), es mucho más limpio definir una interfaz estándar de Kotlin (interface) en commonMain e inyectar las implementaciones nativas mediante un framework de DI como Koin o mediante inyección manual en el punto de entrada de la aplicación. Esto facilita los tests unitarios en commonMain mediante el uso de dobles de prueba (mocks/fakes) sin depender del mecanismo de compilación.

    5. Type Aliasing: El Truco Avanzado de Rendimiento

    Existe una característica avanzada dentro del ecosistema actual que los desarrolladores senior deben aprovechar: los actual typealias. Si una plataforma ya tiene un objeto que cumple perfectamente con el contrato que necesitas, no es necesario crear una clase envoltorio (wrapper) que añada sobrecarga en tiempo de ejecución. Puedes simplemente mapearlo.

    Por ejemplo, si necesitas un tipo de dato para manejar UUIDs de forma nativa, puedes declarar un expect class UUID en el código común. En androidMain, en lugar de reimplementar la clase, puedes hacer:

    // En androidMain
    public actual typealias UUID = java.util.UUID

    Esto le dice al compilador que, cuando compile para Android, reemplace directamente cualquier uso de tu UUID compartido por la clase nativa optimizada de Java, eliminando cualquier capa de abstracción intermitente y manteniendo el rendimiento nativo al 100%.

    Para profundizar

    Para contrastar estos conceptos arquitectónicos con las especificaciones técnicas vigentes, recomiendo encarecially la lectura de los siguientes recursos de la documentación oficial de JetBrains:

    Conclusión

    El mecanismo expect/actual es uno de los pilares que hacen de Kotlin Multiplatform una tecnología tan robusta y respetuosa con el rendimiento nativo. Al delegar la resolución de contratos al tiempo de compilación en lugar de pagar el costo de rendimiento en tiempo de ejecución, KMP nos permite estructurar aplicaciones altamente optimizadas.

    Como buena práctica senior, mantén tus declaraciones expect lo más minimalistas posible. Trátalas como los puntos de anclaje de tu infraestructura y construye el resto de tu arquitectura (interfaces, casos de uso y ViewModels) sobre abstracciones puras en commonMain. Esto garantizará que tu base de código sea escalable, fácil de testear y preparada para añadir nuevas plataformas en el futuro con un esfuerzo mínimo.

    Una vez consolidado el plano teórico y las reglas de visibilidad del compilador, es hora de poner en práctica este mecanismo en un escenario real de producción. En el próximo artículo, Unidad 2 — Tema 2: Escribiendo código específico de plataforma: Obteniendo información del dispositivo en iOS y Android, nos arremangaremos para implementar un lector de telemetría de hardware conectando con las APIs nativas de Android SDK y CocoaTouch.

  • Unidad 1 — Tema 3: Estructura de un proyecto KMP: ¿Dónde vive mi código compartido?

    Unidad 1 — Tema 3: Estructura de un proyecto KMP: ¿Dónde vive mi código compartido?

    En el artículo anterior de la serie (Unidad 1 — Tema 2), pusimos a punto nuestro entorno de desarrollo y logramos tener listos Xcode, Android Studio y KDoctor para compilar en cualquier plataforma. Con la máquina ya configurada, es hora de dar el primer paso práctico: crear nuestro proyecto. Al abrir el repositorio multiplataforma por primera vez, es normal sentir cierto desconcierto.

    Si vienes del desarrollo web fullstack o de entornos backend distribuidos, estás acostumbrado a mitigar un problema histórico: la sincronización de contratos. Mantener los modelos de datos de tu API en TypeScript mapeados idénticamente con tus entidades del backend en Java, C# o Go suele requerir generadores de código o un mantenimiento manual propenso a fallos. Al aterrizar en un proyecto Kotlin Multiplatform (KMP) fullstack, la primera fricción mental desaparece al abrir el repositorio: no estamos ante una simple app móvil, sino ante un monorepo Gradle nativo que orquesta el backend, el navegador y los clientes nativos bajo una única fuente de verdad.

    El error más común al empezar con KMP es heredar los vicios de las arquitecturas móviles simples. Cuando el Kotlin Multiplatform Wizard oficial genera un proyecto con soporte para Servidor, Web, Desktop y Móvil, estructura el árbol de directorios de forma modular para escalar limpiamente. En este artículo vamos a destripar un ejemplo representativo de proyecto fullstack, entendiendo la separación de responsabilidades entre submódulos y cómo las entrañas de Gradle configuran el grafo de dependencias.

    1. El Grafo de SourceSets: La jerarquía del código multiplataforma

    A diferencia de un proyecto de software tradicional con su clásico src/main/kotlin, KMP introduce el concepto de SourceSets. Un SourceSet es una agrupación de código fuente, recursos y dependencias que apunta a un objetivo de compilación específico o a una abstracción intermedia en el árbol de compilación.

    El motor que orquesta esta magia es la configuración de estructura jerárquica (Hierarchical Structure Support) introducida por el plugin multiplataforma de Kotlin. En la cúspide de esta pirámide se encuentra el denominador común: commonMain.

    • commonMain: Es el núcleo agnóstico al entorno de ejecución. El código aquí escrito se compila usando el compilador común de Kotlin y solo puede acceder a la librería estándar y a dependencias multiplataforma puras (Ktor, kotlinx.serialization). No hay acceso a java.*, javax.*, ni a las APIs nativas del navegador o de iOS.
    • Plataformización dirigida: A medida que descendemos en el grafo, el compilador especializa el entorno. En los targets basados en la JVM (como el servidor o el escritorio), tienes acceso total al ecosistema Java. En los targets de Apple, el compilador Kotlin/Native expone los frameworks del sistema (Foundation, UIKit). Y en el target Web, el compilador genera JavaScript o WebAssembly (Wasm), dándote acceso directo al DOM de la página.

    2. Anatomía de la raíz: Desglosando el Monorepo Fullstack

    Al configurar un proyecto KMP que abarca todo el stack tecnológico, el Wizard organiza el código aislando los entornos de ejecución clientes dentro de un directorio paraguas y segregando la lógica pura. La estructura exacta puede variar según la versión del wizard y las opciones marcadas, así que el siguiente árbol debe leerse como un ejemplo posible y realista, no como una salida inmutable o literal de todas las versiones:

    ├─ app/                  # Capa de Presentación de los Clientes (Frontend)
    │   ├─ androidApp/       # Módulo Gradle: Punto de entrada para Android
    │   ├─ desktopApp/       # Módulo Gradle: Punto de entrada para Escritorio (JVM)
    │   ├─ iosApp/           # Proyecto Xcode nativo (Swift / SwiftUI)
    │   ├─ webApp/           # Módulo Gradle: Configuración para Kotlin/Wasm o Kotlin/JS
    │   └─ shared/           # UI Compartida (Compose Multiplatform) para todos los clientes
    ├─ core/                 # Lógica de negocio pura compartida entre CLIENTE y SERVIDOR
    ├─ server/               # Backend de la aplicación (Ktor / Entorno JVM)
    └─ gradle/
        └─ libs.versions.toml # Catálogo centralizado de dependencias (Version Catalog)

    El contenedor app/ y la UI compartida

    Las aplicaciones androidApp, desktopApp, iosApp y webApp suelen actuar como cascarones de inicialización. En una variante basada en Compose Multiplatform, la lógica de UI real (pantallas, componentes visuales, estados de vista) puede residir en app/shared/src/commonMain. Pero esa no es la única opción: otros proyectos mantienen toda la UI nativa en cada cliente y comparten solo datos y dominio. Lo importante es entender la responsabilidad de cada módulo, no memorizar un árbol cerrado.

    El módulo core/ (La frontera Fullstack)

    Este es el componente más crítico del sistema. Es un módulo KMP puro (sin dependencias de interfaz de usuario). Su único objetivo es albergar los modelos de datos (DTOs), reglas de validación comunes y configuraciones de red que consumirá tanto la UI en el cliente como el Backend en el servidor.

    3. Configuración del sistema de módulos en Gradle

    Para que este monorepo funcione sin acoplamientos cíclicos, la orquestación en los archivos de configuración de Gradle debe ser limpia y rigurosa. Todo comienza en la raíz del proyecto, donde le indicamos al motor de construcción la estructura modular.

    A nivel de código práctico, la vinculación jerárquica se declara en el archivo settings.gradle.kts:

    // settings.gradle.kts (Raíz del proyecto)
    rootProject.name = "Demo"
    
    // Módulos base independientes
    include(":core")
    include(":server")
    
    // Módulos anidados bajo el contenedor de clientes (app)
    include(":app:shared")
    include(":app:androidApp")
    include(":app:desktopApp")
    include(":app:webApp")
    

    La verdadera ventaja competitiva de este diseño se aprecia en cómo el módulo server consume al módulo core como una dependencia local interna. Al ser un entorno backend (por ejemplo, usando Ktor), su archivo build.gradle.kts se simplifica drásticamente:

    // server/build.gradle.kts
    plugins {
        alias(libs.plugins.kotlinJvm)
        alias(libs.plugins.ktor) // Plugin oficial de Ktor Server
    }
    
    dependencies {
        // Inyección directa de la lógica de negocio y contratos comunes
        api(projects.core)
        
        // Infraestructura del servidor
        implementation(libs.logback)
        implementation(libs.ktor.serverCore)
        implementation(libs.ktor.serverNetty) // Motor de ejecución embebido
    }

    4. Enfoque Arquitectónico Sénior: El Flujo de Datos Unificado

    Diseñar un monorepo fullstack con KMP introduce ventajas operativas enormes, pero exige disciplina arquitectónica para no convertir el proyecto en un monolito acoplado indescifrable.

    El flujo ideal del dato Compartido

    1. Una entidad de negocio UserSession se define en el módulo :core.
    2. El módulo :server procesa la autenticación, serializa UserSession a JSON y la expone en un endpoint.
    3. El módulo :app:shared (Frontend) realiza la petición HTTP a través de Ktor Client, recibe el JSON y, al tener :core mapeado como dependencia, deserializa automáticamente el flujo a la clase exacta UserSession. No hay mapeos intermedios ni archivos autogenerados en TypeScript.

    Cuándo evitar la compartición absoluta y usar aislamiento

    • Arquitecturas Backend complejas: Si tu servidor necesita conectarse a bases de datos relacionales vía Hibernate/Exposed o interactuar con colas de mensajería (Kafka, RabbitMQ), ese código jamás debe rozar el módulo :core. El módulo :core debe permanecer estéril, limitado a estructuras de datos puras y lógica computacional agnóstica.
    • Alternativas en Frontend: Si el rendimiento visual en la web es crítico y necesitas SEO indexable por motores de búsqueda tradicionales, forzar Compose Multiplatform vía WebAssembly en app/webApp puede ser contraproducente, ya que renderiza la UI en un <canvas>. Una alternativa senior es usar KMP en la web solo para la capa de datos (:core) y acoplarla a un framework JS nativo mediante Kotlin/JS.

    5. El proceso de entrega: Compilación multiplataforma en tiempo de ejecución

    Tener todos los módulos bajo el mismo paraguas de Gradle no significa que se compilen en un único ejecutable. El flujo de compilación segmenta los artefactos según sus naturalezas nativas:

    • Backend (:server): El compilador ejecuta kotlinJvm. Compila el código del servidor y el de :core en archivos .class estándar de Java, listos para ser empaquetados en un Fat JAR ejecutable sobre cualquier JRE 17 o superior.
    • Navegador (:app:webApp): El compilador traduce el código Kotlin a binarios de WebAssembly (.wasm) y genera un pegamento mínimo en JavaScript (.js) junto al archivo HTML index, optimizando el tamaño del bundle mediante el DCE (Dead Code Elimination).
    • Móviles (:app:androidApp y :app:iosApp): Android genera su bytecode intermedio optimizado por R8/D8, mientras que el pipeline de iOS genera el framework binario de Kotlin/Native que después enlaza Xcode. La herramienta cinterop no se usa para «encapsular» tu framework exportado, sino para generar bindings de Kotlin a librerías nativas externas cuando necesitas consumir APIs de C u Objective-C desde el código Kotlin.

    Para profundizar

    Para estudiar a fondo el comportamiento de los entornos de ejecución web, la integración de redes y la estructura modular oficial del ecosistema, consulta los portales de referencia:

    Conclusión

    El Wizard fullstack de Kotlin Multiplatform nos demuestra que el lenguaje ha madurado hacia una solución de ingeniería de software global. Al separar radicalmente la lógica de presentación de los clientes de las reglas e intercambio de datos del negocio, se consigue un desacoplamiento que respira las mejores prácticas de la arquitectura limpia. Mantener el módulo core libre de dependencias de plataforma es la mayor garantía de rendimiento y mantenibilidad que un arquitecto de software puede asegurar en este ecosistema.

    En el próximo artículo, Unidad 2 — Tema 1: Interoperabilidad Nativa: Cómo usar expect/actual en KMP, daremos el salto a la acción escribiendo nuestro primer código multiplataforma y aprendiendo cómo invocar de manera segura APIs del sistema nativo de Android e iOS utilizando las palabras clave expect y actual.

  • Unidad 1 — Tema 2: Configura tu entorno KMP sin morir en el intento

    Unidad 1 — Tema 2: Configura tu entorno KMP sin morir en el intento

    En el artículo anterior de la serie (Unidad 1 — Tema 1), exploramos por qué Kotlin Multiplatform no es otro clon de Flutter o React Native, sino un enfoque enfocado en compartir lógica de negocio pura conservando interfaces nativas. Sin embargo, antes de poder saborear los beneficios de esta arquitectura, hay un peaje inevitable que pagar: poner en marcha nuestras herramientas de trabajo.

    Todos hemos estado ahí. Encuentras una nueva tecnología espectacular, te entusiasmas con la documentación, decides crear tu primer proyecto y… ¡pum! Cientos de errores de dependencias, variables de entorno que faltan y problemas de compatibilidad. Es el famoso «funciona en mi máquina», pero al revés.

    Configurar un entorno de desarrollo para Kotlin Multiplatform (KMP) solía dar algo de respeto porque obliga a convivir a dos gigantes que históricamente se han llevado mal: el ecosistema de Google (Android) y el de Apple (iOS).

    Afortunadamente, el entorno ha madurado al máximo y hoy tenemos asistentes oficiales, plantillas modernas y herramientas de diagnóstico que ayudan mucho cuando algo falla. Vamos a configurar tu entorno The Right Way (de la forma correcta).

    1. Prerrequisito físico: El dilema del hardware

    Para desarrollar código compartido de KMP y probar la parte de Android, puedes usar Windows, Linux o macOS. Sin embargo, si quieres compilar y ejecutar la versión de iOS, la regla de Apple sigue siendo estricta: necesitas una Mac. El compilador de iOS (Xcode) solo corre sobre macOS.

    2. Las herramientas base (La interfaz visual)

    Para tener el entorno definitivo, necesitamos instalar tres piezas de software esenciales. Asegúrate de tenerlas actualizadas:

    • Android Studio: Sigue siendo el centro de control visual absoluto para los proyectos KMP. Es donde escribirás la inmensa mayoría de tu código compartido en Kotlin.
    • Xcode: Es el entorno oficial de Apple. KMP lo necesita obligatoriamente bajo el capó para usar sus compiladores y lanzar los simuladores de iPhone. Una vez instalado, ábrelo al menos una vez para aceptar los términos de la licencia e instalar sus componentes internos.
    • El Plugin de Kotlin Multiplatform: Dentro de Android Studio, ve a Settings -> Plugins, busca e instala Kotlin Multiplatform. Este plugin añade las plantillas de proyectos y te permitirá algo maravilloso: presionar el botón de «Play» en Android Studio y ejecutar la app tanto en el emulador de Android como en el simulador de iOS de forma directa.

    3. El entorno moderno: CLI y automatización

    Hoy en día no podemos configurar un entorno pensando solo en que un humano va a hacer «clic» en una pantalla. El desarrollo asistido por Inteligencia Artificial y agentes autónomos exige herramientas de línea de comandos robustas.

    Disponer de las utilidades nativas de comandos conectadas en tu sistema te permitirá ejecutar tareas Gradle de forma directa, arrancar emuladores de Android de forma autónoma desde tu terminal, instalar la app compilada desde tu módulo KMP y ejecutar pruebas automatizadas de extremo a extremo sin necesidad de mover el ratón. Tener tus rutas y herramientas CLI enlazadas en la consola es el pase de oro para la eficiencia y automatización del desarrollo moderno.

    4. Diagnóstico asistido: kdoctor

    Para evitar que pases horas adivinando por qué tu configuración falla, KDoctor sigue siendo una herramienta útil de consola que escanea tu sistema de arriba a abajo, encuentra qué le falta a tu configuración y te da pistas muy concretas para solucionarlo. Hoy no suele ser el flujo principal de entrada a KMP, porque el plugin y la documentación oficial cubren gran parte del camino, pero sí es un excelente apoyo cuando algo no cuadra.

    Cómo instalarlo y ejecutarlo (en tu terminal de Mac):

    brew install kdoctor
    kdoctor

    KDoctor analizará tu Sistema Operativo, tu versión de Java (JDK), Android Studio y Xcode. Te devolverá un reporte donde verás un check verde [✓] si todo está bien, o un error [✗] con la solución exacta en texto plano que debes copiar y pegar en tu terminal para arreglarlo. Si el asistente del plugin no te deja claro qué está fallando, aquí suele aparecer la pista que faltaba.

    5. El Checklist definitivo de variables de entorno

    Abre el archivo de configuración de tu terminal (.zshrc o .zprofile en Mac) y asegúrate de exportar correctamente las rutas del SDK, las herramientas de Java y los binarios de comandos para que tanto tú como los procesos de automatización de tu sistema puedan encontrarlos:

    export ANDROID_HOME=$HOME/Library/Android/sdk
    export JAVA_HOME=/Applications/Android Studio.app/Contents/jbr/Contents/Home
    export PATH=$PATH:$ANDROID_HOME/tools:$ANDROID_HOME/platform-tools:$ANDROID_HOME/cmdline-tools/latest/bin

    Para asegurar que Xcode sea totalmente accesible desde la consola por los procesos automatizados de compilación, ejecuta también este comando una vez:

    sudo xcode-select -s /Applications/Xcode.app/Contents/Developer

    6. Enfoque Sénior: El dilema de la Mac y alternativas de compilación remota

    Tener que comprar una Mac física solo para compilar e iniciar un simulador de iOS suele ser un obstáculo para muchos desarrolladores individuales y equipos pequeños orientados a Android. A nivel profesional, existen tres alternativas viables si necesitas compilar el binario nativo de iOS (.framework) sin contar con hardware Apple local:

    • Servidores de compilación en la nube (CI/CD): Herramientas como Codemagic, GitHub Actions (utilizando runners de macOS) o Bitrise permiten compilar el módulo de iOS en cada push de Git de manera desatendida.
    • Alquiler de Macs en la nube: Servicios como MacinCloud o MacStadium proporcionan acceso vía Escritorio Remoto a equipos Mac dedicados por una tarifa mensual muy competitiva.
    • Desacoplamiento total: Si trabajas en un equipo donde solo algunos programadores desarrollan la UI de iOS, puedes distribuir el módulo KMP como un paquete precompilado de Swift Package Manager (SPM) o integrarlo con otros mecanismos soportados por el proyecto. De este modo, los desarrolladores de Swift reducen la necesidad de compilar Kotlin continuamente en sus equipos.

    Para profundizar

    Si quieres entender a fondo la configuración oficial y las herramientas recomendadas, revisa estos enlaces:

    Conclusión

    Configurar el entorno de KMP ya no es una odisea de dependencias rotas. El ecosistema actual combina asistentes en IDE, Gradle, Xcode, integración mediante Swift Package Manager cuando encaja, y herramientas de apoyo como kdoctor para diagnosticar incidencias concretas. CocoaPods sigue siendo válido en proyectos que ya lo usan o en integraciones específicas; simplemente ya no es la única ruta que suele aparecer primero en un proyecto nuevo. El resultado es un entorno moderno, más limpio y mucho más predecible.

    Ahora que tu máquina tiene el entorno idóneo y los prerrequisitos de compilación están claros, el siguiente paso lógico es crear tu primer proyecto multiplataforma. En el próximo artículo, Unidad 1 — Tema 3: Estructura de un proyecto KMP: ¿Dónde vive mi código compartido?, abriremos el capó del monorepo generado por el Gradle Wizard para entender la jerarquía de SourceSets y cómo interactúan las dependencias comunes y de plataforma.

  • Unidad 1 — Tema 1: Por qué Kotlin Multiplatform no es otro Flutter/React Native y por qué deberías usarlo

    Unidad 1 — Tema 1: Por qué Kotlin Multiplatform no es otro Flutter/React Native y por qué deberías usarlo

    En el artículo anterior de la serie (Unidad 0 — Tema 4: Programación Reactiva: Domina StateFlow y SharedFlow), consolidamos los pilares fundamentales del lenguaje explorando el flujo asíncrono caliente. Con los cimientos de Kotlin asentados, hoy nos adentramos formalmente en la Unidad 1.

    Cuando escuchas las palabras «desarrollo multiplataforma», es normal que sientas un ligero escepticismo. La mente viaja automáticamente a las soluciones clásicas: React Native o Flutter. Historias de interfaces que a veces no se sienten del todo «nativas», peleas para acceder a las últimas API del sistema o la necesidad de adoptar un ecosistema gigantesco solo para lanzar una aplicación.

    Tanto Flutter como React Native son herramientas espectaculares que han madurado muchísimo, resolviendo gran parte de sus problemas históricos de rendimiento. Sin embargo, Kotlin Multiplatform (KMP) juega en una liga completamente diferente. No es un intento de unificar cómo se dibuja una pantalla; es una estrategia quirúrgica para compartir lo que realmente importa: la lógica de negocio.

    Vamos a abrir el capó para entender por qué el enfoque de KMP es tan distinto y revolucionario.

    1. La Gran Diferencia: Compartir código vs. Compartir UI

    Para entender el valor de KMP, primero debemos entender la filosofía de las alternativas actuales:

    • React Native: Ha dejado atrás el viejo y lento «puente» asíncrono basado en JSON. Sus arquitecturas modernas permiten que el código JavaScript interactúe directamente con las APIs del sistema operativo a través de enlaces ultra-rápidos en C++. Sigue enfocado en construir toda tu aplicación y su interfaz bajo su propio ecosistema.
    • Flutter: Apuesta por el control total de los píxeles. No utiliza los componentes visuales del sistema operativo; en su lugar, incluye su propio motor gráfico de alto rendimiento que dibuja la interfaz directamente en la pantalla. Es perfecto si buscas una consistencia absoluta (píxel por píxel) en cualquier dispositivo.
    • Kotlin Multiplatform: Da un paso al lado en la batalla por la UI. KMP te dice: «Puedes conservar tu UI nativa con Jetpack Compose o Views en Android y SwiftUI o UIKit en iOS; o incluso compartir parte de la UI con Compose Multiplatform si ese trade-off encaja en tu producto. Yo me encargaré de las bases de datos, las peticiones de red, los algoritmos y la encriptación, y te los entregaré de forma nativa en cada plataforma».

    2. ¿Cómo funciona bajo el capó? La ventaja de la compilación directa

    KMP no añade un motor de renderizado multiplataforma al estilo de Flutter ni una capa puente tipo JavaScript runtime para comunicar la lógica compartida con iOS y Android. Aun así, sí depende de los runtimes nativos de cada destino, como la JVM en Android o el runtime de Kotlin/Native en Apple. Su ventaja es que el código compartido se compila al formato que cada sistema operativo espera en lugar de ejecutarse dentro de un contenedor UI adicional.

    Cuando compilas tu módulo compartido, el compilador toma dos caminos separados:

                      ┌───► Compilador Kotlin/JVM ───► Bytecode (.class) ───► Android Nativo
                      │
    Módulo Compartido ┤
    (Código Kotlin)   │
                      └───► Compilador Kotlin/Native ───► LLVM ───► Framework Nativo (.framework) ───► iOS Nativo

    El camino hacia Android: Kotlin/JVM

    Para Android, el proceso es el de toda la vida. Tu código Kotlin se compila a Bytecode de la JVM. Se integra como cualquier otra librería Kotlin del ecosistema Android y aprovecha el runtime habitual de la plataforma.

    El camino hacia iOS: Kotlin/Native

    Aquí ocurre la verdadera magia. Como iOS no entiende de máquinas virtuales de Java, el compilador utiliza la infraestructura LLVM (la misma tecnología que usa Apple para compilar Swift). Tu código se transforma en un archivo binario .framework nativo. Xcode lo lee como una librería local más, permitiendo que el desarrollador de iOS invoque tus clases y funciones desde Swift con total fluidez.

    3. Cara a cara: Tabla comparativa real

    CaracterísticaReact NativeFlutterKotlin Multiplatform (KMP)
    LenguajeJavaScript / TypeScriptDartKotlin
    Interfaz de UIComponentes del sistema modificadosMotor gráfico propio (Canvas)Normalmente nativa (Compose/Views en Android, SwiftUI/UIKit en iOS), con opción de compartir UI en algunos escenarios mediante Compose Multiplatform
    RendimientoExcelente (comunicación directa C++)Excelente (renderizado directo)Muy cercano al nativo en la lógica compartida, al compilar para cada plataforma
    Adopción GradualRequiere una arquitectura completaRequiere una arquitectura completaExtremadamente fácil (puedes compartir desde una sola función)

    4. ¿Por qué el enfoque de KMP convence a los equipos de ingeniería?

    1. Cero compromisos en la Experiencia de Usuario (UX): Un usuario de iPhone nota al instante cuando las animaciones de rebote, los menús contextuales o el lector de pantalla para accesibilidad no son los nativos de Apple. Si decides mantener la interfaz en SwiftUI y Jetpack Compose, tu app seguirá el comportamiento esperado del sistema. Y si más adelante apuestas por Compose Multiplatform, esa decisión será explícita y localizada en la capa de presentación, no impuesta por KMP como requisito.
    2. Adopción incremental única: No tienes que tirar tu aplicación actual a la basura ni convencer a tu empresa de reescribirla por completo. Puedes crear un módulo KMP hoy mismo para gestionar, por ejemplo, el sistema de caché, inyectarlo en tus apps nativas actuales y seguir expandiéndolo poco a poco.
    3. Adiós a duplicar la lógica aburrida: Las validaciones de formularios, las reglas de negocio, los cálculos de precios o la sincronización con tu API se escriben una sola vez, se prueban con tests unitarios una sola vez y se comparten en todas partes.

    5. Enfoque Sénior: Cuándo elegir KMP y cuándo evitarlo

    Como arquitecto de software, la elección de KMP frente a frameworks híbridos UI-centric (como Flutter o React Native) o un enfoque nativo clásico debe basarse en métricas de negocio e infraestructura técnica, y no en preferencias de lenguaje:

    Cuándo elegir Kotlin Multiplatform:

    • Equipos con bases de código Android ya existentes: Si ya cuentas con un desarrollo nativo en Android maduro en Kotlin, adoptar KMP requiere un esfuerzo de reescritura bajísimo. Solo debes mover tu lógica de datos y negocio a un módulo común, manteniendo el 100% de tu UI en Android sin alterar.
    • Aplicaciones con alta dependencia del hardware y el diseño del sistema: Si tu app requiere integrar APIs de bajo nivel (sensores, Bluetooth, criptografía nativa) o requiere que la UI luzca 100% nativa en Android e iOS para pasar los estándares de accesibilidad o diseño de Apple.

    Cuándo evitar Kotlin Multiplatform (Alternativas):

    • Equipos sin experiencia en desarrollo móvil nativo: Si tu equipo está compuesto puramente por desarrolladores web que no conocen Xcode, Gradle ni los ciclos de vida móviles, frameworks como React Native o Flutter tienen una curva de aprendizaje menor para lanzar una app sencilla rápidamente.
    • Consistencia absoluta pixel-perfect: Si tu cliente exige que la aplicación se renderice idénticamente al pixel en cualquier versión y plataforma sin invertir tiempo adaptando componentes nativos, el motor gráfico de Flutter (Impeller/Skia) sigue siendo superior en este escenario.

    Para profundizar

    Si quieres ver las entrañas del proceso de compilación y la filosofía detrás del proyecto, dale un vistazo a estos enlaces:

    Conclusión

    Kotlin Multiplatform no viene a sustituir el desarrollo nativo; viene a potenciarlo. Te permite enfocarte en escribir interfaces hermosas y fluidas usando las herramientas oficiales de cada casa, o decidir conscientemente cuándo compartir también la UI, mientras elimina la pesadilla de tener dos equipos intentando replicar exactamente la misma lógica en lenguajes distintos. Es, probablemente, el enfoque más pragmático y eficiente de la ingeniería de software móvil actual.

    En la próxima entrega, Unidad 1 — Tema 2: Configura tu entorno KMP sin morir en el intento, arremangaremos las camisas para instalar y enlazar las herramientas base del sistema, variables de entorno y utilizaremos KDoctor como herramienta de diagnóstico complementaria para certificar que tu entorno está listo para el desarrollo multiplataforma profesional.

  • Unidad 0 — Tema 4: Programación Reactiva: Domina StateFlow y SharedFlow

    Unidad 0 — Tema 4: Programación Reactiva: Domina StateFlow y SharedFlow

    En el artículo anterior de la serie (Unidad 0 — Tema 3), desmitificamos la asincronía en Kotlin a través de las corrutinas, los despachadores y los ámbitos de ejecución estructurada. Sin embargo, procesar una única llamada de red de forma asíncrona es solo el principio.

    Si echamos la vista atrás, la forma en que manejamos la asincronía y la reactividad ha cambiado drásticamente. Atrás quedaron los tiempos de RxJava con sus complejas cadenas de operadores, e incluso LiveData ha pasado a formar parte del baúl de los recuerdos de Android.

    Hoy, en 2026, la API de Kotlin Flows es el estándar indiscutible. Cuando trabajamos en Kotlin Multiplatform (KMP), los flujos reactivos no son solo una opción para el módulo de Android; son la herramienta nativa con la que comunicamos la lógica de negocio compartida con las interfaces de usuario de Jetpack Compose y SwiftUI.

    Para dominar la arquitectura multiplataforma, necesitas entender a los dos reyes del flujo «caliente»: StateFlow y SharedFlow.

    1. El concepto clave: Flujos Fríos vs. Flujos Calientes

    Antes de entrar en detalle, una distinción rápida pero vital:

    • Flujos Fríos (Flow estándar): Solo emiten datos cuando alguien los está escuchando (collecting). Si hay tres observadores, la ejecución se repite tres veces desde el principio para cada uno. Son ideales para consultas a bases de datos o peticiones de red únicas.
    • Flujos Calientes (StateFlow / SharedFlow): Están activos y emitiendo datos independientemente de si hay pantallas escuchando o no. Son como estaciones de radio: la música sigue sonando aunque apagues el auto. Múltiples observadores pueden sintonizar el mismo flujo al mismo tiempo.

    2. La Batalla Definitiva: StateFlow vs. SharedFlow

    Aunque ambos son flujos calientes, tienen propósitos arquitectónicos completamente opuestos. Confundirlos suele provocar bugs visuales extraños.

    CaracterísticaStateFlowSharedFlow
    PropósitoRepresentar un Estado (¿Cómo se ve la pantalla?).Representar un Evento (¿Qué acaba de pasar?).
    Valor InicialObligatorio. Siempre debe empezar con un estado.No lo requiere.
    Memoria (Replay)Almacena y repite siempre el último valor emitido.Por defecto no almacena nada (configurable).
    FiltradoDescarta duplicados consecutivos (distinctUntilChanged).Emite absolutamente todo, incluso si es idéntico.

    3. Lógica Compartida en KMP: Un caso práctico

    Imaginemos un componente de lógica compartida (un ViewModel o Presenter común) que gestiona el estado de un perfil y dispara una alerta de un solo uso (como un mensaje flotante o Toast).

    class PerfilViewModel {
        // 1. STATEFLOW: Guarda el estado de la pantalla
        private val _uiState = MutableStateFlow<PerfilState>(PerfilState.Loading)
        val uiState: StateFlow<PerfilState> = _uiState.asStateFlow()
    
        // 2. SHAREDFLOW: Dispara eventos únicos que no deben repetirse si la pantalla gira
        private val _eventos = MutableSharedFlow<PerfilEvent>()
        val eventos: SharedFlow<PerfilEvent> = _eventos.asSharedFlow()
    
        suspend fun actualizarNombre(nuevoNombre: String) {
            if (nuevoNombre.isBlank()) {
                // El error es un evento. Se muestra una vez y listo.
                _eventos.emit(PerfilEvent.MostrarError("El nombre no puede estar vacío"))
                return
            }
            
            _uiState.value = PerfilState.Success(nombre = nuevoNombre)
        }
    }

    💡 Por qué esto es crucial: Si usaras un StateFlow para el mensaje de error, cuando el usuario rote el teléfono, la pantalla volvería a leer el último estado guardado y mostraría el mensaje de error otra vez, frustrando al usuario. Con SharedFlow, el evento se consume y desaparece.

    4. El consumo multiplataforma: Conectando con las Vistas

    Escribir el flujo en Kotlin es solo la mitad del trabajo. ¿Cómo se consumen en las plataformas de destino?

    En Android (Jetpack Compose)

    Compose se integra de manera orgánica con los flujos de Kotlin. Usando la extensión adecuada, la recolección se detiene automáticamente cuando la app pasa a segundo plano, ahorrando batería:

    val state by viewModel.uiState.collectAsStateWithLifecycle()

    En iOS (SwiftUI)

    En el desarrollo multiplataforma moderno, consumir estos flujos desde Swift ya no requiere acoplamientos complejos (wrappers) manuales.

    Herramientas de interoperabilidad como SKIE convierten automáticamente tus StateFlow de Kotlin en propiedades nativas que exponen objetos de tipo AsyncSequence o AsyncStream en Swift. Para un desarrollador de iOS, interactuar con tu lógica reactiva se siente idéntico a usar Async/Await nativo de Apple:

    Task {
        for await state in viewModel.uiState {
            self.render(state)
        }
    }

    5. Enfoque Sénior: Prevención de fugas de recursos en iOS al recolectar flujos

    Recolectar flujos reactivos de Kotlin en Swift a través de AsyncSequence es una gozada, pero introduce un riesgo severo de fugas de memoria y consumo de batería residual si la suscripción no se cancela al salir de la pantalla:

    • El ciclo de vida en SwiftUI: En Android, collectAsStateWithLifecycle() detiene la recolección automáticamente cuando la vista pasa a STOPPED. En SwiftUI, debes asociar la recolección asíncrona al modificador .task { } de SwiftUI. Este modificador cancela la Task de Swift automáticamente cuando la vista se destruye o se desmonta del árbol visual.
    • Suscripciones calientes persistentes: Si inicias una recolección manual de un StateFlow o SharedFlow de fondo y olvidas desvincularla, el recolector de basura de Kotlin (GC) no podrá liberar la instancia de la clase compartida debido a una referencia fuerte sostenida por la clausura de Swift.
    • Buenas prácticas: Declara siempre tus flujos compartidos en componentes vinculados al ciclo de vida de los controladores de vista y utiliza en Swift llamadas estructuradas ligadas al ciclo de vida visual de SwiftUI para evitar fugas silenciosas.

    Para profundizar

    La programación reactiva con flujos tiene reglas estrictas de concurrencia. Te sugiero revisar la documentación oficial para profundizar en operadores avanzados y testing:

    Conclusión

    Diseñar tu arquitectura multiplataforma basándote en la combinación de StateFlow (para estados persistentes) y SharedFlow (para eventos efímeros) garantiza una separación de conceptos limpia y predecible. Le estás dando a las vistas de Android y iOS un canal de comunicación asíncrono, robusto y optimizado para los ciclos de vida de cada sistema operativo.

    Con este tema cerramos con broche de oro la Unidad 0 de introducción avanzada a Kotlin. Tienes todas las bases del lenguaje dominadas: nulidad, tipos sellados, asincronía y reactividad. Ahora estamos listos para adentrarnos en el desarrollo multiplataforma real y crear nuestro primer monorepo. En el próximo artículo, Unidad 1 — Tema 1: Por qué Kotlin Multiplatform no es otro Flutter/React Native y por qué deberías usarlo, abriremos el debate arquitectónico sobre por qué KMP lidera la nueva era del desarrollo móvil híbrido.

  • Unidad 0 — Tema 3: Hilos y Asincronía: Tu primera guía de Corrutinas en Kotlin

    Unidad 0 — Tema 3: Hilos y Asincronía: Tu primera guía de Corrutinas en Kotlin

    En el artículo anterior de la serie (Unidad 0 — Tema 2), descubrimos el inmenso poder de las Sealed Interfaces para diseñar interfaces libres de estados imposibles o contradictorios. Pero, ¿de dónde provienen los datos que modelan esos estados? Generalmente provienen de llamadas asíncronas a APIs o bases de datos locales.

    Hacer que una aplicación se sienta fluida no es tarea fácil. Si ejecutas una operación pesada (como descargar un archivo de internet o procesar una base de datos) en el hilo principal, la pantalla se congelará y tu usuario desinstalará la app en tres segundos.

    Históricamente, la solución era crear hilos (Threads). Pero los hilos son pesados, consumen mucha memoria y gestionarlos es un dolor de cabeza. Aquí es donde entran las Corrutinas de Kotlin: la herramienta definitiva para manejar la asincronía de forma eficiente, elegante y, sobre todo, legible.

    En este post, derribaremos la complejidad de las corrutinas y entenderemos por qué son la columna vertebral del código asíncrono en Kotlin Multiplatform (KMP).

    1. Hilos vs. Corrutinas: ¿Por qué son tan eficientes?

    A menudo se describe a las corrutinas como «hilos ligeros», pero técnicamente no lo son.

    Un hilo tradicional está ligado directamente al sistema operativo. Crear uno requiere reservar memoria dedicada (generalmente 1 MB por hilo) y cambiar de un hilo a otro (context switch) es una operación costosa para la CPU. Si intentas lanzar 100,000 hilos a la vez, cualquier dispositivo colapsará de inmediato.

    Una corrutina, en cambio, es puramente una pieza de software gestionada por el entorno de ejecución de Kotlin. Es una tarea que se ejecuta sobre un hilo. Kotlin puede gestionar cientos de miles de corrutinas simultáneamente utilizando un número muy reducido de hilos reales. Cuando una corrutina se pausa, libera el hilo físico para que otra corrutina pueda usarlo.

    2. Los tres pilares fundamentales

    Para dominar las corrutinas, solo necesitas entender tres conceptos clave:

    A. Funciones de Suspensión (suspend)

    Es la palabra clave que cambia las reglas del juego. Una función marcada con suspend tiene la capacidad única de pausar su ejecución sin bloquear el hilo en el que corre.

    suspend fun descargarDatosDelServidor(): String {
        delay(2000) // Simula una espera de red de 2 segundos sin congelar la app
        return "Datos descargados"
    }

    Mientras delay espera, el hilo físico queda libre para hacer otras cosas (como renderizar la pantalla). Cuando el tiempo pasa, la función se «reanuda» exactamente donde se quedó.

    B. Los Despachadores (Dispatchers)

    Los Dispatchers le dicen a la corrutina en qué hilo o grupo de hilos debe ejecutarse. Kotlin nos proporciona tres opciones principales por defecto:

    • Dispatchers.Main: Vinculado al hilo principal de la interfaz de usuario. Se usa para actualizar la pantalla, animaciones o interacciones rápidas. ¡Nunca metas lógica pesada aquí!
    • Dispatchers.IO: Optimizado para operaciones de entrada/salida (I/O). Es el lugar ideal para peticiones de red, leer/escribir archivos o consultar bases de datos locales.
    • Dispatchers.Default: Diseñado para tareas intensivas de CPU. Si tienes que ordenar una lista gigantesca, procesar una imagen o realizar cálculos matemáticos complejos, este es tu sitio.

    C. El Alcance (CoroutineScope)

    Las corrutinas no pueden flotar libres en el espacio; necesitan un ciclo de vida que las controle. El CoroutineScope es el contenedor que realiza el seguimiento de todas las corrutinas que lanzas dentro de él.

    Si destruyes el contenedor (por ejemplo, si el usuario cierra una pantalla y destruyes su controlador), el Scope cancelará automáticamente todas las corrutinas activas en su interior, evitando por completo las fugas de memoria (memory leaks).

    3. Uniendo las piezas: Tu primera corrutina

    ¿Cómo se ve todo esto junto? Supongamos que estamos en un componente intermedio de nuestro código compartido en KMP:

    class RepositorioUsuario(private val scope: CoroutineScope) {
        fun cargarPerfil() {
            // Lanzamos una corrutina en el scope indicando el hilo inicial
            scope.launch(Dispatchers.IO) {
                // 1. Esto se ejecuta en un hilo de fondo (IO) sin congelar la app
                val datos = descargarDatosDelServidor() 
                
                withContext(Dispatchers.Main) {
                    // 2. Cambiamos de contexto al hilo principal para tocar la UI con seguridad
                    mostrarEnPantalla(datos)
                }
            }
        }
    }

    4. El superpoder en Kotlin Multiplatform (KMP)

    Cuando llevas esto a KMP, ocurre algo espectacular. Gracias a la librería oficial kotlinx.coroutines, el código asíncrono que escribes en tu módulo compartido funciona perfectamente tanto en Android como en iOS:

    • En Android, se integra de forma nativa con los hilos de la JVM y los ciclos de vida de Jetpack (como viewModelScope).
    • En iOS, el compilador de Kotlin/Native traduce los Dispatchers para que utilicen las colas de ejecución de Apple (Grand Central Dispatch o GCD), mapeando automáticamente Dispatchers.Main con el Main Queue de iOS de forma transparente.

    5. Enfoque Sénior: Bajo el capó de la concurrencia en Kotlin/Native y iOS

    Cuando utilizas corrutinas en un entorno multiplataforma, la forma en que los hilos y la concurrencia se comportan bajo el capó difiere enormemente entre sistemas operativos debido a sus motores de ejecución nativos:

    • JVM (Android/Backend): Kotlin Coroutines corre sobre hilos Java estándar gestionados por un pool de hilos dinámico y optimizado (ExecutorService). El cambio de contexto entre Dispatchers es nativo de la máquina virtual.
    • Kotlin/Native (iOS): iOS no tiene JVM. Las corrutinas mapean sus despachadores nativos a las colas de GCD (Grand Central Dispatch) de Apple. Dispatchers.Main se asocia al Main Queue, garantizando que cualquier actualización estética de la UI ocurra de forma segura, mientras que Dispatchers.Default y Dispatchers.IO se mapean de forma coordinada a colas globales en background de iOS.
    • El nuevo modelo de memoria: En versiones antiguas de Kotlin/Native, compartir variables mutables entre hilos requería congelar (freeze) objetos, provocando caídas en tiempo de ejecución. Desde la estabilización del nuevo modelo de memoria sin restricciones de concurrencia y recolector de basura automatizado compartido, podemos compartir estado de forma segura e idéntica a la JVM utilizando primitivas de concurrencia nativas.

    Para profundizar

    Las corrutinas tienen mucha tela que cortar. Si quieres dominar el control de flujos asíncronos y la concurrencia, revisa estos recursos oficiales:

    Conclusión

    Las corrutinas transforman el código asíncrono complejo y propenso a errores (lleno de callbacks anidados) en un código secuencial que se lee de arriba a abajo como si fuera síncrono. Al entender cómo interactúan las suspend functions, los Dispatchers y los Scopes, tienes en tus manos la clave para crear aplicaciones multiplataforma ultra eficientes.

    Pero lanzar una corrutina y procesar un resultado asíncrono único es solo el inicio. ¿Qué ocurre cuando necesitamos manejar un flujo de datos continuo y en tiempo real que reacciona a los cambios del sistema? En el próximo artículo, Unidad 0 — Tema 4: Programación Reactiva: Domina StateFlow y SharedFlow, profundizaremos en el paradigma reactivo utilizando flujos calientes de alto rendimiento.

  • Unidad 0 — Tema 2: Modelando datos sin errores: El poder de las Sealed Interfaces en Kotlin

    Unidad 0 — Tema 2: Modelando datos sin errores: El poder de las Sealed Interfaces en Kotlin

    En el artículo anterior de la serie (Unidad 0 — Tema 1), exploramos el escudo protector de Kotlin Null Safety y cómo sus garantías de nulidad estricta cruzan las fronteras de Android e iOS. Sin embargo, proteger variables individuales no es suficiente cuando la aplicación escala.

    Imagina diseñar una aplicación donde sea físicamente imposible representar un estado inválido. Olvídate de esos bugs extraños donde la pantalla muestra el icono de carga (loader) y, al mismo tiempo, el mensaje de error y los datos antiguos mezclados.

    En el desarrollo moderno, la arquitectura se basa en predecir estados precisos. Y en el ecosistema Kotlin, la herramienta definitiva para lograr esto son las jerarquías selladas: las Sealed Classes y Sealed Interfaces.

    Cuando trabajamos en Kotlin Multiplatform (KMP), estas estructuras se convierten en el contrato sagrado que define cómo fluyen los datos desde el código compartido hasta la interfaz de usuario en Android y iOS.

    1. ¿Qué significa que algo esté «sellado»?

    En Kotlin, anteponer la palabra clave sealed a una clase o interfaz significa restringir su árbol de herencia. Le estás diciendo al compilador: «Escucha bien, nadie fuera de este paquete/módulo puede crear subclases de esta interfaz».

    Esto le da al compilador el control total y absoluto de cuántas variantes existen de un tipo.

    ¿Sealed Class o Sealed Interface?

    Aunque nacieron en momentos distintos de la evolución de Kotlin, hoy conviven en armonía:

    • sealed class: Ideal si tus subtipos necesitan heredar comportamiento común o propiedades compartidas definidas en la propia clase base. Puede tener constructor primario, pero eso no implica constructores «mágicos» en las subclases: cada subtipo sigue declarando explícitamente cómo inicializa su propio estado.
    • sealed interface: Más ligera desde el punto de vista del modelo. Al ser una interfaz, una misma implementación puede cumplir con varios contratos a la vez. Es la opción favorita por defecto para modelar estados puros si no necesitas heredar lógica compleja.

    2. El combo perfecto: Estados de UI y Respuestas de Red

    El caso de uso estrella para las estructuras selladas es el manejo de peticiones asíncronas. En lugar de tener tres variables booleanas independientes (isLoading, hasError, data), unificamos todo en un único flujo de verdad:

    sealed interface UIState<out T> {
        object Loading : UIState<Nothing>
        data class Success<out T>(val data: T) : UIState<T>
        data class Error(val message: String) : UIState<Nothing>
    }

    Con este modelo, tu pantalla solo puede estar en uno de estos tres estados a la vez. No hay espacio para la ambigüedad.

    3. El when exhaustivo: Tu compilador es tu mejor QA

    La verdadera magia ocurre cuando combinas una interfaz sellada con la expresión when. Como el compilador conoce exactamente todas las opciones posibles, no te exige escribir una rama else.

    fun renderUI(state: UIState<String>) {
        when (state) {
            is UIState.Loading -> showLoader()
            is UIState.Success -> showContent(state.data)
            is UIState.Error -> showError(state.message)
            // ¡No hace falta poner un 'else'! El compilador sabe que no hay más opciones.
        }
    }

    💡 La ventaja competitiva: Si mañana decides añadir un nuevo estado llamado UIState.Empty, el proyecto dejará de compilar de inmediato en todos los lugares donde uses este when. El compilador te obligará a controlar el nuevo caso antes de lanzar la app, eliminando por completo los olvidos humanos.

    4. Cruzando el puente hacia iOS: ¿Qué pasa en Swift?

    Históricamente, este ha sido uno de los mayores dolores de cabeza en KMP. Como Objective-C (el puente intermedio clásico) no entiende el concepto de clases selladas, el compilador las traducía a clases normales herederas de una base. En Swift, los desarrolladores de iOS se veían obligados a escribir sentencias switch con un caso default obligatorio, perdiendo la verificación estática del compilador.

    Afortunadamente, las herramientas modernas han solucionado esto:

    El héroe actual: SKIE

    Herramientas de la comunidad altamente adoptadas como SKIE (desarrollada por Touchlab) analizan tu código Kotlin y generan automáticamente Enums de Swift con valores asociados (Enums with associated values).

    Para el desarrollador de iOS, consumir tu estado de Kotlin se siente 100% nativo:

    switch viewState {
        case .loading:
            showLoader()
        case .success(let content):
            showContent(content.data)
        case .error(let error):
            showError(error.message)
    } // ¡Exhaustivo y sin necesidad de un 'default'!

    El futuro: Swift Export

    JetBrains está desarrollando de forma nativa Swift Export, un mecanismo directo en el compilador de Kotlin que elimina el puente intermedio de Objective-C para que características potentes como los enums y las jerarquías de tipos se comuniquen de forma directa y transparente entre ambos lenguajes en los próximos años.

    5. Enfoque Sénior: Sealed Classes vs. Sealed Interfaces bajo el capó de la JVM

    Desde la perspectiva de diseño, la decisión entre sealed class y sealed interface debería apoyarse más en expresividad y restricciones del modelo que en supuestas ganancias de rendimiento difíciles de medir en la mayoría de aplicaciones.

    • Constructor y estado compartido: Una sealed class puede centralizar propiedades y lógica base en su constructor o en miembros concretos. Eso resulta útil cuando todos los subtipos comparten invariantes reales.
    • Composición de contratos: Las sealed interfaces permiten modelar estados o eventos sin imponer jerarquías de estado común, y además pueden combinarse con otras interfaces del dominio.
    • Cuándo elegir cada una: Por defecto, prefiere las Sealed Interfaces para modelar estados o eventos de la UI si solo necesitas variantes exhaustivas. Elige Sealed Classes cuando de verdad necesites compartir estado o comportamiento implementado entre subtipos.

    Para profundizar

    Si quieres ver las reglas exactas de compilación y cómo exprimir estas estructuras en tus proyectos, dale un vistazo a la documentación oficial:

    Conclusión

    Modelar tus datos usando sealed interface en un proyecto KMP eleva la calidad de todo el sistema. Estás exportando una arquitectura libre de estados imposibles que tanto el desarrollador de Android como el de iOS agradecerán profundamente. Menos condicionales defensivos, cero estados duplicados y toda la potencia del chequeo en tiempo de compilación.

    Pero modelar el estado no sirve de nada si no somos capaces de resolver de dónde proceden las peticiones asíncronas de red y bases de datos que lo mutan. En el próximo artículo, Unidad 0 — Tema 3: Hilos y Asincronía: Tu primera guía de Corrutinas en Kotlin, aprenderemos a dominar la suspensión y la concurrencia estructurada multiplataforma de forma legible y elegante.