Best practice per le coroutine su Android

Questa pagina presenta diverse best practice che hanno un impatto positivo, rendendo la tua app più scalabile e testabile quando utilizzi le coroutine.

Inserisci Dispatcher

Non codificare Dispatchers quando crei nuove coroutine o chiami 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) { /* ... */ }
}

Questo pattern di inserimento delle dipendenze semplifica i test, in quanto puoi sostituire questi dispatcher nei test delle unità e di strumentazione con un dispatcher di test per rendere i test più deterministici.

Le funzioni di sospensione devono essere sicure da chiamare dal thread principale

Le funzioni di sospensione devono essere sicure per il thread principale, il che significa che possono essere chiamate dal thread principale. Se una classe esegue operazioni di blocco a lunga esecuzione in una coroutine, è responsabile dello spostamento dell'esecuzione dal thread principale utilizzando withContext. Questo vale per tutte le classi della tua app, indipendentemente dalla parte dell'architettura in cui si trova la classe.

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

Questo pattern rende la tua app più scalabile, in quanto le classi che chiamano le funzioni di sospensione non devono preoccuparsi di quale Dispatcher utilizzare per quale tipo di lavoro. Questa responsabilità spetta alla classe che esegue il lavoro.

ViewModel deve creare coroutine

ViewModel classi devono preferire la creazione di coroutine anziché l'esposizione di funzioni di sospensione per eseguire la logica di business. Le funzioni di sospensione in ViewModel possono essere utili se, anziché esporre lo stato utilizzando uno stream di dati, è necessario emettere un solo valore.

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

Le visualizzazioni non devono attivare direttamente le coroutine per eseguire la logica di business. Invece, delega questa responsabilità a ViewModel. In questo modo, la logica di business è più facile da testare, in quanto gli oggetti ViewModel possono essere testati a livello di unità, anziché utilizzare i test di strumentazione necessari per testare le visualizzazioni.

Inoltre, le coroutine sopravvivranno automaticamente alle modifiche alla configurazione se il lavoro viene avviato in viewModelScope. Se crei coroutine utilizzando lifecycleScope, dovrai gestirle manualmente. Se la coroutine deve sopravvivere all'ambito di ViewModel, consulta la sezione Creazione di coroutine nel livello di business e dati.

Non esporre tipi modificabili

È preferibile esporre tipi immutabili ad altre classi. In questo modo, tutte le modifiche al tipo modificabile vengono centralizzate in una sola classe, semplificando il debug in caso di problemi.

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

    /* ... */
}

Il livello di dati e di business deve esporre funzioni di sospensione e flussi

Le classi nei livelli di dati e di business in genere espongono funzioni per eseguire chiamate una tantum o per ricevere notifiche delle modifiche ai dati nel tempo. Le classi in questi livelli devono esporre funzioni di sospensione per chiamate una tantum e flusso per notificare le modifiche ai dati.

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

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

Questa best practice consente al chiamante, in genere il livello di presentazione, di controllare l'esecuzione e il ciclo di vita del lavoro che si svolge in questi livelli e di annullarlo quando necessario.

Creazione di coroutine nel livello di business e dati

Per le classi nel livello di dati o di business che devono creare coroutine per motivi diversi, esistono diverse opzioni.

Se il lavoro da svolgere in queste coroutine è pertinente solo quando l'utente è presente nella schermata corrente, deve seguire il ciclo di vita del chiamante. Nella maggior parte dei casi, il chiamante sarà ViewModel e la chiamata verrà annullata quando l'utente esce dalla schermata e ViewModel viene cancellato. In questo caso, coroutineScope o supervisorScope è necessario utilizzare.

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

Se il lavoro da svolgere è pertinente finché l'app è aperta e non è vincolato a una schermata specifica, deve sopravvivere al ciclo di vita del chiamante. Per questo scenario, è necessario utilizzare un CoroutineScope esterno, come spiegato nel post del blog Coroutine e pattern per il lavoro che non deve essere annullato.

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 deve essere creato e gestito da una classe che dura più a lungo della schermata corrente. Può essere gestito dalla classe Application o da un ViewModel con ambito in un grafico di navigazione.

