Im Android-Projekt zu Kotlin wechseln: Tipps und Tricks

  • Tutorial

Autor: Sergey Yoshino, starke Mittel Android Developer, DataArt

Seit mehr als eineinhalb Jahre sind seitdem vergangen, als Google die offizielle Unterstützung von Kotlin in Android angekündigt, aber die erfahrensten Entwickler haben mit ihnen in ihrem Kampf zu experimentieren begonnen, und nicht mehr als drei Jahren Projekt.

Die neue Sprache wurde in der Android-Community herzlich aufgenommen und der überwiegende Teil der neuen Android-Projekte beginnt mit Kotlin an Bord. Es ist auch wichtig, dass Kotlin in JVM-Bytecode kompiliert wird. Daher ist es vollständig mit Java kompatibel. In bestehenden Android-Projekten, die in Java geschrieben sind, gibt es also auch die Möglichkeit (außerdem die Notwendigkeit), alle Funktionen von Kotlin zu nutzen, dank denen er so viele Fans gewonnen hat.

In dem Artikel werde ich über die Erfahrungen bei der Migration einer Android-Anwendung von Java nach Kotlin sprechen, über die Schwierigkeiten, die dabei überwunden werden mussten, und erläutern, warum dies alles nicht umsonst war. Der Artikel richtet sich hauptsächlich an Android-Entwickler, die gerade erst mit Kotlin beginnen, und stützt sich neben persönlichen Erfahrungen auf Materialien anderer Mitglieder der Community.

Warum Kotlin?


Ich werde kurz die Eigenschaften von Kotlin beschreiben, weshalb ich im Projekt darauf umgestiegen bin und die "gemütliche und schmerzlich vertraute" Welt von Java verlassen habe:

  1. Volle Java-Kompatibilität
  2. Null Sicherheit
  3. Inferenz eingeben
  4. Erweiterungsmethoden
  5. Fungiert als erstklassige und Lambda-Objekte
  6. Generics
  7. Coroutinen
  8. Abwesenheit geprüft Ausnahme

DISCO-Anwendung


Dies ist eine kleine Anwendung für den Austausch von Rabattkarten, bestehend aus 10 Bildschirmen. In seinem Beispiel betrachten wir die Migration.

Architektur in Kürze


Die Anwendung verwendet die MVVM-Architektur mit den Google Architecture-Komponenten unter der Haube: ViewModel, LiveData, Room.


Nach den Prinzipien von Clean Architecture von Uncle Bob habe ich drei Ebenen in der Anwendung ausgewählt: Daten, Domäne und Präsentation.


Wo soll ich anfangen? Wir stellen uns daher die Hauptmerkmale von Kotlin vor und haben eine minimale Vorstellung von dem Projekt, das migriert werden muss. Es gibt eine natürliche Frage "wo soll ich anfangen?".

Auf der Android-Seite Erste Schritte mit Kotlin wurde geschrieben, dass Sie, wenn Sie eine vorhandene Anwendung nach Kotlin übertragen möchten, nur Unit-Tests schreiben müssen. Wenn Sie ein wenig Erfahrung mit dieser Sprache haben, schreiben Sie neuen Code in Kotlin. Sie müssen lediglich vorhandenen Java-Code konvertieren.

Aber es gibt ein "aber". Tatsächlich ermöglicht eine einfache Konvertierung in der Regel (wenn auch nicht immer) einen Funktionscode für Kotlin, aber seine Idiomatizität lässt zu wünschen übrig. Dann werde ich Ihnen sagen, wie Sie diese Lücke aufgrund der erwähnten (und nicht nur) Merkmale der Kotlin-Sprache beseitigen können.

Schichtmigration


Da die Anwendung bereits in Ebenen aufgeteilt ist, ist es sinnvoll, die Migration nach Schichten durchzuführen, beginnend mit der obersten.

Die Reihenfolge der Ebenen während der Migration ist in der folgenden Abbildung dargestellt:


