Anwendungsleistung mit Kotlin-Koroutinen verbessern

Mit Kotlin-Coroutinen können Sie übersichtlichen, vereinfachten asynchronen Code schreiben, der Ihre App reaktionsschnell hält und gleichzeitig lang andauernde Aufgaben wie Netzwerkaufrufe oder Laufwerkoperationen verwaltet.

In diesem Thema werden Coroutinen unter Android ausführlich behandelt. Wenn Sie mit Koroutinen nicht vertraut sind, sollten Sie sich vor dem Lesen dieses Themas mit Kotlin-Koroutinen in Android vertraut machen.

Lang andauernde Aufgaben verwalten

Coroutinen basieren auf regulären Funktionen und fügen zwei Vorgänge zum Verarbeiten von zeitaufwendigen Aufgaben hinzu. Zusätzlich zu invoke (oder call) und return werden durch Coroutinen suspend und resume hinzugefügt:

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

suspend-Funktionen können nur von anderen suspend-Funktionen aufgerufen werden oder Sie können mit einem Coroutine-Builder wie launch eine neue Coroutine starten.

Das folgende Beispiel zeigt eine einfache Coroutine-Implementierung 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, aber die Coroutine wird angehalten, bevor die Netzwerkanfrage gestartet wird. Wenn die Netzwerkanfrage abgeschlossen ist, wird die angehaltene Coroutine von get fortgesetzt, anstatt den Hauptthread über einen Callback zu benachrichtigen.

In Kotlin wird ein Stapelframe verwendet, um zu verwalten, welche Funktion zusammen mit lokalen Variablen ausgeführt wird. Beim Unterbrechen einer Coroutine wird der aktuelle Stapelrahmen kopiert und für später gespeichert. Beim Fortsetzen wird der Stapelframe von der Stelle, an der er gespeichert wurde, zurückkopiert und die Funktion wird wieder ausgeführt. Auch wenn der Code wie eine normale sequenzielle blockierende Anfrage aussieht, sorgt die Coroutine dafür, dass die Netzwerkanfrage den Hauptthread nicht blockiert.

Coroutinen für die Hauptthread-Sicherheit verwenden

Kotlin-Coroutinen verwenden Dispatcher, um zu bestimmen, welche Threads für die Ausführung von Coroutinen verwendet werden. Wenn Sie Code außerhalb des Hauptthreads ausführen möchten, können Sie Kotlin-Coroutinen anweisen, die Arbeit entweder im Default- oder im IO-Dispatcher auszuführen. In Kotlin müssen alle Coroutinen in einem Dispatcher ausgeführt werden, auch wenn sie im Hauptthread ausgeführt werden. Coroutinen können sich selbst unterbrechen und der Dispatcher ist für die Wiederaufnahme verantwortlich.

In Kotlin gibt es drei Dispatcher, mit denen Sie angeben können, wo die Koroutinen ausgeführt werden sollen:

  • Dispatchers.Main: Mit diesem Dispatcher wird eine Coroutine im Android-Hauptthread ausgeführt. Diese Option 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 für die Ausführung von Festplatten- oder Netzwerk-E/A außerhalb des Hauptthreads optimiert. Beispiele hierfür sind die Verwendung der Room-Komponente, das Lesen aus oder Schreiben in Dateien sowie die Ausführung von Netzwerkoperationen.
  • Dispatchers.Default: Dieser Dispatcher ist für die Ausführung von CPU-intensiven Aufgaben außerhalb des Hauptthreads optimiert. Beispiele für Anwendungsfälle sind das Sortieren einer Liste und das Parsen von JSON.

Im vorherigen Beispiel können Sie die Dispatcher 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 IO-Threadpool ausgeführt wird. Jeder Code, den Sie in diesen Block einfügen, wird immer über den IO-Dispatcher ausgeführt. Da withContext selbst eine Suspend-Funktion ist, ist auch die Funktion get eine Suspend-Funktion.

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 Coroutinen können Sie Threads mit detaillierter Steuerung verteilen. Da Sie mit withContext() den Threadpool jeder Codezeile steuern können, ohne Callbacks einzuführen, können Sie ihn auf sehr kleine Funktionen anwenden, z. B. zum Lesen aus einer Datenbank oder zum Ausführen einer Netzwerkanfrage. Es empfiehlt sich, withContext() zu verwenden, um sicherzustellen, dass jede Funktion main-safe ist. Das bedeutet, dass Sie die Funktion vom Hauptthread aus aufrufen können. So muss sich der Aufrufer nie Gedanken darüber machen, welcher Thread für die Ausführung der Funktion verwendet werden soll.

Im vorherigen Beispiel wird fetchDocs() im Hauptthread ausgeführt. Es kann jedoch sicher get aufrufen, wodurch eine Netzwerkanfrage im Hintergrund ausgeführt wird. Da Coroutinen suspend und resume unterstützen, wird die Coroutine im Hauptthread mit dem Ergebnis get fortgesetzt, sobald der withContext-Block abgeschlossen ist.

Leistung von withContext()

withContext() verursacht keinen zusätzlichen Aufwand im Vergleich zu einer entsprechenden Callback-basierten Implementierung. Außerdem ist es in einigen Situationen möglich, withContext()-Aufrufe über eine entsprechende Callback-basierte Implementierung hinaus zu optimieren. Wenn eine Funktion beispielsweise zehn Aufrufe an ein Netzwerk sendet, können Sie Kotlin anweisen, den Thread nur einmal zu wechseln, indem Sie ein äußeres withContext() verwenden. Auch wenn die Netzwerkbibliothek withContext() mehrmals verwendet, bleibt sie auf demselben Dispatcher und vermeidet Thread-Wechsel. Außerdem optimiert Kotlin den Wechsel zwischen Dispatchers.Default und Dispatchers.IO, um Thread-Wechsel nach Möglichkeit zu vermeiden.

Coroutine starten

Es gibt zwei Möglichkeiten, Coroutinen zu starten:

  • launch startet eine neue Coroutine und gibt das Ergebnis nicht an den Aufrufer zurück. Alle Arbeiten, die als „Fire and Forget“ gelten, können mit launch gestartet werden.
  • async startet eine neue Coroutine und ermöglicht es Ihnen, ein Ergebnis mit einer Suspend-Funktion namens await zurückzugeben.

Normalerweise sollten Sie eine neue Coroutine mit launch aus einer regulären Funktion starten, da await nicht aus einer regulären Funktion aufgerufen werden kann. Verwenden Sie async nur innerhalb einer anderen Coroutine oder innerhalb einer suspend-Funktion, wenn Sie eine parallele Zerlegung durchführen.

Parallele Aufteilung

Alle Coroutinen, die in einer suspend-Funktion gestartet werden, müssen beendet werden, wenn diese Funktion zurückgegeben wird. Sie müssen also wahrscheinlich dafür sorgen, dass diese Coroutinen vor der Rückgabe abgeschlossen werden. Mit strukturierter Nebenläufigkeit in Kotlin können Sie einen coroutineScope definieren, der eine oder mehrere Coroutinen startet. Mit await() (für eine einzelne Coroutine) oder awaitAll() (für mehrere Coroutines) können Sie dann dafür sorgen, dass diese Coroutines abgeschlossen werden, bevor die Funktion zurückgegeben wird.

Sehen wir uns als Beispiel eine coroutineScope an, die zwei Dokumente asynchron abruft. Durch den Aufruf von await() für jede verzögerte Referenz wird sichergestellt, 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, wartet die Funktion mit awaitAll() darauf, dass diese gestarteten Koroutinen abgeschlossen werden, bevor sie zurückgegeben wird. Auch wenn wir awaitAll() nicht aufgerufen hätten, würde der coroutineScope-Builder die Coroutine, die fetchTwoDocs aufgerufen hat, erst fortsetzen, nachdem alle neuen Coroutinen abgeschlossen sind.

Außerdem fängt coroutineScope alle Ausnahmen ab, die von den Coroutinen ausgelöst werden, und leitet sie an den Aufrufer zurück.

Weitere Informationen zur parallelen Zerlegung finden Sie unter Suspend-Funktionen zusammensetzen.

Koroutinenkonzepte

CoroutineScope

Ein CoroutineScope verfolgt alle Coroutinen, die mit launch oder async erstellt werden. Die laufende Arbeit (d.h. die ausgeführten Coroutinen) kann jederzeit durch Aufrufen von scope.cancel() abgebrochen werden. In Android stellen einige KTX-Bibliotheken eigene CoroutineScope für bestimmte Lifecycle-Klassen bereit. Beispiel: ViewModel hat eine viewModelScope und Lifecycle hat lifecycleScope. Im Gegensatz zu einem Dispatcher führt ein CoroutineScope die Coroutinen jedoch nicht aus.

viewModelScope wird auch in den Beispielen unter Background threading on Android with Coroutines verwendet. Wenn Sie jedoch ein eigenes CoroutineScope erstellen müssen, um den Lebenszyklus von Coroutinen in einer bestimmten Ebene Ihrer App zu steuern, können Sie es so 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()
    }
}

In einem abgebrochenen Bereich können keine weiteren Coroutinen erstellt werden. Daher sollten Sie scope.cancel() nur aufrufen, wenn die Klasse, die ihren Lebenszyklus steuert, zerstört wird. Wenn Sie viewModelScope verwenden, wird der Bereich durch die Klasse ViewModel automatisch in der Methode onCleared() des ViewModel abgebrochen.

Job

Ein Job ist ein Handle für eine Koroutine. Jede mit launch oder async erstellte Coroutine gibt eine Job-Instanz zurück, die die Coroutine eindeutig identifiziert und ihren Lebenszyklus verwaltet. Sie können auch ein Job an ein CoroutineScope übergeben, um seinen 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 (condition) {
            // 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 Coroutine mit den folgenden Elementen:

Für neue Coroutinen, die in einem Bereich erstellt werden, wird der neuen Coroutine eine neue Job-Instanz zugewiesen und die anderen CoroutineContext-Elemente werden vom enthaltenden Bereich übernommen. Sie können die geerbten Elemente überschreiben, indem Sie der Funktion launch oder async ein neues CoroutineContext übergeben. Das Übergeben eines Job an launch oder async hat keine Auswirkungen, da einer neuen Coroutine immer eine neue Instanz von Job 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 zu Coroutinen

Weitere Informationen zu Coroutines finden Sie unter den folgenden Links: