Etiqueta: Compose Multiplatform

  • 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.