Por qué elegimos Kotlin como uno de nuestros idiomas de destino. Parte 2: Multiplataforma Kotlin

Continuamos la serie de artículos sobre la introducción del lenguaje Kotlin en nuestro proceso de desarrollo. Busque la primera parte aquí .

En 2017, el ambicioso proyecto de Jetbrains vio la luz del día, ofreciendo una nueva perspectiva sobre el desarrollo multiplataforma. ¡Compilando el código de Kotlin en código nativo de varias plataformas! En Domklik, a su vez, siempre estamos buscando formas de optimizar el proceso de desarrollo. ¿Qué podría ser mejor que reutilizar código, pensamos? Así es, no escriba código en absoluto. Y para que todo funcione como quieras. Pero mientras esto no suceda. Y si hay una solución que nos permita, sin gastar demasiado esfuerzo, usar una única base de código para diferentes plataformas, ¿por qué no intentarlo?

¡Hola a todos! Mi nombre es Gennady Vasilkov, soy desarrollador de Android en Domklik y hoy quiero compartir con ustedes nuestra experiencia en el desarrollo de Kotlin Multiplatform para dispositivos móviles, cuéntenos qué dificultades encontramos, cómo resolvimos y con qué terminamos. El tema seguramente será de interés para aquellos que quieran probar Kotlin MPP (proyectos multiplataforma), o ya lo hayan intentado, pero no hayan terminado la producción. O traído, pero no como me gustaría. Trataré de transmitir nuestra visión de cómo debe organizarse el proceso de desarrollo y entrega de bibliotecas desarrolladas (utilizando una de ellas como ejemplo, les contaré el comienzo de nuestra ruta de desarrollo en Kotlin MPP).

¿Quieres historias como lo hicimos? ¡Los tenemos!



Brevemente sobre tecnología


Para aquellos que aún no han escuchado o no se han sumergido en el tema, se cuelga en el mundo de Java y solo va al mundo de Kotlin (o no va, pero se asoma): Kotlin MPP es una tecnología que le permite usar código escrito una vez en muchas plataformas a la vez.

El desarrollo es, no sorprendentemente, en el lenguaje Kotlin en IntelliJ IDEA o Android Studio. La ventaja es que todos los desarrolladores de Android (al menos con nosotros) conocen y aman tanto el lenguaje como estos maravillosos entornos de desarrollo.

También una gran ventaja al compilar el código resultante en los idiomas nativos de cada plataforma (OBJ-C, JS, todo está claro con JVM).

Es decir, todo es genial. En mi opinión, Kotlin Multiplatform es ideal para llevar la lógica empresarial a las bibliotecas. También es posible escribir una aplicación por completo, pero por ahora parece demasiado extrema, pero el desarrollo de la biblioteca es adecuado para proyectos tan grandes como el nuestro.

Un poco técnico, para entender cómo funciona el proyecto en Kotlin MPP


  • El sistema de compilación es Gradle , admite sintaxis en groovy y kotlin script (kts).
  • Kotlin MPP tiene el concepto de objetivos: plataformas objetivo. En estos bloques, los sistemas operativos que necesitamos están configurados, cuyo código nativo compilará nuestro código en Kotlin. En la imagen a continuación se implementan 2 objetivos, jvm y js (la imagen se tomó parcialmente del sitio web ): el

    soporte para otras plataformas se implementa de la misma manera.
  • Conjuntos de origen: el nombre en sí es claro: los códigos fuente para las plataformas se almacenan aquí. Hay un conjunto de fuentes y una plataforma comunes (hay tantos como objetivos en el proyecto, seguido por el IDE). Es imposible no mencionar el mecanismo de esperar-real aquí.



    El mecanismo permite acceder al código específico de la plataforma desde el módulo común. Declaramos la declaración esperada en el módulo común y la implementamos en los de la plataforma. A continuación se muestra un ejemplo del uso del mecanismo para obtener la fecha en los dispositivos:

    Common:
    internal expect val timestamp: Long
    
    Android/JVM:
    internal actual val timestamp: Long 
        get() = java.lang.System.currentTimeMillis()
    
    iOS:
    internal actual val timestamp: Long 
        get() = platform.Foundation.NSDate().timeIntervalSince1970.toLong()
    

    Como puede ver para los módulos de la plataforma, las bibliotecas del sistema de la Fundación Java e iOS están disponibles, respectivamente.