Es ist kein Zufall, dass wir die Migration von der oberen Schicht aus gestartet haben. Damit sparen wir uns die Verwendung von Kotlin-Code in Java-Code. Im Gegenteil: Der Kotlin-Code der oberen Schicht verwendet die Java-Klassen der unteren Schicht. Tatsache ist, dass Kotlin ursprünglich unter Berücksichtigung der Notwendigkeit der Interaktion mit Java entwickelt wurde. Bestehender Java-Code kann auf natürliche Weise von Kotlin aus aufgerufen werden. Wir können leicht von vorhandenen Java-Klassen erben, auf sie zugreifen und Java-Annotationen auf Kotlin-Klassen und -Methoden anwenden. Kotlin-Code kann auch in Java ohne besondere Probleme verwendet werden. Häufig sind jedoch zusätzliche Anstrengungen erforderlich, beispielsweise das Hinzufügen von JVM-Anmerkungen. Und warum zusätzliche Konvertierungen in Java-Code, wenn sie am Ende noch in Kotlin geschrieben werden?

Betrachten wir zum Beispiel die Erzeugung von Überlastungen.

Wenn Sie eine Kotlin-Funktion mit Standardparameterwerten schreiben, wird sie in Java normalerweise nur als vollständige Signatur mit allen Parametern angezeigt. Wenn Sie Java-Aufrufe mehrere Überladungen bereitstellen möchten, können Sie die Annotation @JvmOverloads verwenden:

class Foo @JvmOverloads constructor(x: Int, y: Double = 0.0) {
    @JvmOverloads fun f(a: String, b: Int = 0, c: String = "abc") { ... }
}

Für jeden Parameter mit einem Standardwert wird eine zusätzliche Überladung erstellt, bei der sich dieser Parameter und alle Parameter rechts in der Remote-Parameterliste befinden. In diesem Beispiel wird Folgendes erstellt:

// Constructors:
Foo(int x, double y)
Foo(int x)
// Methods
void f(String a, int b, String c) { }
void f(String a, int b) { }
void f(String a) { }

Es gibt viele Beispiele für die Verwendung von JVM-Anmerkungen für Kotlin, um korrekt zu funktionieren. Auf dieser Seite der Dokumentation im Detail offenbarte Kotlin Aufruf Thema aus Java.

Nun beschreiben wir den Migrationsprozess Schicht für Schicht.

Präsentationsschicht


Dies ist eine Benutzeroberflächenschicht, die Bildschirme mit Ansichten und ViewModel enthält, die Eigenschaften in Form von LiveData mit Daten aus dem Modell enthalten. Als Nächstes betrachten wir die Techniken und Tools, die sich beim Migrieren dieser Anwendungsschicht als nützlich erwiesen haben.

1. Kapt-Anmerkungsprozessor


Wie bei jeder MVVM bindet View über die Datenbindung an die ViewModel-Eigenschaften. Bei Android handelt es sich um die Android Databind Library, die Annotationsverarbeitung verwendet. Kotlin verfügt also über einen eigenen Anmerkungsprozessor. Wenn Sie keine Änderungen an der entsprechenden Datei build.gradle vornehmen, wird das Projekt nicht mehr erstellt. Deshalb nehmen wir diese Änderungen vor:

apply plugin: 'kotlin-kapt'
android {
   dataBinding {
       enabled = true
   }
}
dependencies {
   api fileTree(dir: 'libs', include: ['*.jar'])
   ///…
   kapt "com.android.databinding:compiler:$android_plugin_version"
}

Beachten Sie, dass Sie alle Vorkommen der AnnotationProcessor-Konfiguration in Ihrem build.gradle vollständig durch kapt ersetzen müssen.

Wenn Sie beispielsweise Dagger- oder Room-Bibliotheken in einem Projekt verwenden, die auch den Anmerkungsprozessor für die Codegenerierung unter dem Hood verwenden, müssen Sie kapt als Anmerkungsprozessor angeben.

2. Inline-Funktionen


Wenn Sie eine Funktion als Inline kennzeichnen, bitten wir den Compiler, diese am Verwendungsort abzulegen. Der Körper der Funktion wird eingebettet, dh er ersetzt die übliche Verwendung der Funktion. Dank dessen können wir die Typ-Löschbedingung umgehen, d. H. Die Typ-Löschung. Bei der Verwendung von Inline-Funktionen können wir den Typ (Klasse) zur Laufzeit abrufen.

