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
- Módulos Koin: grafo de dependencias en
commonMainque conecta cliente HTTP, base de datos y repository. - NewsViewModel: gestiona el estado de la pantalla y expone un
StateFlow. - NewsListScreen: pantalla Compose Multiplatform en
commonMain, compartida entre Android e iOS. - Puntos de entrada nativos:
MainActivity(Android) yMainViewController(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/actualen 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 usarexpect/actualcon 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
keyyremember.
Para profundizar
- JetBrains — Create your Compose Multiplatform app: tutorial oficial paso a paso que cubre la configuración del plugin, los source sets y la ejecución en Android, iOS y Desktop.
- Koin — KMP Advanced Patterns: patrones avanzados de inyección en proyectos multiplatforma, incluyendo
KoinAndroidContext, módulos por plataforma y testing. - Kotlin — Share code on platforms: guía oficial sobre estrategias de compartición: qué va en
commonMain, qué en source sets específicos y cómo funciona la resoluciónexpect/actual.
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.

