Sean McQuillan
Sean McQuillan

Follow

Apr 30, 2019 – 9 min read

Dies ist Teil einer mehrteiligen Serie über die Verwendung von Coroutines auf Android. Dieser Beitrag konzentriert sich darauf, wie Coroutines funktionieren und welche Probleme sie lösen.

Weitere Artikel in dieser Serie:

Kotlin-Coroutines führen eine neue Art der Gleichzeitigkeit ein, die auf Android verwendet werden kann, um asynchronen Code zu vereinfachen. Während sie in Kotlin 1.3 neu sind, gibt es das Konzept der Coroutines schon seit den Anfängen der Programmiersprachen. Die erste Sprache, die die Verwendung von Coroutines erforschte, war Simula im Jahr 1967.

In den letzten Jahren haben Coroutines an Popularität gewonnen und sind nun in vielen beliebten Programmiersprachen wie Javascript, C#, Python, Ruby und Go enthalten, um nur einige zu nennen. Kotlin-Coroutinen basieren auf etablierten Konzepten, die zum Aufbau großer Anwendungen verwendet wurden.

Unter Android sind Coroutinen eine großartige Lösung für zwei Probleme:

  1. Lang laufende Aufgaben sind Aufgaben, die zu lange dauern, um den Hauptthread zu blockieren.
  2. Mit Main-Safety kann man sicherstellen, dass jede Suspend-Funktion vom Haupt-Thread aus aufgerufen werden kann.

Lassen Sie uns in die einzelnen Aufgaben eintauchen, um zu sehen, wie Coroutines uns helfen können, den Code sauberer zu strukturieren!

Lang laufende Aufgaben

Das Abrufen einer Webseite oder die Interaktion mit einer API beinhalten beide eine Netzwerkanfrage. Auch das Lesen aus einer Datenbank oder das Laden eines Bildes von der Festplatte erfordert das Lesen einer Datei. Diese Art von Dingen nenne ich „lang laufende Aufgaben“ – Aufgaben, die viel zu lange dauern, als dass deine App anhalten und auf sie warten könnte!

Es kann schwer zu verstehen sein, wie schnell ein modernes Telefon Code im Vergleich zu einer Netzwerkanfrage ausführt. Auf einem Pixel 2 dauert ein einzelner CPU-Zyklus knapp 0,0000000004 Sekunden, eine Zahl, die für den Menschen nur schwer zu erfassen ist. Wenn man sich jedoch eine Netzwerkanforderung als einen Wimpernschlag vorstellt, der etwa 400 Millisekunden (0,4 Sekunden) dauert, ist es einfacher zu verstehen, wie schnell die CPU arbeitet. In einem Wimpernschlag oder einer etwas langsamen Netzwerkanforderung kann die CPU über eine Milliarde Zyklen ausführen!

Unter Android hat jede App einen Haupt-Thread, der für die Handhabung der Benutzeroberfläche (z. B. das Zeichnen von Ansichten) und die Koordination der Benutzerinteraktionen zuständig ist. Wenn zu viel Arbeit in diesem Thread stattfindet, scheint die App zu hängen oder langsamer zu werden, was zu einem unerwünschten Benutzererlebnis führt. Jede langlaufende Aufgabe sollte ohne Blockierung des Hauptthreads ausgeführt werden, damit Ihre Anwendung keinen sogenannten „Jank“, wie eingefrorene Animationen, anzeigt oder langsam auf Berührungsereignisse reagiert.

Um eine Netzwerkanforderung außerhalb des Hauptthreads auszuführen, werden häufig Rückrufe verwendet. Callbacks stellen ein Handle zu einer Bibliothek bereit, mit dem sie zu einem späteren Zeitpunkt in den Code zurückkehren können. Mit Callbacks könnte der Abruf von developer.android.com wie folgt aussehen:

class ViewModel: ViewModel() {
fun fetchDocs() {
get("developer.android.com") { result ->
show(result)
}
}
}

Auch wenn get vom Hauptthread aus aufgerufen wird, wird ein anderer Thread verwendet, um die Netzwerkanfrage durchzuführen. Sobald das Ergebnis aus dem Netzwerk verfügbar ist, wird der Callback im Hauptthread aufgerufen. Dies ist eine großartige Methode, um lang laufende Aufgaben zu bewältigen, und Bibliotheken wie Retrofit können Ihnen helfen, Netzwerkanfragen zu stellen, ohne den Hauptthread zu blockieren.

Verwendung von Coroutines für lang laufende Aufgaben

Coroutines sind eine Möglichkeit, den Code zur Verwaltung lang laufender Aufgaben wie fetchDocs zu vereinfachen. Um herauszufinden, wie Coroutines den Code für lang laufende Aufgaben vereinfachen, schreiben wir das obige Callback-Beispiel um, um Coroutines zu verwenden.

// Dispatchers.Main
suspend fun fetchDocs() {
// Dispatchers.Main
val result = get("developer.android.com")
// Dispatchers.Main
show(result)
}// look at this in the next section
suspend fun get(url: String) = withContext(Dispatchers.IO){/*...*/}

Blockiert dieser Code nicht den Hauptthread? Wie gibt er ein Ergebnis von get zurück, ohne auf die Netzwerkanforderung zu warten und zu blockieren? Es stellt sich heraus, dass Coroutines einen Weg für Kotlin bieten, diesen Code auszuführen und den Hauptthread nicht zu blockieren.

Coroutines bauen auf regulären Funktionen auf, indem sie zwei neue Operationen hinzufügen. Zusätzlich zu invoke (oder call) und return fügen Coroutines suspend und resume hinzu.

  • suspend – pausiert die Ausführung der aktuellen Coroutine und speichert alle lokalen Variablen
  • resume – setzt eine suspendierte Coroutine an der Stelle fort, an der sie pausiert wurde

Diese Funktionalität wird von Kotlin durch das suspend-Schlüsselwort in der Funktion hinzugefügt. Sie können Suspend-Funktionen nur von anderen Suspend-Funktionen aus aufrufen, oder indem Sie einen Coroutine-Builder wie launch verwenden, um eine neue Coroutine zu starten.

Suspend und Resume arbeiten zusammen, um Rückrufe zu ersetzen.

Im obigen Beispiel wird get die Coroutine suspendieren, bevor sie die Netzwerkanfrage startet. Die Funktion get ist weiterhin für die Ausführung der Netzwerkanforderung außerhalb des Hauptthreads verantwortlich. Wenn die Netzwerkanforderung abgeschlossen ist, muss sie keinen Callback aufrufen, um den Hauptthread zu benachrichtigen, sondern kann einfach die unterbrochene Coroutine wieder aufnehmen.

Animation, die zeigt, wie Kotlin Suspend und Resume implementiert, um Callbacks zu ersetzen.

Bei der Ausführung von fetchDocs kann man sehen, wie Suspend funktioniert. Immer, wenn eine Coroutine angehalten wird, wird der aktuelle Stack-Frame (der Ort, den Kotlin verwendet, um zu verfolgen, welche Funktion läuft und ihre Variablen) kopiert und für später gespeichert. Wenn die Coroutine fortgesetzt wird, wird der Stack-Frame von der Stelle, an der er gespeichert wurde, zurückkopiert und beginnt erneut zu laufen. In der Mitte der Animation – wenn alle Coroutines des Hauptthreads angehalten werden – ist der Hauptthread frei, um den Bildschirm zu aktualisieren und Benutzerereignisse zu verarbeiten. Zusammen ersetzen Suspend und Resume die Callbacks.

Wenn alle Coroutines auf dem Haupt-Thread angehalten sind, kann der Haupt-Thread andere Aufgaben erledigen.

Auch wenn wir einen einfachen sequentiellen Code geschrieben haben, der genau wie eine blockierende Netzwerkanforderung aussieht, führen Coroutines unseren Code genau so aus, wie wir es wollen, und vermeiden eine Blockierung des Haupt-Threads!

Als Nächstes wollen wir uns ansehen, wie Coroutines für die Hauptsicherheit verwendet werden können und uns mit Dispatchern beschäftigen.

Hauptsicherheit mit Coroutines