Diese Funktion von Kotlin wurde in meinem Code verwendet, um die Klasse der gestarteten Aktivität zu "extrahieren".

inline fun <reified T : Activity> Context?.startActivity(args: Bundle) {
   this?.let {
       val intent = Intent(this, T::class.java)
       intent.putExtras(args)
       it.startActivity(intent)
   }
}

reified - die Bezeichnung des materialisierten Typs.

In dem oben beschriebenen Beispiel haben wir auch eine solche Funktion der Kotlin-Sprache als Erweiterungen angesprochen.

3. Erweiterungen


Sie sind Erweiterungen. In Erweiterungen wurden Gebrauchsmethoden eingeführt, die es ermöglichten, aufgeblähte und monströse Klassenhilfsprogramme zu vermeiden.

Ich werde ein Beispiel für die Erweiterungen geben, die an der Anwendung beteiligt sind:

fun Context.inflate(res: Int, parent: ViewGroup? = null): View {
   return LayoutInflater.from(this).inflate(res, parent, false)
}
fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean {
  return this != null && isNotEmpty();
}
fun Fragment.hideKeyboard() {
   view?.let { hideKeyboard(activity, it.windowToken) }
}

Kotlin-Entwickler haben bereits im Voraus über nützliche Erweiterungen für Android nachgedacht, indem sie ihr Kotlin Android Extensions-Plugin anbieten. Unter den angebotenen Funktionen können Sie Bindungen anzeigen und Unterstützung für Parcelable anzeigen. Detaillierte Informationen zu den Funktionen dieses Plugins finden Sie hier .

4. Lambda-Funktionen und Funktionen höherer Ordnung


Mit Hilfe von Lambda-Funktionen im Android-Code können Sie ungeschickte ClickListener und Callbacks loswerden, die über selbstgeschriebene Schnittstellen in Java implementiert wurden.

Ein Beispiel für die Verwendung von Lambda anstelle von onClickListener:

button.setOnClickListener({ doSomething() })

Lambda wird auch in übergeordneten Funktionen verwendet, beispielsweise für Funktionen zum Arbeiten mit Sammlungen.

Nehmen Sie zum Beispiel die Karte :

fun <T, R> List<T>.map(transform: (T) -> R): List<R> {...}

In meinem Code gibt es einen Ort, an dem Sie ID-Karten für ihre spätere Entnahme „einwickeln“ müssen.

Mit dem Lambda-Ausdruck, der an die Map übergeben wird, erhalte ich das erforderliche ID-Array:

 val ids = cards.map { it.id }.toIntArray()
 cardDao.deleteCardsByIds(ids)

Beachten Sie, dass Klammern beim Aufruf einer Funktion überhaupt weggelassen werden können, wenn Lambda das einzige Argument ist und das Schlüsselwort der implizite Name des einzigen Parameters ist.

5. Plattformtypen


Sie müssen zwangsläufig mit in Java geschriebenen SDKs arbeiten (einschließlich des Android-SDK). Daher sollten Sie immer mit einer solchen Funktion von Kotlin und Java Interop als Plattformtypen wachsam sein.

Der Plattformtyp ist ein Typ, für den Kotlin keine Informationen zur Gültigkeit von NULL finden kann. Tatsache ist, dass der Java-Code standardmäßig keine Informationen zur Gültigkeit von null enthält, und die NotNull- und @ Nullable-Annotationen werden nicht immer verwendet. Wenn die entsprechende Annotation in Java fehlt, wird der Typ zur Plattform. Sie können damit wie mit einem Typ arbeiten, der Null zulässt, und mit einem Typ, der Null nicht zulässt.


Dies bedeutet, dass der Entwickler wie in Java für den Betrieb mit diesem Typ allein verantwortlich ist. Der Compiler fügt keine Laufzeitprüfung zu null hinzu und ermöglicht Ihnen, alles zu tun.

Im folgenden Beispiel überschreiben wir onActivityResult in unserer Aktivität:

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent{
    super.onActivityResult(requestCode, resultCode, data) 
    val randomString = data.getStringExtra("some_string") 
}

