Coroutines en commonMain — funcionan igual
kotlinx-coroutines-core es multiplataforma. Todo lo que sabés de coroutines en Android funciona en commonMain: suspend, Flow, launch, async, withContext, StateFlow, SharedFlow. La única diferencia es en los Dispatchers.
// commonMain — código de coroutines idéntico al que usarías en Android
class ProductoRepositoryImpl(
private val api: ProductoApi,
private val local: ProductoLocalDataSource
) : ProductoRepository {
// suspend function — funciona en todas las plataformas
override suspend fun sincronizar(): Resultado<Unit> {
return try {
val productos = api.getProductos()
local.reemplazarTodos(productos.map { it.toDomain() })
Resultado.Exito(Unit)
} catch (e: Exception) {
Resultado.Error(e.message ?: "Error desconocido", e)
}
}
// Flow — funciona en todas las plataformas
override fun getProductos(): Flow<List<Producto>> {
return local.observarProductos()
}
// flow builder — igual que en Android
fun getProductosConRefresh(): Flow<Resultado<List<Producto>>> = flow {
emit(Resultado.Cargando)
try {
sincronizar()
emitAll(local.observarProductos().map { Resultado.Exito(it) })
} catch (e: Exception) {
emit(Resultado.Error(e.message ?: "", e))
}
}
}
Dispatchers multiplataforma
Los Dispatchers de Android (Dispatchers.IO, Dispatchers.Main) no existen en iOS. Hay que usar expect/actual para proveer el dispatcher correcto en cada plataforma, o usar solo los que son verdaderamente multiplataforma:
// Dispatchers disponibles en commonMain (sin expect/actual):
// Dispatchers.Default — thread pool de CPU, disponible en todas las plataformas
// Dispatchers.Unconfined — sin confinamiento, disponible en todas
// Dispatchers NO disponibles en commonMain:
// Dispatchers.IO — solo Android/JVM
// Dispatchers.Main — solo cuando hay un main thread (Android, iOS, desktop)
// Solución 1: expect/actual para el dispatcher de I/O
expect val ioDispatcher: CoroutineDispatcher
// androidMain:
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
// iosMain:
actual val ioDispatcher: CoroutineDispatcher = Dispatchers.Default
// iOS no tiene I/O dispatcher dedicado — Default es el equivalente más cercano
// Solución 2: usar Dispatchers.Default directamente
// Ktor y SQLDelight ya manejan el threading internamente,
// por lo que en la mayoría de los casos no necesitás withContext en el shared module
// Solución 3 (recomendada): no cambiar de dispatcher en commonMain
// Dejar que Android/iOS manejen el threading al consumir el shared module
// El ViewModel de Android usa viewModelScope (Main) y llama al use case
// El use case llama al repo que llama a Ktor — Ktor maneja el hilo internamente
Flow como contrato entre shared y UI
Flow es la forma más limpia de exponer datos reactivos desde el shared module. Android consume el Flow directamente con collectAsStateWithLifecycle. iOS necesita un adaptador porque Swift no entiende Kotlin Flow nativamente.
// commonMain — exponer Flow desde el shared module
class ProductosViewModel(
private val getProductos: GetProductosUseCase,
private val sincronizar: SincronizarUseCase,
private val scope: CoroutineScope // el scope se pasa desde cada plataforma
) {
// StateFlow como estado central
private val _uiState = MutableStateFlow(ProductosUiState())
val uiState: StateFlow<ProductosUiState> = _uiState.asStateFlow()
init {
scope.launch {
getProductos()
.catch { e -> _uiState.update { it.copy(error = e.message) } }
.collect { productos ->
_uiState.update { it.copy(productos = productos, isLoading = false) }
}
}
scope.launch { sincronizar() }
}
fun onPullRefresh() {
scope.launch {
_uiState.update { it.copy(isLoading = true) }
sincronizar()
}
}
}
data class ProductosUiState(
val productos: List<Producto> = emptyList(),
val isLoading: Boolean = true,
val error: String? = null
)
// En Android — consumir directamente:
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// En iOS — necesitás un wrapper porque Swift no entiende Flow (ver lección 07)
El problema con iOS y coroutines
El mayor desafío de KMP para devs Android: Swift no puede consumir suspend functions ni Flow directamente. Kotlin compila a Objective-C para iOS, y las coroutines no tienen equivalente directo en ese mundo.
// El problema: desde Swift, esto no funciona directamente
// val productos = repository.getProductos().collect { ... } // No compila en Swift
// Solución 1: SKIE (Swift Kotlin Interface Enhancer) — la más limpia
// Una librería que hace que suspend functions y Flow funcionen nativamente en Swift
// https://skie.touchlab.co
// Con SKIE, desde Swift podés hacer:
// for await producto in repository.getProductos() { ... }
// Solución 2: wrapper manual con callbacks
// Exponer una función que acepta callbacks en lugar de retornar Flow
class ProductosViewModelWrapper(
private val viewModel: ProductosViewModel
) {
fun observarUiState(
onUpdate: (ProductosUiState) -> Unit,
onError: (String) -> Unit
): () -> Unit { // retorna función de cancelación
val job = viewModel.scope.launch {
viewModel.uiState.collect { state ->
onUpdate(state)
}
}
return { job.cancel() }
}
}
// Desde Swift:
// let cancelar = wrapper.observarUiState(
// onUpdate: { state in self.productos = state.productos },
// onError: { error in self.errorMensaje = error }
// )
// Solución 3: KMP-NativeCoroutines (librería de Touchlab)
// Genera extensiones Swift automáticamente para todas las suspend functions y Flows
Testing con coroutines en KMP
// commonTest — los tests corren en la JVM (Android) y en iOS simulator
// La misma API de testing que en Android: runTest, TestCoroutineDispatcher
class ProductoRepositoryTest {
private val fakeApi = FakeProductoApi()
private val fakeLocal = FakeProductoLocalDataSource()
private val repository = ProductoRepositoryImpl(fakeApi, fakeLocal)
@Test
fun `sincronizar guarda productos de la API en local`() = runTest {
fakeApi.setProductos(listOf(
ProductoDto(1, "Mouse", 2000.0, null, "img.jpg"),
ProductoDto(2, "Teclado", 5000.0, null, "img2.jpg")
))
repository.sincronizar()
assertEquals(2, fakeLocal.getProductos().size)
assertEquals("Mouse", fakeLocal.getProductos()[0].nombre)
}
@Test
fun `getProductos emite desde la fuente local`() = runTest {
fakeLocal.setProductos(listOf(Producto(1, "Mouse", 2000.0, null, "img.jpg")))
val productos = repository.getProductos().first()
assertEquals(1, productos.size)
}
}
// Los tests de commonTest se compilan a JVM y también a Native (iOS)
// y deben correr en ambas plataformas exitosamente