Etiqueta: Native Interop

  • Unidad 2 — Tema 2: Escribiendo código específico de plataforma: Obteniendo información del dispositivo en iOS y Android

    Unidad 2 — Tema 2: Escribiendo código específico de plataforma: Obteniendo información del dispositivo en iOS y Android

    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 (en commonMain): 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 (en androidMain, 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/actual para 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) en commonMain. 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:

    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.

  • Unidad 2 — Tema 1: Interoperabilidad Nativa: Cómo usar expect/actual en KMP

    Unidad 2 — Tema 1: Interoperabilidad Nativa: Cómo usar expect/actual en KMP

    En el artículo anterior de la serie (Unidad 1 — Tema 3), desglosamos el esqueleto físico del monorepo fullstack y organizamos la estructura modular de directorios. Con las carpetas en su sitio, el gran reto es lograr que el código compartido dialogue fluidamente con el hardware e infraestructura de cada sistema operativo sin pagar penalizaciones de rendimiento.

    En el desarrollo multiplataforma, el Santo Grial es maximizar la reutilización de código sin perder el acceso a las capacidades nativas de cada sistema operativo. Kotlin Multiplatform (KMP) aborda este desafío de una manera radicalmente distinta a otros frameworks que introducen capas de abstracción pesadas o puentes de serialización en tiempo de ejecución. KMP prefiere resolver la interoperabilidad en tiempo de compilación.

    Cuando el compilador de Kotlin genera los binarios para Android (JVM) e iOS (Apple Silicon/Intel), necesita un mecanismo seguro para enlazar el código común con las APIs nativas de cada plataforma. Aquí es donde entra el mecanismo de expect/actual. No se trata de un simple reflejo o una inyección de dependencias tradicional en tiempo de ejecución; es un contrato estricto a nivel de compilador que nos permite declarar «expectativas» en nuestro código compartido y exigir «realidades» en el código de la plataforma. En este artículo, analizaremos a fondo cómo funciona este ciclo, sus reglas a nivel de arquitectura y cómo implementarlo de forma eficiente.

    1. El Mecanismo Expect/Actual a Nivel de Compilador

    A diferencia de los patrones de diseño tradicionales donde las abstracciones se resuelven dinámicamente, el par expect/actual funciona como un enlace en tiempo de compilación. Cuando declaras un elemento con la palabra clave expect en el source set commonMain, estás definiendo un contrato topológico. El compilador de Kotlin no generará el artefacto final a menos que cada una de las plataformas destino (como androidMain e iosMain) provea una implementación idéntica marcada con actual.

    Es importante entender que expect no es una interfaz. Mientras que una interfaz define un comportamiento que una clase debe implementar en tiempo de ejecución, expect puede aplicarse a clases, funciones, propiedades, enumerados o funciones de extensión. El compilador valida que las firmas (nombre, parámetros, tipos de retorno y modificadores de visibilidad) sean compatibles entre la declaración expect y las declaraciones actual. Si hay una discrepancia no permitida, la compilación fallará antes de generar cualquier binario.

    2. Reglas Estrictas y Restricciones de Visibilidad

    Para dominar este mecanismo, es crucial comprender las restricciones que impone el compilador de Kotlin para garantizar la seguridad de tipos:

    • Coincidencia de Firmas: Los nombres de los parámetros y sus tipos deben ser exactamente los mismos. No se permite cambiar el nombre de un argumento en la implementación actual, ya que esto rompería la resolución de argumentos con nombre (named arguments) en el código común.
    • Modificadores de Visibilidad: La declaración actual debe tener una visibilidad compatible con la expect. En Kotlin actual, eso significa que puede mantener la misma visibilidad o incluso ser más permisiva que el contrato esperado; lo que no puede hacer es ser más restrictiva y dejar inaccesible algo que el código común esperaba usar.
    • Restricción de Paquetes: Tanto la declaración expect como la actual deben residir exactamente en el mismo paquete (package) dentro de sus respectivos source sets. El compilador utiliza el nombre completamente cualificado (FQCN) para emparejarlos durante la fase de enlace.

    3. Ejemplo Práctico: Un Identificador Único de Dispositivo

    Veamos un ejemplo genérico pero de arquitectura sénior: obtener un identificador único de plataforma o el nombre del sistema operativo. Esto requiere acceder a las APIs nativas de Android e iOS, pero queremos exponer una propiedad limpia en nuestro dominio compartido.

    En commonMain (El Contrato):

    package dev.asiles.blog.core
    
    /**
     * Define el contrato para obtener información del entorno nativo.
     * El compilador garantizará que cada plataforma resuelva esta propiedad.
     */
    public expect class PlatformInfo() {
        public val operatingSystemName: String
        
        public fun logDebugInfo(): Unit
    }

    En androidMain (La Realidad en Android):

    package dev.asiles.blog.core
    
    import android.os.Build
    
    public actual class PlatformInfo {
        // Usamos la API nativa de Android (SDK de Java/Android)
        public actual val operatingSystemName: String = "Android ${Build.VERSION.RELEASE} (API ${Build.VERSION.SDK_INT})"
    
        public actual fun logDebugInfo() {
            android.util.Log.d("PlatformInfo", "Running on $operatingSystemName")
        }
    }

    En iosMain (La Realidad en iOS):

    package dev.asiles.blog.core
    
    import platform.UIKit.UIDevice
    
    public actual class PlatformInfo {
        // Acceso directo a las APIs de Apple a través del interop nativo de Kotlin/Native
        public actual val operatingSystemName: String = "${UIDevice.currentDevice.systemName} ${UIDevice.currentDevice.systemVersion}"
    
        public actual fun logDebugInfo() {
            // En iOS usamos la función de impresión estándar de Kotlin/Native que mapea a la consola nativa
            println("PlatformInfo: Running on $operatingSystemName")
        }
    }

    4. Enfoque Arquitectónico: Cuándo Usarlo y Cuándo Evitarlo

    El mecanismo expect/actual es extremadamente potente, pero en la arquitectura de un sistema de nivel empresarial, debe ser tu última opción, no la primera.

    Cuándo usarlo:

    • Para construir wrappers muy delgados sobre tipos primitivos o firmas de funciones que dependen directamente de tipos de la plataforma (por ejemplo, dar formato a una fecha, interactuar con tipos atómicos o inicializar una base de datos local como SQLDelight).
    • Cuando necesitas instanciar una clase nativa cuyo constructor requiere un contexto específico de la plataforma.

    Cuándo evitarlo (y qué usar en su lugar):

    • No lo uses para lógica de negocio pesada. Si creas grandes clases expect, estarás duplicando lógica y rompiendo el propósito de KMP.
    • Alternativa superior (Inyección de Dependencias / Inversión de Control): Para componentes arquitectónicos complejos (como clientes HTTP personalizados, gestores de Bluetooth o sistemas de almacenamiento), es mucho más limpio definir una interfaz estándar de Kotlin (interface) en commonMain e inyectar las implementaciones nativas mediante un framework de DI como Koin o mediante inyección manual en el punto de entrada de la aplicación. Esto facilita los tests unitarios en commonMain mediante el uso de dobles de prueba (mocks/fakes) sin depender del mecanismo de compilación.

    5. Type Aliasing: El Truco Avanzado de Rendimiento

    Existe una característica avanzada dentro del ecosistema actual que los desarrolladores senior deben aprovechar: los actual typealias. Si una plataforma ya tiene un objeto que cumple perfectamente con el contrato que necesitas, no es necesario crear una clase envoltorio (wrapper) que añada sobrecarga en tiempo de ejecución. Puedes simplemente mapearlo.

    Por ejemplo, si necesitas un tipo de dato para manejar UUIDs de forma nativa, puedes declarar un expect class UUID en el código común. En androidMain, en lugar de reimplementar la clase, puedes hacer:

    // En androidMain
    public actual typealias UUID = java.util.UUID

    Esto le dice al compilador que, cuando compile para Android, reemplace directamente cualquier uso de tu UUID compartido por la clase nativa optimizada de Java, eliminando cualquier capa de abstracción intermitente y manteniendo el rendimiento nativo al 100%.

    Para profundizar

    Para contrastar estos conceptos arquitectónicos con las especificaciones técnicas vigentes, recomiendo encarecially la lectura de los siguientes recursos de la documentación oficial de JetBrains:

    Conclusión

    El mecanismo expect/actual es uno de los pilares que hacen de Kotlin Multiplatform una tecnología tan robusta y respetuosa con el rendimiento nativo. Al delegar la resolución de contratos al tiempo de compilación en lugar de pagar el costo de rendimiento en tiempo de ejecución, KMP nos permite estructurar aplicaciones altamente optimizadas.

    Como buena práctica senior, mantén tus declaraciones expect lo más minimalistas posible. Trátalas como los puntos de anclaje de tu infraestructura y construye el resto de tu arquitectura (interfaces, casos de uso y ViewModels) sobre abstracciones puras en commonMain. Esto garantizará que tu base de código sea escalable, fácil de testear y preparada para añadir nuevas plataformas en el futuro con un esfuerzo mínimo.

    Una vez consolidado el plano teórico y las reglas de visibilidad del compilador, es hora de poner en práctica este mecanismo en un escenario real de producción. En el próximo artículo, Unidad 2 — Tema 2: Escribiendo código específico de plataforma: Obteniendo información del dispositivo en iOS y Android, nos arremangaremos para implementar un lector de telemetría de hardware conectando con las APIs nativas de Android SDK y CocoaTouch.