In diesem Fall handelt es sich bei Daten um einen Plattformtyp, der Null enthalten kann. Aus Sicht des Kotlin-Codes dürfen Daten jedoch unter keinen Umständen null sein. Unabhängig davon, ob Sie den Typ Intent als nullfähig angeben, erhalten Sie vom Compiler weder eine Warnung noch einen Fehler, da beide Signaturvarianten gültig sind . Da jedoch der Empfang nicht leerer Daten nicht garantiert ist, da Sie in Fällen mit dem SDK dies nicht steuern können, führt das Erhalten von null in diesem Fall zu NPE.

Als ein Beispiel können Sie die folgenden möglichen Orte für Plattformtypen auflisten:

  1. Service.onStartCommand (), wobei der Intent null sein kann.
  2. BroadcastReceiver.onReceive ().
  3. Activity.onCreate (), Fragment.onViewCreate () und andere ähnliche Methoden.

Darüber hinaus kommt es vor, dass die Parameter der Methode mit Anmerkungen versehen werden, aber aus irgendeinem Grund verliert das Studio seine Nullfähigkeit, wenn eine Überschreibung generiert wird.

Domain-Layer


Diese Schicht umfasst die gesamte Geschäftslogik und ist für die Interaktion zwischen der Datenschicht und der Präsentationsschicht verantwortlich. Die Schlüsselrolle spielt dabei das Repository. Im Repository führen wir die erforderlichen Manipulationen mit Daten durch, sowohl mit Server als auch mit lokalen. Oben in der Präsentationsschicht geben wir nur die Repository-Schnittstellenmethode an, die die Komplexität von Aktionen mit Daten verbirgt.

Wie oben erwähnt, wurde zur Implementierung RxJava verwendet.

1. RxJava


Kotlin ist vollständig mit RxJava kompatibel und in Verbindung damit knapper als Java. Hier hatte ich jedoch ein unangenehmes Problem. Es klingt wie folgt : Wenn Sie Lambda als Parameter der Methode übergeben und dann wird dieses Lambda nicht ausgeführt!

Um dies zu überprüfen, reicht es aus, einen einfachen Test zu schreiben:

Completable
.fromCallable { cardRepository.uploadDataToServer() } 
.andThen { cardRepository.markLocalDataAsSynced() } 
.subscribe()

Inhalt und dann scheitert. Dies ist der Fall mit der Mehrheit der Betreiber (wie flatMap , der Zurückstellungs , fromAction und viele andere) als Argumente wirklich Lambda. Und wenn eine solche Fixierung andthen erwartet komplettierbar / beobachtbare / SingleSource . Das Problem wird durch Verwendung gewöhnlicher Klammern () anstelle von geschweiften {} gelöst.

Dieses Problem wird im Artikel „Kotlin und Rx2. Wie ich fünf Stunden wegen falscher Klammern war .

2. Zerstörung


Lassen Sie uns auch auf eine interessante Kotlin-Syntax wie Destruktuierung oder destruktuierende Zuordnung eingehen . Sie können ein Objekt mehreren Variablen gleichzeitig zuweisen und es aufteilen.

Stellen Sie sich vor, wir haben eine Methode in der API, die mehrere Entitäten gleichzeitig zurückgibt:

@GET("/foo/api/sync")
fun getBrandsAndCards(): Single<BrandAndCardResponse>
data class BrandAndCardResponse(@SerializedName("cards") val cards: List<Card>?,
                             @SerializedName("brands") val brands: List<Brand>?)

Eine kompakte Methode, um das Ergebnis dieser Methode zurückzugeben, ist die Destrukturierung (siehe folgendes Beispiel):

syncRepository.getBrandsAndCards()
       .flatMapCompletable {it->
           Completable.fromAction{
               val (cards, brands) = it
                   syncCards(cards)
                   syncBrands(brands)
               }
           }
       }

Es ist erwähnenswert, dass Multi-Deklarationen auf einer Konvention basieren: Klassen, die strukturiert sein sollen, müssen functionN () - Funktionen enthalten, wobei N die entsprechende Nummer der Komponente ist, die Mitglied der Klasse ist. Das obige Beispiel wird also in folgenden Code übersetzt:

val cards = it.component1()
val brands = it.component2()

