Best Practices für Koroutinen in Android

Auf dieser Seite werden mehrere Best Practices vorgestellt, die sich positiv auf die Verwendung von Koroutinen auswirken, indem sie Ihre Anwendung skalierbarer und testbarer machen.

Injection-Dispatcher

Sie sollten Dispatchers nicht hartcodieren, wenn Sie neue Koroutinen 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 Abhängigkeitsinjektionsmuster erleichtert das Testen, da Sie diese Disponenten in Einheiten- und Instrumentierungstests durch einen Test-Dispatcher ersetzen können, um Ihre Tests deterministischer zu machen.

Aussetzen von Funktionen sollten sicher aus dem Hauptthread aufgerufen werden können

Ausgesetzte Funktionen sollten hauptsicher sein, d. h. sie können bedenkenlos aus dem Hauptthread aufgerufen werden. Wenn eine Klasse lang andauernde Blockiervorgänge in einer Koroutine ausführt, ist sie dafür zuständig, die Ausführung mit withContext aus dem Hauptthread zu verschieben. 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(): List<ArticleWithAuthor> {
        val news = newsRepository.fetchLatestNews()

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

Dieses Muster macht Ihre Anwendung skalierbarer, da sich Klassen, die Sperrfunktionen aufrufen, keine Gedanken darüber machen müssen, welche Dispatcher für welche Art von Arbeit verwendet werden soll. Diese Verantwortung liegt bei der Klasse, die die Arbeit erledigt.

Die ViewModel sollte Koroutinen erstellen.

ViewModel-Klassen sollten es vorziehen, Koroutinen zu erstellen, anstatt zur Ausführung der Geschäftslogik Haltefunktionen verfügbar zu machen. Ausblendungsfunktionen in ViewModel können nützlich sein, wenn statt eines Datenstroms 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 dürfen keine Koroutinen zur Ausführung von Geschäftslogik direkt auslösen. Verschieben Sie diese Verantwortung stattdessen auf die ViewModel. Dies erleichtert das Testen Ihrer Geschäftslogik, da ViewModel-Objekte Einheitentests durchführen können, anstatt Instrumentierungstests zu verwenden, die zum Testen von Ansichten erforderlich sind.

Außerdem bleiben Ihre Koroutinen Konfigurationsänderungen automatisch erhalten, wenn die Arbeit im viewModelScope gestartet wird. Wenn Sie stattdessen Koroutinen mit lifecycleScope erstellen, müssen Sie dies manuell verarbeiten. Wenn die Koroutine den Bereich von ViewModel überschreiten muss, lesen Sie den Abschnitt Koroutinen im Geschäfts- und Datenschichtbereich erstellen.

Keine änderbaren Typen bereitstellen

Unveränderliche Typen sollen anderen Klassen gegenüber bevorzugt werden. Auf diese Weise werden alle Änderungen am änderbaren Typ in einer Klasse zentralisiert, was das Debuggen erleichtert, wenn ein Fehler auftritt.

// 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 Unternehmensebene sollte Aussetzerfunktionen und Abläufe verfügbar machen.

Klassen auf den Daten- und Geschäftsebenen stellen im Allgemeinen Funktionen bereit, mit denen One-Shot-Aufrufe ausgeführt oder über Datenänderungen im Zeitverlauf benachrichtigt werden können. Die Klassen in diesen Ebenen sollten Aussetzungsfunktionen für One-Shot-Aufrufe und Ablauf zur Benachrichtigung über Datenänderungen enthalten.

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

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

Durch diese Best Practice kann der Aufrufer, im Allgemeinen die Präsentationsebene, die Ausführung und den Lebenszyklus der Arbeit auf diesen Ebenen steuern und bei Bedarf abbrechen.

Koroutinen auf der Unternehmens- und Datenschicht erstellen

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

Wenn die in diesen Koroutinen zu erledigende Arbeit nur relevant ist, wenn der Nutzer auf dem aktuellen Bildschirm zu sehen ist, sollte der Lebenszyklus des Aufrufs eingehalten werden. In den meisten Fällen ist ViewModel der Aufrufer. Der Aufruf wird abgebrochen, wenn der Nutzer den Bildschirm verlässt und ViewModel gelöscht wird. In diesem Fall sollte coroutineScope oder supervisorScope 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 zu erledigende Arbeit relevant ist, solange 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 eine externe CoroutineScope verwendet werden, wie im Blogpost Coroutines & Patterns for work that should not becanceled (Coroutinen und Muster für Arbeiten, die nicht abgebrochen werden sollten) beschrieben.

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 ist. Sie könnte von der Application-Klasse oder einer ViewModel in einem Navigationsdiagramm verwaltet werden.

TestDispatcher in Tests einfügen

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

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

  • UnconfinedTestDispatcher: Führt neue Koroutinen ehrgeizig aus, obwohl sie blockieren. Dies vereinfacht in der Regel das Schreiben von Tests, gibt Ihnen jedoch weniger Kontrolle darüber, wie Koroutinen während des Tests ausgeführt werden.

Weitere Informationen finden Sie in der Dokumentation der einzelnen Implementierungen des Disponenten.

Mit dem Koroutinen-Builder runTest können Sie Koroutinen testen. runTest verwendet einen TestCoroutineScheduler, um Verzögerungen bei Tests zu überspringen und die virtuelle Zeit zu steuern. Sie können diesen Planer auch verwenden, um bei Bedarf zusätzliche Test-Disponenten 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,
            testDispatcher
        )
        val article = Article()
        repository.bookmarkArticle(article)
        assertThat(articlesDataSource.isBookmarked(article)).isTrue()
    }
}

Alle TestDispatchers sollten denselben Planer verwenden. Auf diese Weise können Sie Ihren gesamten Koroutinecode in einem einzigen Testthread ausführen und so Ihre Tests deterministisch machen. runTest wartet, bis alle Koroutinen abgeschlossen sind, die sich im selben Planer befinden oder der Testkoroutine untergeordnet sind, bevor sie zurückgegeben werden.

GlobalScope vermeiden

Dies ähnelt der Best Practice für Inject Dispatcher. Wenn Sie GlobalScope verwenden, hartcodieren Sie die CoroutineScope einer Klasse mit einigen Nachteilen:

  • Hartcodierte Werte werden unterstützt. Wenn Sie GlobalScope hartcodieren, codieren Sie möglicherweise auch Dispatchers.

  • Da das Testen in einem unkontrollierten Bereich sehr schwierig wird, können Sie die Ausführung des Codes nicht steuern.

  • Sie können keine gemeinsame CoroutineContext haben, die für alle Koroutinen ausgeführt wird, die in den Bereich selbst integriert sind.

Ziehen Sie stattdessen die Injektion eines CoroutineScope für Arbeiten in Betracht, die den aktuellen Bereich überdauern müssen. Weitere Informationen zu diesem Thema finden Sie im Abschnitt Koroutinen 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 Coroutines und Patterns for work that should not becancel.

Koroutine stornierbar machen

Der Abbruch von Koroutinen ist kooperativ. Wenn also die Job einer Koroutine abgebrochen wird, wird sie erst abgebrochen, wenn sie ausgesetzt oder auf ihren Abbruch geprüft wurde. Wenn Sie Vorgänge in einer Koroutine blockieren, muss sie abgebrochen sein.

Wenn Sie beispielsweise mehrere Dateien von der Festplatte lesen, prüfen Sie vor dem Lesen jeder Datei, ob die Koroutine abgebrochen wurde. Eine Möglichkeit, um zu prüfen, ob ein Vorgang abgebrochen wurde, besteht darin, die Funktion ensureActive aufzurufen.

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

Alle Sperrfunktionen aus kotlinx.coroutines wie withContext und delay können abgebrochen werden. Wenn sie von der Koroutine aufgerufen werden, sollten Sie keine zusätzlichen Arbeitsschritte ausführen müssen.

Weitere Informationen zum Abbrechen in Koroutinen finden Sie im Blogpost zum Abbrechen in Coroutinen.

Ausnahmen beachten

Unbehandelte Ausnahmen in Koroutinen können zum Absturz deiner App führen. Wenn Ausnahmen wahrscheinlich sind, fangen Sie sie im Text aller Koroutinen 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)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

Weitere Informationen finden Sie in der Kotlin-Dokumentation im Blogpost Exceptions in coroutines (Ausnahmen in Koroutinen) und Coroutine-Ausnahmen in der Kotlin-Dokumentation.

Weitere Informationen zu Koroutinen

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