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.

Disponenten einschleusen

Dispatchers sollte beim Erstellen neuer Coroutinen oder beim Aufrufen von withContext nicht fest codiert werden.

// 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 Dependency Injection-Muster erleichtert das Testen, da Sie diese Dispatcher in Unit- und Instrumentierungstests durch einen Test-Dispatcher ersetzen können, um Ihre Tests deterministischer zu machen.

Suspend-Funktionen sollten sicher aus dem Hauptthread aufgerufen werden können.

Suspend-Funktionen sollten „main-safe“ sein, d. h., sie können sicher vom Hauptthread aus aufgerufen werden. Wenn eine Klasse lang andauernde blockierende Vorgänge in einer Coroutine 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 Klassen, die suspend-Funktionen aufrufen, sich nicht darum kümmern müssen, welche Dispatcher für welche Art von Arbeit verwendet werden soll. Diese Verantwortung liegt in der Klasse, die die Arbeit erledigt.

Das ViewModel sollte Koroutinen erstellen

In ViewModel-Klassen sollten lieber Coroutinen erstellt werden, anstatt suspend-Funktionen für die Ausführung der Geschäftslogik zu verwenden. Suspend-Funktionen in ViewModel können nützlich sein, wenn anstelle der Bereitstellung von 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 nicht direkt Coroutinen auslösen, um Geschäftslogik auszuführen. Überlassen Sie diese Verantwortung stattdessen dem ViewModel. So lässt sich Ihre Geschäftslogik leichter testen, da ViewModel-Objekte Unit-Tests unterzogen werden können. Für das Testen von Ansichten sind dagegen Instrumentationstests erforderlich.

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

Keine veränderlichen Typen verfügbar machen

Es ist besser, unveränderliche Typen für andere Klassen verfügbar zu machen. So werden alle Änderungen am veränderlichen Typ in einer Klasse zentralisiert, was die Fehlersuche erleichtert.

// 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 die Geschäftsebene sollten Funktionen und Flows zum Sperren bereitstellen.

Klassen in der Daten- und Geschäftsebene stellen in der Regel Funktionen für einmalige Aufrufe oder für Benachrichtigungen über Datenänderungen im Zeitverlauf bereit. Klassen in diesen Ebenen sollten Suspend-Funktionen für einmalige Aufrufe und Flow zum Benachrichtigen über Datenänderungen bereitstellen.

// 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, in der Regel die Präsentationsschicht, die Ausführung und den Lebenszyklus der Arbeit in diesen Schichten steuern und bei Bedarf abbrechen.

Coroutinen in der Geschäfts- und Datenschicht erstellen

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

Wenn die in diesen Coroutinen auszuführenden Aufgaben nur relevant sind, wenn der Nutzer sich auf dem aktuellen Bildschirm befindet, sollten 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 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 auszuführende Aufgabe relevant ist, solange die App geöffnet ist, und nicht an einen bestimmten Bildschirm gebunden ist, sollte sie den Lebenszyklus des Aufrufers überdauern. Für dieses Szenario sollte ein externes CoroutineScope verwendet werden, wie im Blogpost zu Coroutinen und Mustern für Aufgaben, 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 aktiv ist. Sie könnte von der Klasse Application oder einem ViewModel verwaltet werden, das auf einen Navigationsgraphen beschränkt ist.

TestDispatchers in Tests einfügen

Eine Instanz von TestDispatcher sollte in Tests in Ihre Klassen eingefügt werden. Es gibt zwei Implementierungen in der kotlinx-coroutines-test-Bibliothek:

  • StandardTestDispatcher: Coroutinen, die darauf gestartet werden, werden mit einem Scheduler in die Warteschlange gestellt und ausgeführt, wenn der Test-Thread nicht ausgelastet ist. Sie können den Test-Thread mit Methoden wie advanceUntilIdle anhalten, damit andere in die Warteschlange eingereihte Coroutinen ausgeführt werden können.

  • UnconfinedTestDispatcher: Führt neue Coroutinen sofort und blockierend aus. Das macht 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 Coroutine-Builder runTest, um Coroutinen zu testen. runTest verwendet eine 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 den gesamten Coroutine-Code im einzelnen Test-Thread ausführen, um Ihre Tests deterministisch zu machen. runTest wartet, bis alle Coroutinen, die sich auf demselben Scheduler befinden oder untergeordnete Coroutinen der Test-Coroutine sind, abgeschlossen sind, bevor sie zurückkehrt.

GlobalScope vermeiden

Dies ähnelt der Best Practice Inject Dispatchers. Wenn Sie GlobalScope verwenden, codieren Sie die CoroutineScope, die eine Klasse verwendet, fest. Das hat einige Nachteile:

  • Es werden hartcodierte Werte gefördert. Wenn Sie GlobalScope hartcodieren, codieren Sie möglicherweise auch Dispatchers hart.

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

  • Es gibt keine gemeinsame CoroutineContext, die für alle im Bereich integrierten Coroutinen ausgeführt werden kann.

Stattdessen sollten Sie eine CoroutineScope für Aufgaben einfügen, die über den aktuellen Umfang hinausgehen. 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 Coroutines & Patterns for work that shouldn’t be cancelled.

Coroutine abbrechen

Die Abbrechen-Funktion in Coroutinen ist kooperativ. Das bedeutet, dass eine Coroutine erst abgebrochen wird, wenn sie pausiert oder auf Abbruch geprüft wird.Job Wenn Sie blockierende Vorgänge in einer Coroutine ausführen, muss die Coroutine abbrechbar sein.

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

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

Alle Suspend-Funktionen aus kotlinx.coroutines wie withContext und delay können abgebrochen werden. Wenn Ihre Coroutine sie aufruft, müssen Sie nichts weiter tun.

Weitere Informationen zur Kündigung in Coroutinen finden Sie im Blogpost zur Kündigung in Coroutinen.

Achten Sie auf Ausnahmen

Nicht abgefangene 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 mit viewModelScope oder lifecycleScope erstellten Coroutinen ab.

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 Exceptions in coroutines oder in der Kotlin-Dokumentation unter Coroutine exceptions handling.

Weitere Informationen zu Coroutinen

Weitere Informationen zu Coroutines finden Sie in der Anleitung zu Coroutines in der Kotlin-Dokumentation.