Bonnes pratiques concernant les coroutines sur Android

Cette page présente plusieurs bonnes pratiques qui ont un impact positif en rendant votre application plus évolutive et facile à tester lorsque vous utilisez des coroutines.

Coordinateurs d'injection

Ne codez pas en dur Dispatchers lorsque vous créez des coroutines ou que vous appelez withContext.

// 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) { /* ... */ }
}

Ce modèle d'injection de dépendances facilite les tests, car vous pouvez remplacer les coordinateurs dans les tests unitaires et d'instrumentation par un coordinateur de test pour rendre vos tests plus déterministes.

Les fonctions de suspension doivent pouvoir être appelées en toute sécurité depuis le thread principal

Les fonctions de suspension doivent être sécurisées, c'est-à-dire qu'elles peuvent être appelées de façon sécurisée depuis le thread principal. Si une classe effectue des opérations de blocage de longue durée dans une coroutine, elle est chargée de déplacer l'exécution en dehors du thread principal à l'aide de withContext. Cela s'applique à toutes les classes de votre application, quelle que soit la partie de l'architecture dans laquelle elles se trouvent.

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)
    }
}

Ce modèle rend votre application plus évolutive, car les classes qui appellent des fonctions de suspension n'ont pas à se préoccuper de la valeur de Dispatcher utilisée pour chaque type de travail. Cette responsabilité revient à la classe qui effectue le travail.

Le ViewModel doit créer des coroutines

Les classes ViewModel doivent de préférence créer des coroutines au lieu d'exposer des fonctions de suspension pour exécuter la logique métier. Les fonctions de suspension dans le ViewModel peuvent être utiles si, au lieu d'exposer l'état à l'aide d'un flux de données, il suffit d'émettre une seule valeur.

// 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()
}

Les vues ne doivent pas déclencher directement de coroutines pour exécuter la logique métier. Confiez plutôt cette responsabilité au ViewModel. Cela permet de tester plus facilement votre logique métier, car vous pouvez effectuer des tests unitaires sur les objets ViewModel plutôt que des tests d'instrumentation qui sont nécessaires pour tester les vues.

De plus, vos coroutines survivront automatiquement aux modifications de configuration si le travail est lancé dans le viewModelScope. Par contre, si vous créez des coroutines à l'aide de lifecycleScope, vous devez gérer cela manuellement. Si la coroutine doit survivre au champ d'application du ViewModel, consultez la section Créer des coroutines dans la couche métier et de données.

Ne pas exposer les types modifiables

Il est préférable d'exposer les types immuables aux autres classes. De cette façon, toutes les modifications apportées au type modifiable sont regroupées dans une seule classe, ce qui facilite le débogage en cas de problème.

// 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)

    /* ... */
}

Les couches métier et de données doivent exposer les fonctions de suspension et les flux

Les classes des couches métier et de données exposent généralement des fonctions permettant d'effectuer des appels uniques ou d'être averti des modifications de données au fil du temps. Les classes de ces couches doivent exposer les fonctions de suspension pour les appels uniques et les flux pour vous avertir des modifications de données.

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

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

Cette bonne pratique permet à l'appelant, généralement la couche de présentation, de contrôler l'exécution et le cycle de vie du travail dans ces couches, et d'annuler l'opération si nécessaire.

Créer des coroutines dans la couche métier et de données

Pour les classes de la couche métier ou de données qui doivent créer des coroutines pour différentes raisons, plusieurs options s'offrent à vous.

Si le travail à effectuer dans ces coroutines n'est pertinent que lorsque l'utilisateur se trouve sur l'écran actuel, il doit suivre le cycle de vie de l'appelant. Dans la plupart des cas, l'appelant est le ViewModel, et l'appel est annulé lorsque l'utilisateur quitte l'écran et que le ViewModel est effacé. Dans ce cas, vous devez utiliser coroutineScope ou supervisorScope.

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())
        }
    }
}

Si le travail à effectuer est pertinent tant que l'application est ouverte et qu'il n'est pas lié à un écran particulier, il doit survivre au cycle de vie de l'appelant. Pour ce scénario, un CoroutineScope externe doit être utilisé comme expliqué dans l'article de blog Coroutines and Patterns for Work That Shouldn't Be Cancelled (Coroutines et modèles pour les travaux qui ne doivent pas être annulés).

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 doit être créé et géré par une classe qui dure plus longtemps que l'écran actuel. Il peut être géré par la classe Application ou un ViewModel limité à un graphique de navigation.

