Best Practices für Koroutinen in Android

Auf dieser Seite werden mehrere Best Practices vorgestellt, die sich positiv auf die Skalierbarkeit und Testbarkeit Ihrer App auswirken, wenn Sie Coroutinen verwenden.

Dispatcher einschleusen

Codieren Sie Dispatchers nicht fest, wenn Sie neue Coroutinen erstellen oder withContext aufrufen.

// DO inject Dispatchers
class NewsRepository(
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    suspend fun loadNews() = withContext(defaultDispatcher) { /* ... */ }
}

// DO NOT hardcode Dispatchers
class NewsRepository {
    // DO NOT use Dispatchers.Default directly, inject it instead
    suspend fun loadNews() = withContext(Dispatchers.Default) { /* ... */ }
}

Dieses Muster für die Abhängigkeitsinjektion erleichtert das Testen, da Sie diese Dispatcher in Einheiten- und Instrumentierungstests durch einen Test-Dispatcher ersetzen können, um Ihre Tests deterministischer zu machen.

Suspend-Funktionen sollten sicher vom Hauptthread aus aufrufbar sein

Suspend-Funktionen sollten main-safe sein, d. h., sie können sicher vom Hauptthread aus aufgerufen werden. Wenn eine Klasse in einer Coroutine lange blockierende Vorgänge ausführt, ist sie dafür verantwortlich, die Ausführung mit withContext vom Hauptthread zu verlagern. Dies gilt für alle Klassen in Ihrer App, unabhängig davon, in welchem Teil der Architektur sich die Klasse befindet.

class NewsRepository(private val ioDispatcher: CoroutineDispatcher) {

    // As this operation is manually retrieving the news from the server
    // using a blocking HttpURLConnection, it needs to move the execution
    // to an IO dispatcher to make it main-safe
    suspend fun fetchLatestNews(): List<Article> {
        withContext(ioDispatcher) { /* ... implementation ... */ }
    }
}

// This use case fetches the latest news and the associated author.
class GetLatestNewsWithAuthorsUseCase(
    private val newsRepository: NewsRepository,
    private val authorsRepository: AuthorsRepository
) {
    // This method doesn't need to worry about moving the execution of the
    // coroutine to a different thread as newsRepository is main-safe.
    // The work done in the coroutine is lightweight as it only creates
    // a list and add elements to it
    suspend operator fun invoke(): Result<List<ArticleWithAuthor>> {
        val news = newsRepository.fetchLatestNews()

        val response = mutableListOf<ArticleWithAuthor>()
        for (article in news) {
            val author = authorsRepository.getAuthor(article.author)
            response.add(ArticleWithAuthor(article, author))
        }
        return Result.Success(response)
    }
}

Dieses Muster macht Ihre App skalierbarer, da sich Klassen, die Suspend-Funktionen aufrufen, nicht darum kümmern müssen, welchen Dispatcher sie für welche Art von Arbeit verwenden sollen. Diese Verantwortung liegt bei der Klasse, die die Arbeit ausführt.

Das ViewModel sollte Coroutinen erstellen

ViewModel -Klassen sollten Coroutinen erstellen, anstatt Suspend-Funktionen zur Ausführung von Geschäftslogik zu verwenden. Suspend-Funktionen im ViewModel können nützlich sein, wenn anstelle der Bereitstellung des Status über einen Datenstream nur ein einzelner Wert ausgegeben werden muss.

// DO create coroutines in the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {

    private val _uiState = MutableStateFlow<LatestNewsUiState>(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    fun loadNews() {
        viewModelScope.launch {
            val latestNewsWithAuthors = getLatestNewsWithAuthors()
            _uiState.value = LatestNewsUiState.Success(latestNewsWithAuthors)
        }
    }
}

// Prefer observable state rather than suspend functions from the ViewModel
class LatestNewsViewModel(
    private val getLatestNewsWithAuthors: GetLatestNewsWithAuthorsUseCase
) : ViewModel() {
    // DO NOT do this. News would probably need to be refreshed as well.
    // Instead of exposing a single value with a suspend function, news should
    // be exposed using a stream of data as in the code snippet above.
    suspend fun loadNews() = getLatestNewsWithAuthors()
}

Ansichten sollten keine Coroutinen direkt auslösen, um Geschäftslogik auszuführen. Übertragen Sie diese Verantwortung stattdessen an das ViewModel. Dadurch lässt sich Ihre Geschäftslogik einfacher testen, da ViewModel-Objekte Einheitentests unterzogen werden können, anstatt Instrumentierungstests zu verwenden, die zum Testen von Ansichten erforderlich sind.

Außerdem überstehen Ihre Coroutinen Konfigurationsänderungen automatisch, wenn die Arbeit im viewModelScope gestartet wird. Wenn Sie Coroutinen stattdessen mit lifecycleScope erstellen, müssen Sie das manuell verarbeiten. Wenn die Coroutine den Bereich von ViewModel überdauern muss, lesen Sie den Abschnitt Coroutinen in der Geschäfts- und Datenschicht erstellen.

Veränderliche Typen nicht verfügbar machen

Es ist besser, unveränderliche Typen für andere Klassen verfügbar zu machen. Auf diese Weise werden alle Änderungen am veränderlichen Typ in einer Klasse zentralisiert, was die Fehlerbehebung erleichtert, wenn etwas schiefgeht.

// DO expose immutable types
class LatestNewsViewModel : ViewModel() {

    private val _uiState = MutableStateFlow(LatestNewsUiState.Loading)
    val uiState: StateFlow<LatestNewsUiState> = _uiState

    /* ... */
}

class LatestNewsViewModel : ViewModel() {

    // DO NOT expose mutable types
    val uiState = MutableStateFlow(LatestNewsUiState.Loading)

    /* ... */
}

Die Daten- und Geschäftsschicht sollten Suspend-Funktionen und Flows verfügbar machen

Klassen in der Daten- und Geschäftsschicht machen in der Regel Funktionen verfügbar, um einmalige Aufrufe auszuführen oder um über Datenänderungen im Laufe der Zeit benachrichtigt zu werden. Klassen in diesen Schichten sollten Suspend-Funktionen für einmalige Aufrufe und Flow zur Benachrichtigung über Datenänderungen verfügbar machen.

// Classes in the data and business layer expose
// either suspend functions or Flows
class ExampleRepository {
    suspend fun makeNetworkRequest() { /* ... */ }

    fun getExamples(): Flow<Example> {
        /* ... */
    }
}

Diese Best Practice ermöglicht es dem Aufrufer, in der Regel der Präsentationsschicht, die Ausführung und den Lebenszyklus der Arbeit in diesen Schichten zu steuern und bei Bedarf abzubrechen.

Coroutinen in der Geschäfts- und Datenschicht erstellen

Für Klassen in der Daten- oder Geschäftsschicht, die aus verschiedenen Gründen Coroutinen erstellen müssen, gibt es verschiedene Optionen.

Wenn die in diesen Coroutinen auszuführende Arbeit nur relevant ist, wenn der Nutzer sich auf dem aktuellen Bildschirm befindet, sollte sie dem Lebenszyklus des Aufrufers folgen. In den meisten Fällen ist der Aufrufer das ViewModel und der Aufruf wird abgebrochen, wenn der Nutzer den Bildschirm verlässt und das ViewModel gelöscht wird. In diesem Fall, coroutineScope oder supervisorScope sollten verwendet werden.

class GetAllBooksAndAuthorsUseCase(
    private val booksRepository: BooksRepository,
    private val authorsRepository: AuthorsRepository,
) {
    suspend fun getBookAndAuthors(): BookAndAuthors {
        // In parallel, fetch books and authors and return when both requests
        // complete and the data is ready
        return coroutineScope {
            val books = async { booksRepository.getAllBooks() }
            val authors = async { authorsRepository.getAllAuthors() }
            BookAndAuthors(books.await(), authors.await())
        }
    }
}

Wenn die auszuführende Arbeit so lange relevant ist, wie die App geöffnet ist, und die Arbeit nicht an einen bestimmten Bildschirm gebunden ist, sollte die Arbeit den Lebenszyklus des Aufrufers überdauern. Für dieses Szenario sollte ein externer CoroutineScope verwendet werden, wie im Blogpost Coroutinen und Muster für Arbeit, die nicht abgebrochen werden sollte erläutert.

class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope,
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch { articlesDataSource.bookmarkArticle(article) }
            .join() // Wait for the coroutine to complete
    }
}

externalScope sollte von einer Klasse erstellt und verwaltet werden, die länger als der aktuelle Bildschirm aktiv ist. Sie kann von der Klasse Application oder einem ViewModel verwaltet werden, das auf einen Navigationsgraphen beschränkt ist.

TestDispatchers in Tests einschleusen

Eine Instanz von TestDispatcher sollte in Tests in Ihre Klassen eingeschleust werden. In der Bibliothek kotlinx-coroutines-test sind zwei Implementierungen verfügbar:

  • StandardTestDispatcher: Stellt Coroutinen, die darauf gestartet wurden, mit einem Scheduler in die Warteschlange und führt sie aus, wenn der Testthread nicht ausgelastet ist. Sie können den Testthread anhalten, damit andere in die Warteschlange gestellte Coroutinen mit Methoden wie advanceUntilIdleausgeführt werden können.

  • UnconfinedTestDispatcher: Führt neue Coroutinen sofort und blockierend aus. Dadurch wird das Schreiben von Tests im Allgemeinen einfacher, aber Sie haben weniger Kontrolle darüber, wie Coroutinen während des Tests ausgeführt werden.

Weitere Informationen finden Sie in der Dokumentation der einzelnen Dispatcher-Implementierungen.

Verwenden Sie den runTest Coroutinen-Builder, um Coroutinen zu testen. runTest uses a TestCoroutineScheduler um Verzögerungen in Tests zu überspringen und die virtuelle Zeit zu steuern. Sie können diesen Scheduler auch verwenden, um bei Bedarf zusätzliche Test-Dispatcher zu erstellen.

class ArticlesRepositoryTest {

    @Test
    fun testBookmarkArticle() = runTest {
        // Pass the testScheduler provided by runTest's coroutine scope to
        // the test dispatcher
        val testDispatcher = UnconfinedTestDispatcher(testScheduler)

        val articlesDataSource = FakeArticlesDataSource()
        val repository = ArticlesRepository(
            articlesDataSource,
            defaultDispatcher = testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

Alle TestDispatchers sollten denselben Scheduler verwenden. So können Sie Ihren gesamten Coroutinen-Code im einzelnen Testthread ausführen, um Ihre Tests deterministisch zu machen. runTest wartet, bis alle Coroutinen, die sich auf demselben Scheduler befinden oder untergeordnete Elemente der Test-Coroutine sind, abgeschlossen sind, bevor die Funktion zurückkehrt.

GlobalScope vermeiden

Dies ähnelt der Best Practice Dispatcher einschleusen. Wenn Sie GlobalScope, den CoroutineScope fest codieren, den eine Klasse verwendet, was einige Nachteile mit sich bringt:

  • Fördert das Festcodieren von Werten. Wenn Sie GlobalScope fest codieren, codieren Sie möglicherweise auch Dispatchers fest.

  • Erschwert das Testen erheblich, da Ihr Code in einem unkontrollierten Bereich ausgeführt wird und Sie die Ausführung nicht steuern können.

  • Sie können keinen gemeinsamen CoroutineContext für alle Coroutinen verwenden, die in den Bereich selbst eingebunden sind.

Erwägen Sie stattdessen, einen CoroutineScope für Arbeit einzuschleusen, die den aktuellen Bereich überdauern muss. Weitere Informationen finden Sie im Abschnitt Coroutinen in der Geschäfts- und Datenschicht erstellen.

// DO inject an external scope instead of using GlobalScope.
// GlobalScope can be used indirectly. Here as a default parameter makes sense.
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
    private val externalScope: CoroutineScope = GlobalScope,
    private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
) {
    // As we want to complete bookmarking the article even if the user moves
    // away from the screen, the work is done creating a new coroutine
    // from an external scope
    suspend fun bookmarkArticle(article: Article) {
        externalScope.launch(defaultDispatcher) {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

// DO NOT use GlobalScope directly
class ArticlesRepository(
    private val articlesDataSource: ArticlesDataSource,
) {
    // As we want to complete bookmarking the article even if the user moves away
    // from the screen, the work is done creating a new coroutine with GlobalScope
    suspend fun bookmarkArticle(article: Article) {
        GlobalScope.launch {
            articlesDataSource.bookmarkArticle(article)
        }
            .join() // Wait for the coroutine to complete
    }
}

Weitere Informationen zu GlobalScope und seinen Alternativen finden Sie im Blogpost Coroutinen und Muster für Arbeit, die nicht abgebrochen werden sollte.

Coroutine abbrechbar machen

Der Abbruch in Coroutinen ist kooperativ. Das bedeutet, dass eine Coroutine erst abgebrochen wird, wenn sie angehalten wird oder auf einen Abbruch prüft, nachdem ihr Job abgebrochen wurde. Wenn Sie blockierende Vorgänge in einer Coroutine ausführen, muss die Coroutine abbrechbar sein.

Wenn Sie beispielsweise mehrere Dateien von der Festplatte lesen, prüfen Sie vor dem Lesen jeder Datei, ob die Coroutine abgebrochen wurde. Eine Möglichkeit, auf einen Abbruch zu prüfen, ist der Aufruf der ensureActive Funktion.

someScope.launch {
    for (file in files) {
        ensureActive() // Check for cancellation
        readFile(file)
    }
}

Alle Suspend-Funktionen von kotlinx.coroutines wie withContext und delay sind abbrechbar. Wenn Ihre Coroutine sie aufruft, sind keine weiteren Schritte erforderlich.

Weitere Informationen zum Abbruch in Coroutinen finden Sie im Blogpost Abbruch in Coroutinen.

Auf Ausnahmen achten

Nicht behandelte Ausnahmen, die in Coroutinen ausgelöst werden, können zum Absturz Ihrer App führen. Wenn Ausnahmen wahrscheinlich sind, fangen Sie sie im Hauptteil aller Coroutinen ab, die mit viewModelScope oder lifecycleScope erstellt wurden.

class LoginViewModel(
    private val loginRepository: LoginRepository
) : ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            try {
                loginRepository.login(username, token)
                // Update UI, user logged in successfully
            } catch (exception: IOException) {
                // Update UI, login attempt failed
            }
        }
    }
}

Weitere Informationen finden Sie im Blogpost Ausnahmen in Coroutinen, oder unter Coroutinen-Ausnahmen in der Kotlin-Dokumentation.

Weitere Informationen zu Coroutinen

Weitere Ressourcen zu Coroutinen finden Sie auf der Seite Zusätzliche Ressourcen für Kotlin-Coroutinen und -Flows.