Warum wir Kotlin als eine unserer Zielsprachen gewählt haben. Teil 2: Kotlin Multiplattform

Wir setzen die Artikelserie zur Einführung der Kotlin-Sprache in unseren Entwicklungsprozess fort. Suchen Sie nach dem ersten Teil hier .

2017 erblickte das ehrgeizige Projekt von Jetbrains das Licht der Welt und bot eine neue Perspektive für die plattformübergreifende Entwicklung. Kompilieren von Kotlin-Code in nativen Code verschiedener Plattformen! Wir in Domklik wiederum suchen immer nach Möglichkeiten, den Entwicklungsprozess zu optimieren. Was könnte besser sein, als Code wiederzuverwenden, dachten wir? Das ist richtig - schreiben Sie überhaupt keinen Code. Und damit alles so funktioniert, wie Sie es wollen. Aber während dies nicht passiert. Und wenn es eine Lösung gibt, mit der wir ohne großen Aufwand eine einzige Codebasis für verschiedene Plattformen verwenden können, warum nicht versuchen?

Also hallo alle zusammen! Mein Name ist Gennady Vasilkov, ich bin ein Android-Entwickler bei Domklik und heute möchte ich Ihnen unsere Erfahrungen bei der Entwicklung der Kotlin Multiplatform für mobile Geräte mitteilen und uns mitteilen, auf welche Schwierigkeiten wir gestoßen sind, wie wir sie gelöst haben und was wir am Ende hatten. Das Thema wird sicherlich für diejenigen von Interesse sein, die Kotlin MPP (Multiplatform-Projekte) ausprobieren möchten oder es bereits ausprobiert haben, aber die Produktion noch nicht abgeschlossen haben. Oder gebracht, aber nicht so, wie ich es gerne hätte. Ich werde versuchen, unsere Vision zu vermitteln, wie der Entwicklungs- und Bereitstellungsprozess von entwickelten Bibliotheken gestaltet werden sollte (anhand eines Beispiels möchte ich Ihnen den Beginn unseres Entwicklungspfads in Kotlin MPP erläutern).

Möchten Sie Geschichten darüber, wie wir es gemacht haben? Wir haben sie!



Kurz über Technologie


Für diejenigen, die das Thema noch nicht gehört oder noch nicht kennengelernt haben, hängt es in der Java-Welt und geht einfach in die Kotlin-Welt (oder geht nicht, aber guckt): Kotlin MPP ist eine Technologie, mit der Sie einmal geschriebenen Code auf vielen Plattformen gleichzeitig verwenden können.

Die Entwicklung erfolgt nicht überraschend in der Kotlin-Sprache in IntelliJ IDEA oder Android Studio. Das Plus ist, dass alle Android-Entwickler (zumindest bei uns) sowohl die Sprache als auch diese wunderbaren Entwicklungsumgebungen kennen und lieben.

Auch ein großes Plus beim Kompilieren des resultierenden Codes in Sprachen, die für jede Plattform nativ sind (OBJ-C, JS, mit JVM ist alles klar).

Das heißt, alles ist cool. Meiner Meinung nach ist Kotlin Multiplatform ideal, um Geschäftslogik in Bibliotheken zu bringen. Es ist auch möglich, eine Anwendung vollständig zu schreiben, aber im Moment sieht sie zu extrem aus, aber die Bibliotheksentwicklung eignet sich für so große Projekte wie unsere.

Ein wenig technisch, um zu verstehen, wie das Projekt auf dem Kotlin MPP funktioniert


  • Das Build-System ist Gradle und unterstützt die Syntax in Groovy- und Kotlin-Skripten (kts).
  • Kotlin MPP hat das Konzept von Zielen - Zielplattformen. In diesen Blöcken werden die von uns benötigten Betriebssysteme konfiguriert, deren nativer Code unseren Code auf Kotlin kompiliert. In der Abbildung unten sind 2 Ziele implementiert, jvm und js (das Bild stammt teilweise von der Website ): Die

    Unterstützung für andere Plattformen wird auf die gleiche Weise implementiert.
  • Quellensätze - Der Name selbst ist klar, dass hier Quellcodes für Plattformen gespeichert sind. Es gibt einen gemeinsamen Quellensatz und eine gemeinsame Plattform (es gibt so viele wie es Ziele im Projekt gibt, gefolgt von der IDE). Es ist unmöglich, den erwarteten tatsächlichen Mechanismus hier nicht zu erwähnen.



    Der Mechanismus ermöglicht den Zugriff auf plattformspezifischen Code über das Common-Modul. Wir deklarieren die Erwartungsdeklaration im Common-Modul und implementieren sie in den Plattformmodulen. Im Folgenden finden Sie ein Beispiel für die Verwendung des Mechanismus zum Abrufen des Datums auf Geräten:

    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()
    

    Wie Sie für die Plattformmodule sehen können, sind die Java- und iOS Foundation-Systembibliotheken verfügbar.

Diese Punkte reichen aus, um zu verstehen, was später besprochen wird.

Unsere Erfahrung


Irgendwann entschieden wir uns, dass alles von Kotlin MPP (damals auch Kotlin / Native genannt) als Standard akzeptiert wurde, und begannen, Bibliotheken zu schreiben, in die wir den gemeinsamen Code teilten. Anfangs war es nur Code für mobile Plattformen, irgendwann wurde die Unterstützung für das JVM-Backend hinzugefügt. Unter Android verursachte die Entwicklung und Veröffentlichung der entwickelten Bibliotheken keine Probleme. Unter iOS stießen sie in der Praxis auf einige Probleme, wurden jedoch erfolgreich gelöst und ein Arbeitsmodell für die Entwicklung und Veröffentlichung von Frameworks ausgearbeitet.

Zeit für etwas zum Herumhängen!


Wir haben verschiedene Funktionen in separaten Bibliotheken ausgeführt, die wir im Hauptprojekt verwenden.

1) Analytik


Hintergrund des Auftretens


Es ist kein Geheimnis, dass alle mobilen Anwendungen eine Reihe von Analysen sammeln. Ereignisse werden mit allen wichtigen und nicht sehr wichtigen Ereignissen aufgehängt (in unserer Anwendung werden beispielsweise mehr als 600 verschiedene Metriken erfasst). Und was ist Metriksammlung? Auf einfache Weise ist dies ein Aufruf einer Funktion, die ein Ereignis mit einem bestimmten Schlüssel an den Darm der Analytics Engine sendet. Und dann geht es in eine Vielzahl von Analysesystemen wie Firebase, Appmetrica und andere. Welche Probleme gibt es damit? Ständige Vervielfältigung des gleichen (Plus- oder Minus-) Codes auf zwei Plattformen! Ja, und der Faktor Mensch kann nirgendwo gefunden werden - die Entwickler könnten sich irren, sowohl im Namen der Schlüssel als auch im Metadatensatz, der mit dem Ereignis übertragen wird. Dies muss eindeutig einmal geschrieben und auf jeder Plattform verwendet werden. Ein idealer Kandidat für die Erstellung allgemeiner Logik- und Testtechnologien (dies ist unser Teststift in Kotlin Mpp).

Wie zu implementieren


Wir haben die Ereignisse selbst in die Bibliothek übertragen (in Form von Funktionen mit einem verdrahteten Schlüssel und einer Reihe erforderlicher Metadaten in den Argumenten) und eine neue Logik für die Verarbeitung dieser Ereignisse geschrieben. Das Ereignisformat wurde vereinheitlicht und eine Handler-Engine (Bus) erstellt, in die alle Ereignisse eingegossen wurden. Und für verschiedene Analysesysteme haben Adapter-Listener geschrieben (eine sehr nützliche Lösung stellte sich heraus, dass wir beim Hinzufügen neuer Analysen einfach alles oder jedes Ereignis selektiv in eine neue Analyse umleiten können).



Interessant am Umsetzungsprozess


Aufgrund der Funktionen der Implementierung der Arbeit mit Streams in Kotlin / Native für iOS können Sie das Kotlin-Objekt nicht einfach als Singleton verwenden und dort jederzeit Daten schreiben. Alle Objekte, die ihren Status zwischen Threads übertragen, müssen eingefroren sein (hierfür gibt es eine freeze () - Funktion). In der Bibliothek wird jedoch unter anderem ein Status gespeichert, der sich während der Laufzeit der Anwendung ändern kann. Es gibt verschiedene Möglichkeiten, um diese Situation zu lösen. Wir haben uns für die einfachste entschieden:

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>

Diese Option eignet sich für die einfache Zustandsspeicherung. In unserem Fall wird die Plattformkonfiguration gespeichert:

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)
}

Warum wird das benötigt? Zu Beginn der Anwendung erhalten wir keine Informationen, wenn das Modul bereits geladen sein sollte und seine Objekte bereits eingefroren sind. Um diese Informationen zur Konfiguration hinzuzufügen und sie dann aus verschiedenen Streams unter iOS zu lesen, ist ein solcher Schritt erforderlich.

Wir standen auch vor dem Problem der Veröffentlichung der Bibliothek. Und wenn Android damit keine Probleme hatte, passte die damals in der offiziellen Dokumentation vorgeschlagene Methode, das zusammengestellte Framework direkt mit dem iOS-Projekt zu verbinden, aus mehreren Gründen nicht zu uns. Ich wollte neue Versionen so einfach und transparent wie möglich erhalten. Außerdem wird die Versionierung unterstützt.
Die Lösung besteht darin, das Framework über eine Pod-Datei zu verbinden. Zu diesem Zweck haben sie die Podspec-Datei und das Framework selbst generiert, sie in das Repository gestellt und die Verbindung der Bibliothek mit dem Projekt für iOS-Entwickler sehr einfach und bequem gemacht. Zur Vereinfachung der Entwicklung sammeln wir auch hier ein einziges Artefakt für alle Architekturen, das sogenannte Fat Framework (eigentlich eine fett gedruckte Binärdatei, in der alle vom Kotlin-Plugin generierten Architekturen und hinzugefügten Metadateien zusammenkommen). Die Umsetzung dieser ganzen Sache unter dem Spoiler:

Wen interessiert es, wie wir es vor der offiziellen Entscheidung gemacht haben?
Nachdem das Kotlin-Plugin eine Reihe verschiedener Frameworks für verschiedene Architekturen für uns gesammelt hat, erstellen wir manuell ein neues universelles Framework, in das wir Metadaten von jedem kopieren (in unserem Fall haben wir arm64 übernommen):
//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!


Zum Zeitpunkt des Schreibens gibt es offizielle Lösungen zum Erstellen eines Fat Frameworks und zum Generieren einer Podspec-Datei (und im Allgemeinen zur Integration in CocoaPods). Wir müssen also nur das erstellte Framework und die Podspec-Datei in das Repository hochladen und das Projekt in iOS auf die gleiche Weise wie zuvor verbinden:




Nach der Implementierung eines solchen Schemas mit einem einzigen Ort zum Speichern von Ereignissen, einem Mechanismus zum Verarbeiten und Senden von Metriken, wurde unser Prozess des Hinzufügens neuer Metriken auf die Tatsache reduziert, dass ein Entwickler der Bibliothek eine neue Funktion hinzufügt, die den Ereignisschlüssel und die erforderlichen Metadaten beschreibt, die der Entwickler für ein bestimmtes Ereignis übertragen muss . Außerdem lädt der Entwickler einer anderen Plattform einfach eine neue Version der Bibliothek herunter und verwendet die neue Funktion an der richtigen Stelle. Es ist viel einfacher geworden, die Konsistenz des gesamten Analysesystems aufrechtzuerhalten, und es ist jetzt auch viel einfacher, die interne Implementierung des Ereignisbusses zu ändern. Zum Beispiel mussten wir zu einem bestimmten Zeitpunkt für einen unserer Analysten unseren eigenen Ereignispuffer implementieren, der sofort in der Bibliothek ausgeführt wurde, ohne dass der Code auf den Plattformen geändert werden musste. Profitieren!

Wir übertragen immer mehr Code auf Kotlin MPP. In den folgenden Artikeln werde ich die Geschichte unserer Einführung in die Welt der plattformübergreifenden Entwicklung anhand von zwei weiteren Bibliotheken als Beispiel fortsetzen, in denen die Serialisierung und die Datenbank verwendet wurden, und im Laufe der Zeit arbeiten. Ich erzähle Ihnen von den Einschränkungen, die bei der Verwendung mehrerer Kotlin-Frameworks in einem iOS-Projekt aufgetreten sind, und wie Sie diese Einschränkung umgehen können.

Und nun kurz zu den Ergebnissen unserer Forschung


+ In der Produktion funktioniert alles stabil.
+ Einheitliche Codebasis der Business Service-Logik für alle Plattformen (weniger Fehler und Diskrepanzen).
+ Reduzierte Supportkosten und reduzierte Entwicklungszeit für neue Anforderungen.
+ Wir erhöhen das Know-how der Entwickler.

verwandte Links


Kotlin Multiplatform-Website: www.jetbrains.com/lp/mobilecrossplatform

ZY:
Ich möchte den Lakonismus der Sprache nicht wiederholen und mich daher beim Schreiben von Code auf die Geschäftslogik konzentrieren. Ich kann mehrere Artikel empfehlen,

die dieses Thema behandeln: „Kotlin unter der Haube - siehe dekompilierten Bytecode“ - ein Artikel, der die Funktionsweise des Kotlin-Compilers beschreibt und die Grundstruktur der Sprache behandelt.
habr.com/de/post/425077
Zwei Teile des Artikels "Kotlin, Zusammenstellung in Bytecode und Leistung", die den vorherigen Artikel ergänzen.
habr.com/ru/company/inforion/blog/330060
habr.com/ru/company/inforion/blog/330064
Pasha Finkelsteins Bericht „Kotlin - zwei Jahre in der Produktion und keine einzige Lücke“ basiert auf der Erfahrung mit der Verwendung und Implementierung von Kotlin in unserem Unternehmen.
www.youtube.com/watch?v=nCDWb7O1ZW4

All Articles