Inserisci TestDispatcher nei test

Nei test, è necessario inserire un'istanza di TestDispatcher nelle classi. Nella libreria kotlinx-coroutines-test sono disponibili due implementazioni:

  • StandardTestDispatcher: mette in coda le coroutine avviate con un pianificatore e le esegue quando il thread di test non è occupato. Puoi sospendere il thread di test per consentire l'esecuzione di altre coroutine in coda utilizzando metodi come advanceUntilIdle.

  • UnconfinedTestDispatcher: esegue le nuove coroutine in modo eager e bloccante. In genere, questo semplifica la scrittura dei test, ma offre meno controllo su come vengono eseguite le coroutine durante il test.

Per ulteriori dettagli, consulta la documentazione di ogni implementazione del dispatcher.

Per testare le coroutine, utilizza il runTest builder di coroutine. runTest uses a TestCoroutineScheduler per saltare i ritardi nei test e per consentirti di controllare il tempo virtuale. Puoi anche utilizzare questo pianificatore per creare dispatcher di test aggiuntivi in base alle esigenze.

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

Tutti i TestDispatchers devono condividere lo stesso pianificatore. In questo modo, puoi eseguire tutto il codice della coroutine sul singolo thread di test per rendere i test deterministici. runTest attenderà il completamento di tutte le coroutine che si trovano nello stesso pianificatore o che sono figli della coroutine di test prima di restituire.

Evita GlobalScope

Questo è simile alla best practice Inserisci Dispatcher. Utilizzando GlobalScope, stai codificando CoroutineScope che una classe utilizza, con alcuni svantaggi con esso:

  • Promuove la codifica dei valori. Se codifichi GlobalScope, potresti codificare anche Dispatchers.

  • Rende i test molto difficili, in quanto il codice viene eseguito in un ambito non controllato e non potrai controllarne l'esecuzione.

  • Non puoi avere un CoroutineContext comune da eseguire per tutte le coroutine integrate nell'ambito stesso.

In alternativa, valuta la possibilità di inserire un CoroutineScope per il lavoro che deve sopravvivere all'ambito corrente. Consulta la sezione Creazione di coroutine nel livello di business e dati per scoprire di più su questo argomento.

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

Scopri di più su GlobalScope e sulle sue alternative nel post del blog Coroutine e pattern per il lavoro che non deve essere annullato.

Rendi la coroutine annullabile

L'annullamento nelle coroutine è cooperativo, il che significa che quando il Job di una coroutine viene annullato, la coroutine non viene annullata finché non viene sospesa o non viene verificato l'annullamento. Se esegui operazioni di blocco in una coroutine, assicurati che la coroutine sia annullabile.

Ad esempio, se stai leggendo più file dal disco, prima di iniziare a leggere ogni file, verifica se la coroutine è stata annullata. Un modo per verificare l'annullamento è chiamare la ensureActive funzione.

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

Tutte le funzioni di sospensione di kotlinx.coroutines, come withContext e delay, sono annullabili. Se la coroutine le chiama, non dovresti dover eseguire ulteriori operazioni.

Per ulteriori informazioni sull'annullamento nelle coroutine, consulta il post del blog Annullamento nelle coroutine.

Attenzione alle eccezioni

Le eccezioni non gestite generate nelle coroutine possono causare l'arresto anomalo dell'app. Se è probabile che si verifichino eccezioni, intercettale nel corpo di tutte le coroutine create con viewModelScope o lifecycleScope.

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

Per ulteriori informazioni, consulta il post del blog Eccezioni nelle coroutine, o Gestione delle eccezioni delle coroutine nella documentazione di Kotlin.

Scopri di più sulle coroutine

Per ulteriori risorse sulle coroutine, consulta la pagina Risorse aggiuntive per coroutine e flussi Kotlin.