TL;DR: cinterop genera bindings Kotlin de los headers Objective-C/C de iOS al instalar el toolchain — los klibs viven ya en ~/.konan/ y permiten importar platform.Network, platform.CoreLocation, etc. directamente, sin escribir Swift. Para exponerlos a commonMain el patrón es callbackFlow + awaitClose.
En androidMain existe ConnectivityManagerNetworkMonitor, una clase que envuelve ConnectivityManager y expone un Flow<Boolean> con el estado de conexión. Al implementar el actual para iOS aparece la pregunta inmediata: el equivalente Apple es NWPathMonitor del framework Network, pero es Objective-C/C. ¿Cómo se llama eso desde Kotlin sin tocar Swift?
La respuesta es cinterop.
Qué es cinterop
cinterop es el mecanismo de Kotlin/Native que genera bindings Kotlin a partir de headers C/Objective-C. La parte interesante es que para los frameworks oficiales de Apple no hay que configurar nada: al instalar el toolchain de Kotlin/Native, el compilador descarga klibs pre-generados para todos los frameworks de iOS en ~/.konan/.
Por eso esto compila sin ninguna configuración extra en build.gradle.kts:
import platform.Network.nw_path_monitor_create
import platform.CoreLocation.CLLocationManager
import platform.UIKit.UIApplication
Los klibs cubren platform.Foundation, platform.UIKit, platform.CoreLocation, platform.Network, platform.Contacts, platform.darwin… toda la superficie estándar de iOS.
Mapeo de C/Objective-C a Kotlin
cinterop aplica reglas consistentes al traducir símbolos nativos a Kotlin:
- Una función C como
nw_path_monitor_create()se expone como función top-level dentro del package que corresponde al framework:platform.Network.nw_path_monitor_create(). - Una constante o enum C como
nw_path_status_satisfiedse convierte en una constante top-level del mismo package. - Un bloque Objective-C del tipo
^(nw_path_t path) { ... }se mapea a una lambda Kotlin con la misma firma.
Con esas tres reglas, casi cualquier API Objective-C de Apple se puede traducir mentalmente a Kotlin antes de escribir nada.
El código: NWPathMonitorNetworkMonitor
package com.gasguru.core.data.util
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import platform.Network.nw_path_get_status
import platform.Network.nw_path_monitor_cancel
import platform.Network.nw_path_monitor_create
import platform.Network.nw_path_monitor_set_queue
import platform.Network.nw_path_monitor_set_update_handler
import platform.Network.nw_path_monitor_start
import platform.Network.nw_path_status_satisfied
import platform.darwin.dispatch_queue_create
@OptIn(ExperimentalForeignApi::class)
class NWPathMonitorNetworkMonitor(
private val ioDispatcher: CoroutineDispatcher,
) : NetworkMonitor {
override val isOnline: Flow<Boolean> = callbackFlow {
val monitor = nw_path_monitor_create()
val queue = dispatch_queue_create("com.gasguru.networkmonitor", null)
nw_path_monitor_set_update_handler(monitor) { path ->
trySend(nw_path_get_status(path) == nw_path_status_satisfied)
}
nw_path_monitor_set_queue(monitor, queue)
nw_path_monitor_start(monitor)
awaitClose { nw_path_monitor_cancel(monitor) }
}
.distinctUntilChanged()
.flowOn(ioDispatcher)
.conflate()
}
Tres detalles del código merecen explicación.
@OptIn(ExperimentalForeignApi::class) marca el cruce al modelo de memoria nativa. Cualquier llamada a un símbolo de platform.* lo exige. Esta anotación vive solo en iosMain — nunca debe acabar en commonMain, porque entonces se estaría exponiendo el modelo nativo al código compartido.
dispatch_queue_create es necesario porque NWPathMonitor no acepta un CoroutineDispatcher: exige una dispatch_queue_t nativa de GCD (Grand Central Dispatch). Esa función viene del package platform.darwin, otro klib pre-generado del toolchain.
awaitClose { nw_path_monitor_cancel(monitor) } es el punto crítico del puente con coroutines. callbackFlow permite emitir desde un callback nativo con trySend; awaitClose se ejecuta cuando el flow se cancela. Sin la llamada a nw_path_monitor_cancel dentro de awaitClose, el monitor sigue corriendo después de cerrar el flow — memory leak en cada cancelación y batería consumida en segundo plano.
Cuando un símbolo no aparece
A veces el autocomplete no muestra el símbolo esperado o no queda claro cómo se llama tras el mapeo. La forma fiable de saber qué exporta un klib es volcar su metadata:
~/.konan/kotlin-native-prebuilt-macos-aarch64-2.3.21/bin/klib dump-metadata \
~/.konan/kotlin-native-prebuilt-macos-aarch64-2.3.21/klib/platform/Network/Network.klib
El output lista funciones, tipos y constantes con sus firmas exactas en Kotlin. Es el atajo más directo cuando se sospecha que un símbolo existe pero no se encuentra desde el IDE.
Un caso concreto: CLPlacemark.postalAddress no es un miembro de clase ordinario — es una extension property top-level en el klib de CoreLocation. Sin el dump-metadata, el IDE no la sugiere y el error es Unresolved reference: postalAddress. Con el dump se confirma que existe y qué import exacto necesita.
Lo que cinterop no resuelve
cinterop cubre la mayor parte del trabajo, pero tiene tres límites claros.
Tipos forward-declared entre frameworks distintos. Un caso real: CLPlacemark.postalAddress declara como tipo de retorno objcnames/classes/CNPostalAddress?, mientras que CNPostalAddressFormatter.stringFromPostalAddress espera platform.Contacts.CNPostalAddress. Son el mismo tipo en Objective-C, pero el compilador los ve como dos clases diferentes y el cast falla con this cast can never succeed. La salida pragmática es renunciar al formatter y usar directamente los campos primitivos de CLPlacemark (thoroughfare, subThoroughfare, locality, postalCode), todos String? y sin ningún problema cross-framework.
Pods de terceros. cinterop solo trae los frameworks del sistema. Para integrar una librería de CocoaPods hay que configurar el bloque cocoapods { pod("Mixpanel") } en el Gradle del módulo, lo que añade su propio ciclo de generación de klibs por pod.
APIs solo-Swift. cinterop expone únicamente la superficie Objective-C. Cualquier clase, propiedad o función marcada como solo-Swift queda fuera del binding. Para acceder a esas APIs sigue haciendo falta escribir una capa Swift y llamarla desde Kotlin vía expect/actual.