Blog
KMP Kotlin May 31, 2026

cinterop en KMP: envolver NWPathMonitor con callbackFlow

Generar bindings Kotlin con cinterop, envolver NWPathMonitor del framework Network, y exponerlo como Flow en commonMain con callbackFlow y awaitClose

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_satisfied se 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.