Estos puntos son suficientes para entender lo que se discutirá más adelante.

Nuestra experiencia


Entonces, en algún momento, decidimos que todo era aceptado por Kotlin MPP (entonces también se llamaba Kotlin / Native) como estándar y comenzamos a escribir bibliotecas en las que sacamos el código común. Al principio era código solo para plataformas móviles, en algún momento agregaron soporte para el backend de jvm. En Android, el desarrollo y la publicación de las bibliotecas desarrolladas no causaron problemas, en iOS, en la práctica, encontraron algunos problemas, pero se resolvieron con éxito y se desarrolló un modelo de trabajo para desarrollar y publicar marcos.

¡Es hora de que pase algo!


Hemos llevado a cabo diversas funcionalidades en bibliotecas separadas que usamos en el proyecto principal.

1) análisis


Antecedentes de ocurrencia


No es ningún secreto que todas las aplicaciones móviles recopilan un montón de análisis. Los eventos se cuelgan con todos los eventos significativos y no muy importantes (por ejemplo, en nuestra aplicación se recopilan más de 600 métricas diferentes). ¿Y qué es la colección métrica? De manera simple, esta es una llamada a una función que envía un evento con una determinada clave a las entrañas del motor de análisis. Y luego entra en una variedad de sistemas de análisis como firebase, appmetrica y otros. ¿Qué problemas hay con esto? ¡Duplicación constante del mismo código (más o menos) en dos plataformas! Sí, y el factor humano no se puede encontrar en ninguna parte: los desarrolladores podrían estar equivocados, tanto en el nombre de las claves como en el conjunto de metadatos transmitidos con el evento. Esto claramente debe escribirse una vez y usarse en cada plataforma. Un candidato ideal para hacer lógica general y tecnología de prueba (este es nuestro bolígrafo de prueba en Kotlin Mpp).

Cómo implementar


Transferimos los eventos a la biblioteca (en forma de funciones con una clave cableada y un conjunto de metadatos requeridos en los argumentos) y escribimos una nueva lógica para procesar estos eventos. El formato del evento se unificó y se creó un motor de controlador (bus), en el que se vertieron todos los eventos. Y para varios sistemas de análisis, los oyentes adaptadores escribieron (resultó ser una solución muy útil, al agregar cualquier análisis nuevo podemos redirigir fácilmente todo, o selectivamente, cada evento a un nuevo análisis).



Interesante en el proceso de implementación


Debido a las peculiaridades de la implementación de trabajar con transmisiones en Kotlin / Native para iOS, no puede simplemente tomar y trabajar con el objeto Kotlin como un singleton y escribir datos allí en cualquier momento. Todos los objetos que transfieren su estado entre subprocesos deben congelarse (hay una función congelar () para esto). Pero la biblioteca, entre otras cosas, almacena un estado que puede cambiar durante la vida de la aplicación. Hay varias formas de resolver esta situación, nos decidimos por la más simple:

Common:

expect class AtomicReference<T>(value_: T) {
    var value: T
}

Android:

actual class AtomicReference<T> actual constructor(value_: T) {
    actual var value: T = value_
}

iOS:

actual typealias AtomicReference<V> = kotlin.native.concurrent.AtomicReference<V>

Esta opción es adecuada para el almacenamiento de estado simple. En nuestro caso, la configuración de la plataforma se almacena:

object Config {
    val platform = AtomicReference<String?>("")
    var manufacturer = AtomicReference<String?>("")
    var model = AtomicReference<String?>("")
    var deviceId = AtomicReference<String?>("")
    var appVersion = AtomicReference<String?>("")
    var sessionId = AtomicReference<String?>("")
    var debug = AtomicReference<Boolean>(false)
}

¿Por qué se necesita esto? No obtenemos alguna información al inicio de la aplicación, cuando el módulo ya debe estar cargado y sus objetos ya están en estado congelado. Para agregar esta información a la configuración y luego podría leerse desde diferentes transmisiones en iOS y se requiere dicho movimiento.

También enfrentamos el problema de publicar la biblioteca. Y si Android no tuvo ningún problema con esto, entonces el método propuesto por la documentación oficial en ese momento para conectar el marco ensamblado directamente al proyecto iOS no nos convenía por varias razones, quería obtener nuevas versiones lo más simples y transparentes posible. Además, las versiones son compatibles.
La solución es conectar el marco a través de un archivo pod. Para hacer esto, generaron el archivo podspec y el marco en sí, los pusieron en el repositorio y conectar la biblioteca al proyecto se volvió muy simple y conveniente para los desarrolladores de iOS. Además, nuevamente, para la conveniencia del desarrollo, recopilamos un solo artefacto para todas las arquitecturas, el llamado marco de trabajo (en realidad, un binario en negrita en el que todas las arquitecturas y metaarchivos agregados generados por el complemento kotlin se llevan bien). La implementación de todo esto bajo el spoiler:

A quién le importa cómo lo hicimos antes de la decisión oficial
Después de que el complemento kotlin haya recopilado para nosotros un montón de marcos diferentes para diferentes arquitecturas, creamos manualmente un nuevo marco universal en el que copiamos metadatos de cualquiera (en nuestro caso, tomamos arm64):
//add meta files (headers, modules and plist) from arm64 platform
task copyMeta() {
    dependsOn build

    doLast {
        copy {
            from("$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework")
            into "$buildDir/iphone_universal/${frameworkName}.framework"
        }
    }
}

, , , . lipo, :
//merge binary files into one
task lipo(type: Exec) {
    dependsOn copyMeta

    def frameworks = files(
            "$buildDir/bin/iphone32/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphone/main/release/framework/${frameworkName}.framework/$frameworkName",
            "$buildDir/bin/iphoneSim/main/release/framework/${frameworkName}.framework/$frameworkName"
    )
    def output = file("$buildDir/iphone_universal/${frameworkName}.framework/$frameworkName")
    inputs.files frameworks
    outputs.file output
    executable = 'lipo'
    args = frameworks.files
    args += ['-create', '-output', output]
}

, - Kotlin MPP Info.plist UIRequiredDeviceCapabilities, ( ). PlistBuddy:
//workaround
//remove UIRequiredDeviceCapabilities key from plist file (because we copy this file from arm64, only arm64 architecture was available)
task editPlistFile(type: Exec) {
    dependsOn lipo

    executable = "/bin/sh"
    def script = './scripts/edit_plist_file.sh'
    def command = "Delete :UIRequiredDeviceCapabilities"
    def file = "$buildDir/iphone_universal/${frameworkName}.framework/Info.plist"

    args += [script, command, file]
}

edit_plist_file.sh:
/usr/libexec/PlistBuddy -c "$1" "$2"

, zip ( podspec ):
task zipIosFramework(type: Zip) {
    dependsOn editPlistFile
    from "$buildDir/iphone_universal/"
    include '**/*'
    archiveName = iosArtifactName
    destinationDir(file("$buildDir/iphone_universal_zipped/"))
}

podspec :
task generatePodspecFile(type: Exec) {
    dependsOn zipIosFramework

    executable = "/bin/sh"

    def script = './scripts/generate_podspec_file.sh'

    def templateStr = "version_name"
    def sourceFile = './podspec/template.podspec'
    def replaceStr = "$version"

    args += [script, templateStr, replaceStr, sourceFile, generatedPodspecFile]
}

generate_podscpec_file.sh :
sed -e s/"$1"/"$2"/g <"$3" >"$4"

curl :
task uploadIosFrameworkToNexus(type: Exec) {
    dependsOn generatePodspecFile

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $buildDir/iphone_universal_zipped/$iosArtifactName $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

task uploadPodspecFileToNexus(type: Exec) {
    dependsOn uploadIosFrameworkToNexus

    executable = "/bin/sh"
    def body = "-s -k -v --user \'$userName:$password\' " +
            "--upload-file $generatedPodspecFile $iosUrlRepoPath"
    args += ['-c', "curl $body"]
}

:
task createAndUploadUniversalIosFramework() {
    dependsOn uploadPodspecFileToNexus
    doLast {
        println 'createAndUploadUniversalIosFramework complete'
    }
}


Profit!


Al momento de escribir, existen soluciones oficiales para crear un marco de trabajo grueso y generar un archivo podspec (y generalmente integración con CocoaPods). Por lo tanto, solo tenemos que cargar el marco creado y el archivo podspec en el repositorio y conectar el proyecto en iOS de la misma manera que antes:




Después de implementar dicho esquema con un solo lugar para almacenar eventos, un mecanismo para procesar y enviar métricas, nuestro proceso de agregar nuevas métricas se redujo al hecho de que un desarrollador agrega una nueva función a la biblioteca, que describe la clave del evento y los metadatos necesarios que el desarrollador debe transmitir para un evento en particular . Además, el desarrollador de otra plataforma simplemente descarga una nueva versión de la biblioteca y usa la nueva función en el lugar correcto. Se ha vuelto mucho más fácil mantener la consistencia de todo el sistema de análisis; ahora también es mucho más fácil cambiar la implementación interna del bus de eventos. Por ejemplo, en algún momento, para uno de nuestros analistas, fue necesario que implementemos nuestro propio búfer de eventos, que se realizó inmediatamente en la biblioteca, sin la necesidad de cambiar el código en las plataformas. ¡Lucro!

Continuamos transfiriendo más y más código a Kotlin MPP, en los siguientes artículos continuaré la historia de nuestra introducción al mundo del desarrollo multiplataforma utilizando dos bibliotecas más como ejemplos, en los que se utilizaron la serialización y la base de datos, y trabajaré con el tiempo. Le contaré sobre las restricciones que se encontraron al usar varios marcos de Kotlin en un proyecto de iOS y cómo sortear esta limitación.

Y ahora brevemente sobre los resultados de nuestra investigación.


+ Todo funciona de manera estable en la producción.
+ Base de código unificado de lógica de servicios empresariales para todas las plataformas (menos errores y discrepancias).
+ Costos de soporte reducidos y tiempo de desarrollo reducido para nuevos requisitos.
+ Aumentamos la experiencia de los desarrolladores.

enlaces relacionados


Sitio web de Kotlin Multiplatform: www.jetbrains.com/lp/mobilecrossplatform

ZY:
no quiero repetir sobre el laconismo del lenguaje y, por lo tanto, centrarme en la lógica empresarial al escribir código. Puedo aconsejar varios artículos que

cubren este tema: "Kotlin bajo el capó - ver código de bytes descompilado" - un artículo que describe el trabajo del compilador de Kotlin y cubre la estructura básica del lenguaje.
habr.com/en/post/425077
Dos partes del artículo "Kotlin, compilación en bytecode y rendimiento", que complementa el artículo anterior.
habr.com/ru/company/inforion/blog/330060
habr.com/ru/company/inforion/blog/330064
El informe de Pasha Finkelstein "Kotlin: dos años en producción y ni una sola brecha", que se basa en la experiencia de usar e implementar Kotlin en nuestra empresa.
www.youtube.com/watch?v=nCDWb7O1ZW4

All Articles