In unserem Beispiel wird eine Datenklasse verwendet, die die Funktion componentN () automatisch deklariert. Daher funktionieren Multi-Deklarationen sofort nach dem Auspacken.

Im Folgenden wird näher auf eine Datenklasse eingegangen, die sich auf die Datenschicht konzentriert.

Datenschicht


Diese Schicht umfasst den POJO für Daten vom Server und der Basis sowie Schnittstellen zum Arbeiten mit lokalen Daten und Daten, die vom Server empfangen werden.

Um mit lokalen Daten arbeiten zu können, wurde Room verwendet, was uns einen praktischen Wrapper für die Arbeit mit der SQLite-Datenbank zur Verfügung stellte.

Das erste Ziel für die Migration, das sich nahelegt, sind POJOs, die im Standard-Java-Code dreidimensionale Klassen mit vielen Feldern und entsprechenden get / set-Methoden sind. Sie können POJOs mit Hilfe von Data-Klassen präziser gestalten. Eine Zeile Code reicht aus, um eine Entität mit mehreren Feldern zu beschreiben:


data class Card(val id:String, val cardNumber:String,
                val brandId:String,val barCode:String)

Neben der Kürze erhalten wir:

  • Überschriebene Methoden equals () , hashCode () und toString () unter der Haube. Die Generierung von "equals" für alle Eigenschaften der Datenklasse ist äußerst praktisch, wenn Sie DiffUtil in einem Adapter verwenden, der Ansichten für RecyclerView generiert. Fakt ist, dass DiffUtil zwei Datensätze, zwei Listen, vergleicht: die alte und die neue, erkennt, welche Änderungen aufgetreten sind, und aktualisiert mithilfe von Benachrichtigungsmethoden den Adapter optimal. Und in der Regel werden Listenelemente mit Gleichen verglichen.

    Nach dem Hinzufügen eines neuen Feldes zu einer Klasse müssen wir es daher nicht zu Gleichgestellten hinzufügen, damit DiffUtil ein neues Feld berücksichtigt.
  • Immmtable-Klasse
  • Unterstützung für Standardwerte, die mithilfe des Builder-Musters ersetzt werden können.

    Beispiel:

    data class Card(val id : Long = 0L, val cardNumber: String="99", 
    val barcode: String = "", var brandId: String="1")
    val newCard = Card(id =1L,cardNumber = "123")

Eine weitere gute Nachricht: Mit einem konfigurierten kapt (wie oben beschrieben) funktionieren die Data-Klassen gut mit Raumanmerkungen, sodass alle Datenbankentitäten in Data-Klassen übersetzt werden können. Room unterstützt auch nullfähige Eigenschaften. Zwar unterstützt Room die Standardwerte von Kotlin noch nicht, dies ist jedoch bereits der entsprechende Fehler.

Schlussfolgerungen


Wir haben nur einige Fallstricke betrachtet, die während des Migrationsprozesses von Java nach Kotlin auftreten können. Es ist wichtig, dass zwar Probleme auftreten, insbesondere wenn theoretisches Wissen oder praktische Erfahrung fehlt, sie jedoch alle lösbar sind.

Die Freude, einen kurzen, ausdrucksstarken und sicheren Code auf Kotlin zu schreiben, wird jedoch alle Schwierigkeiten, die sich bei der Umstellung ergeben, mehr als ausgleichen. Ich kann mit Sicherheit sagen, dass das Beispiel des DISCO-Projekts dies sicherlich bestätigt.

Bücher, nützliche Links, Ressourcen


  1. Die theoretische Grundlage der Kenntnis der Sprache wird das Buch Kotlin in Action von den Machern der Sprache Svetlana Isakova und Dmitry Zhemerov legen.

    Prägnanz, informativ, breite Themenabdeckung, Fokus auf Java-Entwickler und die Verfügbarkeit einer Version in Russisch machen sie zu Beginn des Fremdsprachenerwerbs zum besten Werkzeug. Ich habe es von ihr angefangen.
  2. Quellen zu Kotlin von developer.android.
  3. Kotlin Guide auf Russisch
  4. Ausgezeichneter Artikel von Konstantin Mikhailovsky, einem Android-Entwickler von Genesis, über die Erfahrung, zu Kotlin zu wechseln.

Jetzt auch beliebt: