Anwendungsleistung mit Kotlin-Koroutinen verbessern

Mit Kotlin-Koroutinen können Sie sauberen, vereinfachten asynchronen Code schreiben, der Ihre Anwendung responsiv hält und gleichzeitig lang andauernde Aufgaben wie Netzwerkaufrufe oder Laufwerkvorgänge verwaltet.

In diesem Artikel werden die Koroutinen unter Android im Detail beschrieben. Wenn Sie mit Koroutinen nicht vertraut sind, sollten Sie zuerst den Artikel Kotlin-Koroutinen unter Android lesen, bevor Sie dieses Thema lesen.

Lang andauernde Aufgaben verwalten

Koroutinen basieren auf regulären Funktionen und fügen zwei Vorgänge zur Verarbeitung von Aufgaben mit langer Ausführungszeit hinzu. Neben invoke (oder call) und return fügen Koroutinen suspend und resume hinzu:

  • suspend pausiert die Ausführung der aktuellen Koroutine und speichert alle lokalen Variablen.
  • resume setzt die Ausführung einer angehaltenen Koroutine an der Stelle fort, an der sie angehalten wurde.

Sie können suspend-Funktionen nur über andere suspend-Funktionen oder mithilfe eines Koroutinen-Builders wie launch aufrufen, um eine neue Koroutine zu starten.

Das folgende Beispiel zeigt eine einfache Koroutinenimplementierung für eine hypothetische Aufgabe mit langer Ausführungszeit:

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

In diesem Beispiel wird get() weiterhin im Hauptthread ausgeführt, hält aber die Koroutine an, bevor die Netzwerkanfrage gestartet wird. Wenn die Netzwerkanfrage abgeschlossen ist, setzt get die angehaltene Koroutine fort, anstatt einen Callback zur Benachrichtigung des Hauptthreads zu verwenden.

Kotlin verwendet einen Stackframe, um zu verwalten, welche Funktion zusammen mit lokalen Variablen ausgeführt wird. Beim Anhalten einer Koroutine wird der aktuelle Stapelframe kopiert und für später gespeichert. Beim Fortsetzen wird der Stack-Frame vom Speicherort zurückkopiert und die Funktion wird wieder ausgeführt. Auch wenn der Code wie eine gewöhnliche sequenzielle Blockierungsanfrage aussieht, sorgt die Koroutine dafür, dass die Netzwerkanfrage den Hauptthread nicht blockiert.

Koroutinen für „main_safety“ verwenden

Kotlin-Koroutinen verwenden Dispatcher, um zu bestimmen, welche Threads für die Koroutinenausführung verwendet werden. Wenn Code außerhalb des Hauptthreads ausgeführt werden soll, können Sie Kotlin-Koroutinen anweisen, Arbeit im Default- oder IO-Dispatcher auszuführen. In Kotlin müssen alle Koroutinen in einem Dispatcher ausgeführt werden, auch wenn sie im Hauptthread ausgeführt werden. Koroutinen können sich selbst aussetzen und der Disponent ist dafür verantwortlich, sie fortzusetzen.

Um anzugeben, wo die Koroutinen ausgeführt werden sollen, bietet Kotlin drei Disponenten, die Sie verwenden können:

  • Dispatchers.Main: Verwenden Sie diesen Dispatcher, um eine Koroutine im Android-Hauptthread auszuführen. Er sollte nur für die Interaktion mit der Benutzeroberfläche und für schnelle Aufgaben verwendet werden. Beispiele hierfür sind das Aufrufen von suspend-Funktionen, das Ausführen von Android-UI-Framework-Vorgängen und das Aktualisieren von LiveData-Objekten.
  • Dispatchers.IO – Dieser Dispatcher ist so optimiert, dass Laufwerks- oder Netzwerk-E/A außerhalb des Hauptthreads ausgeführt werden können. Beispiele hierfür sind die Verwendung der Komponente „Room“, das Lesen aus oder Schreiben in Dateien und das Ausführen von Netzwerkvorgängen.
  • Dispatchers.Default – Dieser Dispatcher ist so optimiert, dass CPU-intensive Aufgaben außerhalb des Hauptthreads ausgeführt werden. Beispielanwendungsfälle sind das Sortieren einer Liste und das Parsen von JSON.

Ausgehend vom vorherigen Beispiel können Sie die Disponenten verwenden, um die get-Funktion neu zu definieren. Rufen Sie im Text von get withContext(Dispatchers.IO) auf, um einen Block zu erstellen, der im E/A-Thread-Pool ausgeführt wird. Jeder Code, den Sie in diesen Block einfügen, wird immer über den Disponenten IO ausgeführt. Da withContext selbst eine Sperrungsfunktion ist, ist die Funktion get auch eine Anhaltefunktion.

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 (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

Mit Koroutinen können Sie Threads präzise weiterleiten. Da Sie mit withContext() den Thread-Pool einer beliebigen Codezeile steuern können, ohne Callbacks einzuführen, können Sie ihn auf sehr kleine Funktionen wie das Lesen aus einer Datenbank oder das Ausführen einer Netzwerkanfrage anwenden. Es empfiehlt sich, withContext() zu verwenden, damit jede Funktion hauptsicher ist. Das bedeutet, dass Sie die Funktion aus dem Hauptthread aufrufen können. Auf diese Weise muss sich der Aufrufer nie darüber Gedanken machen, welcher Thread zum Ausführen der Funktion verwendet werden soll.

Im vorherigen Beispiel wird fetchDocs() im Hauptthread ausgeführt. Sie kann jedoch ohne Bedenken get aufrufen, wodurch im Hintergrund eine Netzwerkanfrage ausgeführt wird. Da Koroutinen suspend und resume unterstützen, wird die Koroutine im Hauptthread mit dem Ergebnis get fortgesetzt, sobald der Block withContext abgeschlossen ist.

Leistung von withContext()

withContext() verursacht im Vergleich zu einer entsprechenden Callback-basierten Implementierung keinen zusätzlichen Aufwand. Außerdem können withContext()-Aufrufe in einigen Situationen über eine entsprechende Callback-basierte Implementierung hinaus optimiert werden. Wenn eine Funktion beispielsweise zehn Aufrufe an ein Netzwerk sendet, können Sie Kotlin anweisen, Threads nur einmal zu wechseln. Dazu verwenden Sie einen äußeren withContext(). Auch wenn die Netzwerkbibliothek withContext() mehrmals verwendet, bleibt er beim selben Dispatcher und vermeidet den Wechsel von Threads. Darüber hinaus optimiert Kotlin den Wechsel zwischen Dispatchers.Default und Dispatchers.IO, um Thread-Switches nach Möglichkeit zu vermeiden.

Koroutine starten

Sie können Koroutinen auf zwei Arten starten:

  • launch startet eine neue Koroutine und gibt das Ergebnis nicht an den Aufrufer zurück. Jede Arbeit, die als „Fire and Vergessen“ gilt, kann mit launch gestartet werden.
  • async startet eine neue Koroutine und ermöglicht es Ihnen, ein Ergebnis mit einer Anhaltenfunktion namens await zurückzugeben.

Normalerweise sollten Sie mit launch eine neue Koroutine aus einer regulären Funktion erstellen, da await mit einer regulären Funktion nicht aufgerufen werden kann. Verwenden Sie async nur dann, wenn sich eine andere Koroutine oder eine Anhaltenfunktion mit paralleler Zerlegung befindet.

Parallele Zerlegung

Alle Koroutinen, die innerhalb einer suspend-Funktion gestartet werden, müssen beendet werden, wenn diese Funktion zurückgegeben wird. Daher müssen Sie wahrscheinlich sicherstellen, dass diese Koroutinen vor der Rückgabe abgeschlossen sind. Mit strukturierter Nebenläufigkeit in Kotlin können Sie einen coroutineScope definieren, der eine oder mehrere Koroutinen startet. Wenn Sie dann await() (für eine einzelne Koroutine) oder awaitAll() (für mehrere Koroutinen) verwenden, können Sie dafür sorgen, dass diese Koroutinen beendet sind, bevor sie von der Funktion zurückgegeben werden.

Lassen Sie uns beispielsweise einen coroutineScope definieren, der zwei Dokumente asynchron abruft. Durch das Aufrufen von await() für jede zurückgestellte Referenz garantieren wir, dass beide async-Vorgänge abgeschlossen sind, bevor ein Wert zurückgegeben wird:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

Sie können awaitAll() auch für Sammlungen verwenden, wie im folgenden Beispiel gezeigt:

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

Obwohl fetchTwoDocs() neue Koroutinen mit async startet, verwendet die Funktion awaitAll(), um zu warten, bis diese gestarteten Koroutinen beendet sind, bevor sie zurückgegeben werden. Selbst wenn awaitAll() nicht aufgerufen wurde, setzt der coroutineScope-Builder die Koroutine mit dem Aufruf fetchTwoDocs erst fort, wenn alle neuen Koroutinen abgeschlossen sind.

Darüber hinaus fängt coroutineScope alle Ausnahmen ab, die von den Koroutinen ausgelöst werden, und leitet sie an den Aufrufer zurück.

Weitere Informationen zur parallelen Zerlegung finden Sie unter Aussetzen von Haltefunktionen.

Koroutinenkonzepte

CoroutineScope

Ein CoroutineScope erfasst alle Koroutinen, die er mit launch oder async erstellt. Die laufenden Arbeiten (d.h. die laufenden Koroutinen) können jederzeit durch Aufrufen von scope.cancel() abgebrochen werden. In Android stellen einige KTX-Bibliotheken für bestimmte Lebenszyklusklassen ihre eigene CoroutineScope bereit. Beispiel: ViewModel hat viewModelScope und Lifecycle hat lifecycleScope. Im Gegensatz zu einem Dispatcher führt ein CoroutineScope die Koroutinen jedoch nicht aus.

viewModelScope wird auch in den Beispielen unter Hintergrundthreading unter Android mit Koroutinen verwendet. Wenn Sie jedoch eine eigene CoroutineScope erstellen müssen, um den Lebenszyklus von Koroutinen in einer bestimmten Ebene Ihrer Anwendung zu steuern, können Sie so eine erstellen:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Mit einem abgebrochenen Bereich können keine weiteren Koroutinen erstellt werden. Daher sollten Sie scope.cancel() nur dann aufrufen, wenn die Klasse, die ihren Lebenszyklus steuert, gelöscht wird. Wenn Sie viewModelScope verwenden, bricht die Klasse ViewModel den Bereich automatisch für Sie in der Methode onCleared() der ViewModel-Methode ab.

Job

Ein Job ist ein Handle für eine Koroutine. Jede Koroutine, die Sie mit launch oder async erstellen, gibt eine Job-Instanz zurück, die die Koroutine eindeutig identifiziert und ihren Lebenszyklus verwaltet. Sie können eine Job auch an eine CoroutineScope übergeben, um ihren Lebenszyklus weiter zu verwalten, wie im folgenden Beispiel gezeigt:

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

Ein CoroutineContext definiert das Verhalten einer Koroutine mithilfe der folgenden Gruppe von Elementen:

Bei neuen Koroutinen, die innerhalb eines Bereichs erstellt werden, wird der neuen Koroutine eine neue Job-Instanz zugewiesen. Die anderen CoroutineContext-Elemente werden aus dem übergeordneten Bereich übernommen. Sie können die übernommenen Elemente überschreiben, indem Sie einen neuen CoroutineContext an die Funktion launch oder async übergeben. Die Übergabe von Job an launch oder async hat keine Auswirkungen, da eine neue Instanz von Job immer einer neuen Koroutine zugewiesen wird.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Zusätzliche Ressourcen für Koroutinen

Weitere Ressourcen zu Koroutinen finden Sie unter den folgenden Links: