En el artículo anterior de la serie (Unidad 2 — Tema 1), desglosamos las bases conceptuales y reglas de visibilidad que rigen al compilador a la hora de enlazar declaraciones expect y actual. Con la teoría bien afianzada, es momento de bajar al barro y construir una solución real y práctica que pondría en producción cualquier equipo sénior de desarrollo móvil.
¿Cuántas veces has lidiado con un plugin de Cordova, Flutter o React Native que fallaba porque la abstracción de JavaScript/Dart no envolvía correctamente una API nativa recién actualizada? En el desarrollo web, estamos acostumbrados a que el navegador unifique las APIs del sistema mediante Web APIs estándar. Sin embargo, en el mundo móvil, cada sistema operativo es un universo soberano.
Kotlin Multiplatform (KMP) aborda este problema con un enfoque radicalmente distinto: resuelve el enlace de estas APIs en compilación en lugar de construir un puente de UI multiplataforma en tiempo de ejecución. En lugar de empaquetar un bridge serializado, KMP utiliza el mecanismo expect/actual. Es un contrato en tiempo de compilación. No estás creando un wrapper; estás firmando un acuerdo con el compilador de Kotlin para que, cuando el código se compile para Android (JVM) o iOS (Kotlin/Native), se enlace directamente con los símbolos nativos de cada plataforma.
En esta unidad, vamos a ensuciarnos las manos implementando un lector de metadatos del sistema de archivos y del dispositivo. Veremos cómo invocar directamente android.os.Build y el UIDevice de Apple desde nuestro código compartido, entendiendo qué pasa por debajo a nivel de compilador.
1. El Contrato de Compilación: Anatomía de Expect y Actual
A diferencia de los patrones de diseño tradicionales en la capa de aplicación (como el patrón Adapter o la Inyección de Dependencias en tiempo de ejecución), expect y actual operan a nivel de enlace de símbolos en tiempo de compilación.
Cuando declaras una función o una clase como expect en tu source set commonMain, le estás diciendo al compilador: «Oye, este símbolo existe conceptualmente, pero su firma se resolverá específicamente en cada target configurado».
expect(encommonMain): Define la firma del método, clase o propiedad. No puede contener lógica de ejecución. Actúa como una interfaz estricta a nivel de compilador.actual(enandroidMain,iosMain, etc.): Proporciona la implementación real utilizando las dependencias del SDK nativo correspondiente.
Si defines un expect y olvidas compilar su respectivo actual en uno de los targets activos, el compilador generará un error antes de que se produzca cualquier binario. No hay sorpresas en runtime.
2. Implementación Práctica: Diseñando el Contrato en commonMain
Para nuestro ejemplo, crearemos un lector de información del dispositivo que exponga el modelo del hardware y la versión del sistema operativo. Este componente es vital si estás diseñando sistemas de telemetría, analíticas o sincronización de archivos local donde el almacenamiento dependa de las restricciones de la API del OS.
Primero, definimos nuestra interfaz de datos y el componente expect en src/commonMain/kotlin/:
// commonMain
package dev.asiles.kmp.series.hardware
data class DeviceInfo(
val model: String,
val osVersion: String
)
/**
* Contrato de compilación para obtener telemetría del sistema operativo.
* El compilador forzará la resolución de este objeto en cada plataforma.
*/
expect class DeviceInfoReader() {
fun getSystemInfo(): DeviceInfo
}
3. Resolviendo el Target de Android: Accediendo a la JVM y al SDK de Android
En el source set de Android (androidMain), Kotlin se compila a bytecode de la JVM (o posterior bytecode Dalvik/ART). Aquí tenemos acceso directo y transparente a todo el ecosistema de Java y a las APIs de Android proporcionadas por el Android SDK.
Implementamos el actual en src/androidMain/kotlin/:
// androidMain
package dev.asiles.kmp.series.hardware
import android.os.Build
actual class DeviceInfoReader actual constructor() {
actual fun getSystemInfo(): DeviceInfo {
// Acceso directo a las propiedades estáticas de la API de Android
val manufacturer = Build.MANUFACTURER
val model = Build.MODEL
val osVersion = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
val fullModelName = if (model.startsWith(manufacturer, ignoreCase = true)) {
model.replaceFirstChar { it.uppercase() }
} else {
"${manufacturer.replaceFirstChar { it.uppercase() }} $model"
}
return DeviceInfo(
model = fullModelName,
osVersion = osVersion
)
}
}
Detalle de bajo nivel: Para el compilador, DeviceInfoReader en Android es simplemente una clase Java normal tras la compilación, eliminando cualquier capa intermedia de traducción.
4. Resolviendo el Target de iOS: El Milagro del Interop Bidireccional de Kotlin/Native
Aquí es donde ocurre la magia real de KMP. En iosMain, el código no corre sobre una máquina virtual; se compila directamente a binario nativo (a través del backend de LLVM). Kotlin/Native pre-importa los frameworks de Apple como UIKit, Foundation y Platform.
Podemos instanciar clases de Objective-C/Swift directamente usando sintaxis de Kotlin. Implementamos el actual en src/iosMain/kotlin/:
// iosMain
package dev.asiles.kmp.series.hardware
import platform.UIKit.UIDevice
actual class DeviceInfoReader actual constructor() {
actual fun getSystemInfo(): DeviceInfo {
// Interoperabilidad directa con las APIs de CocoaTouch / UIKit
val currentDevice = UIDevice.currentDevice
val deviceModel = currentDevice.model // p.ej., "iPhone" o "iPad"
val systemName = currentDevice.systemName // p.ej., "iOS"
val systemVersion = currentDevice.systemVersion // p.ej., "17.4"
return DeviceInfo(
model = deviceModel,
osVersion = "$systemName $systemVersion"
)
}
}
Detalle de bajo nivel: Kotlin/Native mapea los tipos de Objective-C a tipos de Kotlin. NSString se convierte automáticamente en kotlin.String, y las llamadas a propiedades como .model invocan los selectores nativos de Objective-C. Eso sí: UIDevice.model devuelve una categoría genérica de dispositivo (iPhone, iPad, iPod touch), no el identificador exacto de hardware tipo iPhone15,3. Si necesitas ese nivel de precisión, debes consultar otras APIs del sistema.
5. Cuándo Usar expect/actual (y Cuándo Huir de Él)
Como desarrolladores senior, sabemos que toda herramienta es un arma de doble filo. El mecanismo expect/actual es potentísimo, pero debe usarse con criterio arquitectónico.
Cuándo usarlo:
- APIs muy acopladas al OS: Sensores, Bluetooth, sistemas de archivos nativos, criptografía de bajo nivel (Keystore/Keychain).
- Optimización extrema: Cuando necesitas exprimir el rendimiento de una API nativa específica sin abstracciones intermedias.
Cuándo evitarlo (y qué usar en su lugar):
- Lógica de negocio dependiente de la plataforma: Si estás usando
expect/actualpara cambiar el comportamiento de las reglas de negocio según el OS, estás rompiendo el principio de inversión de dependencias. - Inyección de Dependencias (DI) clásica: En lugar de saturar tu código de
expect class, es mucho más limpio definir una interfaz estándar de Kotlin (interface DeviceInfoProvider) encommonMain. Luego, implementas esa interfaz mediante clases normales en los módulos nativos e inyectas la implementación correcta usando herramientas de DI como Koin o Inject. Esto desacopla tu código shared del compilador y facilita radicalmente los tests unitarios mediante mocks.
Para profundizar
Para comprender a fondo cómo el compilador gestiona estas directivas y las novedades en la flexibilización de restricciones, te recomiendo revisar directamente los canales oficiales:
- Connect to platform-specific APIs | Kotlin Documentation
- Expected and actual declarations | Kotlin Language Specification
- Interoperability with Objective-C/Swift | Kotlin/Native
Conclusión
El mecanismo expect/actual de Kotlin Multiplatform demuestra por qué esta tecnología se diferencia de sus competidores. No intenta aislarte del sistema operativo subyacente a través de un sandbox conceptual; te da un puente directo de acero para comunicarte con él.
Al delegar la resolución de firmas al proceso de compilación estática, mantenemos el control absoluto de la plataforma, preservando el rendimiento nativo y logrando que nuestro código compartido sea, verdaderamente, un ciudadano de primera clase tanto en la JVM como en los entornos nativos de Apple.
Con este tema cerramos con broche de oro la Unidad 2 sobre interoperabilidad y hardware nativo. Con los cimientos del lenguaje consolidados y el acceso bidireccional a las plataformas dominado, estamos listos para adentrarnos en la arquitectura de red móvil fullstack. En el próximo artículo, Unidad 3 — Tema 1: Consumiendo una API real con Ktor en KMP, iniciaremos nuestro bloque de datos e infraestructura conectando la aplicación a internet para descargar y deserializar noticias en tiempo real.