In Kotlin-Coroutines sind gut geschriebene Suspend-Funktionen immer sicher, wenn sie vom Haupt-Thread aufgerufen werden. Egal was sie tun, sie sollten immer jedem Thread erlauben, sie aufzurufen.

Aber es gibt eine Menge Dinge, die wir in unseren Android-Apps tun, die zu langsam sind, um auf dem Haupt-Thread zu passieren. Netzwerkanfragen, das Parsen von JSON, das Lesen oder Schreiben aus der Datenbank oder auch nur die Iteration über große Listen. Jede dieser Funktionen kann so langsam sein, dass sie für den Benutzer sichtbar „ruckelt“ und sollte daher im Haupt-Thread ausgeführt werden.

Die Verwendung von suspend sagt Kotlin nicht, dass eine Funktion in einem Hintergrund-Thread ausgeführt werden soll. Es lohnt sich, klar und oft zu sagen, dass Coroutines auf dem Haupt-Thread laufen werden. Tatsächlich ist es eine wirklich gute Idee, Dispatchers.Main.immediate zu verwenden, wenn eine Coroutine als Reaktion auf ein UI-Ereignis gestartet wird – auf diese Weise kann das Ergebnis im nächsten Frame für den Benutzer verfügbar sein, wenn man nicht eine lang laufende Aufgabe ausführt, die Main-Safety erfordert.

Coroutinen werden auf dem Haupt-Thread ausgeführt, und „suspend“ bedeutet nicht „im Hintergrund“.

Um eine Funktion, die eine Arbeit ausführt, die zu langsam für den Haupt-Thread ist, main-safe zu machen, kann man Kotlin-Coroutinen anweisen, die Arbeit entweder auf dem Default oder IO Dispatcher auszuführen. In Kotlin müssen alle Coroutines in einem Dispatcher laufen – auch wenn sie auf dem Hauptthread laufen. Coroutines können sich selbst suspendieren, und der Dispatcher ist derjenige, der weiß, wie man sie wieder aufnimmt.

Um festzulegen, wo die Coroutines laufen sollen, bietet Kotlin drei Dispatcher, die man für den Thread-Dispatch verwenden kann.

+-----------------------------------+
| Dispatchers.Main |
+-----------------------------------+
| Main thread on Android, interact |
| with the UI and perform light |
| work |
+-----------------------------------+
| - Calling suspend functions |
| - Call UI functions |
| - Updating LiveData |
+-----------------------------------+
+-----------------------------------+
| Dispatchers.IO |
+-----------------------------------+
| Optimized for disk and network IO |
| off the main thread |
+-----------------------------------+
| - Database* |
| - Reading/writing files |
| - Networking** |
+-----------------------------------+
+-----------------------------------+
| Dispatchers.Default |
+-----------------------------------+
| Optimized for CPU intensive work |
| off the main thread |
+-----------------------------------+
| - Sorting a list |
| - Parsing JSON |
| - DiffUtils |
+-----------------------------------+

* Room bietet automatisch Main-Safety, wenn man Suspend-Funktionen, RxJava oder LiveData verwendet.

** Netzwerkbibliotheken wie Retrofit und Volley verwalten ihre eigenen Threads und erfordern keine explizite Hauptsicherheit in Ihrem Code, wenn sie mit Kotlin-Coroutinen verwendet werden.

Um mit dem obigen Beispiel fortzufahren, lassen Sie uns die Dispatcher verwenden, um die Funktion get zu definieren. Innerhalb des Körpers von get rufen Sie withContext(Dispatchers.IO) auf, um einen Block zu erstellen, der auf dem IO-Dispatcher läuft. Jeder Code, den Sie in diesen Block einfügen, wird immer auf dem Dispatcher IO ausgeführt. Da withContext selbst eine Suspend-Funktion ist, funktioniert sie unter Verwendung von Coroutines, um die Hauptsicherheit zu gewährleisten.

// Dispatchers.Main
suspend fun fetchDocs() {
// Dispatchers.Main
val result = get("developer.android.com")
// Dispatchers.Main
show(result)
}// Dispatchers.Main
suspend fun get(url: String) =
// Dispatchers.Main
withContext(Dispatchers.IO) {
// Dispatchers.IO
/* perform blocking network IO here */
}
// Dispatchers.Main

Mit Coroutines können Sie Thread-Dispatching mit feinkörniger Kontrolle durchführen. Da withContext Ihnen die Kontrolle darüber gibt, in welchem Thread eine beliebige Codezeile ausgeführt wird, ohne einen Callback einzuführen, um das Ergebnis zurückzugeben, können Sie es auf sehr kleine Funktionen anwenden, wie das Lesen aus Ihrer Datenbank oder das Ausführen einer Netzwerkanfrage. Eine gute Praxis ist es also, withContext zu verwenden, um sicherzustellen, dass jede Funktion sicher auf jedem Dispatcher einschließlich Main aufgerufen werden kann – auf diese Weise muss der Aufrufer nie darüber nachdenken, welcher Thread für die Ausführung der Funktion benötigt wird.

In diesem Beispiel wird fetchDocs auf dem Hauptthread ausgeführt, kann aber sicher get aufrufen, das eine Netzwerkanfrage im Hintergrund durchführt. Da Coroutines Suspend und Resume unterstützen, wird die Coroutine auf dem Haupt-Thread mit dem Ergebnis fortgesetzt, sobald der withContext-Block abgeschlossen ist.

Gut geschriebene Suspend-Funktionen sind immer sicher, wenn sie vom Haupt-Thread aus aufgerufen werden (oder main-safe).

Es ist eine wirklich gute Idee, jede Suspend-Funktion main-safe zu machen. Wenn sie irgendetwas tut, das die Festplatte oder das Netzwerk berührt oder auch nur zu viel CPU verbraucht, verwenden Sie withContext, um sie sicher zu machen, damit sie vom Haupt-Thread aus aufgerufen werden kann. Dies ist das Muster, dem Coroutine-basierte Bibliotheken wie Retrofit und Room folgen. Wenn Sie diesen Stil in Ihrer gesamten Codebasis befolgen, wird Ihr Code viel einfacher und vermeidet die Vermischung von Threading-Belangen mit der Anwendungslogik. Bei konsequenter Befolgung können Coroutines auf dem Hauptthread starten und mit einfachem Code Netzwerk- oder Datenbankanfragen stellen, ohne dass die Benutzer „Jank“ sehen.

Die Leistung von withContext

withContext ist genauso schnell wie Callbacks oder RxJava, um die Hauptsicherheit zu gewährleisten. Es ist sogar möglich, withContext-Aufrufe zu optimieren, was in manchen Situationen mit Callbacks nicht möglich ist. Wenn eine Funktion 10 Aufrufe an eine Datenbank macht, kann man Kotlin anweisen, einmal in einem äußeren withContext um alle 10 Aufrufe zu wechseln. Auch wenn die Datenbankbibliothek wiederholt withContext aufruft, bleibt sie auf demselben Dispatcher und folgt einem schnellen Pfad. Außerdem wird der Wechsel zwischen Dispatchers.Default und Dispatchers.IO so optimiert, dass Threadwechsel nach Möglichkeit vermieden werden.

Wie geht es weiter

In diesem Beitrag haben wir untersucht, welche Probleme Coroutines hervorragend lösen können. Coroutines sind ein sehr altes Konzept in Programmiersprachen, die in letzter Zeit aufgrund ihrer Fähigkeit, Code, der mit dem Netzwerk interagiert, zu vereinfachen, populär geworden sind.

Unter Android kann man sie verwenden, um zwei sehr häufige Probleme zu lösen:

  1. Vereinfachung des Codes für lang laufende Aufgaben wie das Lesen aus dem Netzwerk, von der Festplatte oder sogar das Parsen eines großen JSON-Ergebnisses.
  2. Präzise Main-Safety, um sicherzustellen, dass der Haupt-Thread nie versehentlich blockiert wird, ohne dass der Code schwer zu lesen und zu schreiben ist.

Im nächsten Beitrag werden wir untersuchen, wie sie auf Android passen, um die ganze Arbeit zu verfolgen, die Sie von einem Bildschirm aus gestartet haben! Lies ihn mal:

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.