Injecter des TestDispatchers dans les tests

Une instance de TestDispatcher doit être injectée dans vos classes lors des tests. Deux implémentations sont disponibles dans la bibliothèque kotlinx-coroutines-test :

  • StandardTestDispatcher : met en file d'attente les coroutines démarrées avec un planificateur et les exécute lorsque le thread de test n'est pas occupé. Vous pouvez suspendre le thread de test pour laisser les autres coroutines en file d'attente s'exécuter à l'aide de méthodes telles que advanceUntilIdle.

  • UnconfinedTestDispatcher : exécute de nouvelles coroutines hâtivement, de manière bloquante. Cela facilite généralement l'écriture des tests, mais vous offre moins de contrôle sur la façon dont les coroutines sont exécutées pendant le test.

Pour en savoir plus, consultez la documentation de chaque implémentation de coordinateur.

Pour tester les coroutines, utilisez le générateur de coroutines runTest. runTest utilise un TestCoroutineScheduler pour ignorer les retards dans les tests et vous permettre de contrôler le temps virtuel. Vous pouvez également utiliser ce planificateur pour créer si nécessaire des coordinateurs de test supplémentaires.

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()
    }
}

Tous les TestDispatchers doivent partager le même planificateur. Cela vous permet d'exécuter tout votre code de coroutine sur un seul thread de test pour rendre vos tests déterministes. runTest attendra que toutes les coroutines qui utilisent le même planificateur ou qui sont des enfants de la coroutine de test soient terminées avant de renvoyer un résultat.

Éviter GlobalScope

Cette bonne pratique est similaire à celle des coordinateurs d'injection. En utilisant GlobalScope, vous codez en dur le CoroutineScope qu'une classe utilise, ce qui présente certains inconvénients. GlobalScope :

  • Promeut des valeurs codées en dur. Si vous codez en dur GlobalScope, vous pouvez également coder en dur Dispatchers.

  • Rend les tests très difficiles, car votre code est exécuté dans un champ d'application non contrôlé. Vous ne pourrez donc pas contrôler son exécution.

  • Vous ne pouvez pas exécuter une commande CoroutineContext commune pour toutes les coroutines intégrées au champ d'application lui-même.

Envisagez plutôt d'injecter un CoroutineScope pour les travaux qui doivent survivre au champ d'application actuel. Pour en savoir plus sur ce sujet, consultez la section Créer des coroutines dans la couche métier et de données.

// 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
    }
}

Pour en savoir plus sur GlobalScope et ses alternatives, consultez l'article de blog Coroutines and Patterns for Work That Shouldn't Be Cancelled (Coroutines et modèles pour les travaux qui ne doivent pas être annulés).

Rendre votre coroutine annulable

L'annulation dans les coroutines est coopérative, ce qui signifie que lorsque le Job d'une coroutine est annulé, la coroutine n'est pas annulée tant qu'elle n'est pas suspendue ou qu'elle ne vérifie pas l'annulation. Si vous effectuez des opérations de blocage dans une coroutine, assurez-vous que la coroutine est annulable.

Par exemple, si vous lisez plusieurs fichiers à partir d'un disque, vérifiez si la coroutine a été annulée avant de commencer à lire chaque fichier. Pour vérifier l'annulation, vous pouvez appeler la fonction ensureActive.

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

Toutes les fonctions de suspension de kotlinx.coroutines telles que withContext et delay peuvent être annulées. Si votre coroutine les appelle, aucune action supplémentaire n'est requise de votre part.

Pour en savoir plus sur ce sujet, consultez l'article de blog Cancellation in coroutines (Annulation dans les coroutines).

Faire attention aux exceptions

Les exceptions non gérées générées dans les coroutines peuvent faire planter votre application. Si des exceptions sont susceptibles de se produire, interceptez-les dans le corps de toutes les coroutines créées avec viewModelScope ou lifecycleScope.

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
            }
        }
    }
}

Pour en savoir plus, consultez l'article de blog Exceptions dans les coroutines ou Gestion des exceptions de coroutines dans la documentation Kotlin.

En savoir plus sur les coroutines

Pour obtenir des instructions plus détaillées sur les coroutines, consultez la page Ressources supplémentaires pour les coroutines Kotlin et Flow.