Best practice per le coroutine su Android

In questa pagina vengono presentate diverse best practice che hanno un impatto positivo grazie rendere l'app più scalabile e verificabile quando usi le coroutine.

Inserisci committenti

Non impostare Dispatchers come hardcoded durante la creazione di nuove coroutine o le chiamate 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 perché puoi sostituirli i committenti nei test di unità e di strumentazione con un committente di test per rendere i test più deterministici.

Le funzioni di sospensione devono essere chiamate in sicurezza dal thread principale

Le funzioni di sospensione devono essere sicure per la rete, ovvero possono essere chiamate in sicurezza thread principale. Se una classe esegue operazioni di blocco a lunga esecuzione in una coroutine, ha il compito di spostare l'esecuzione dal thread principale utilizzando withContext. Questo vale per tutti i corsi nella 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(): 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)
    }
}

Questo pattern rende la tua app più scalabile, poiché le classi che chiamano le funzioni di sospensione non devi preoccuparti di quale Dispatcher usare per un determinato tipo di lavoro. Questo la responsabilità sta nella classe che svolge il lavoro.

Il ViewModel deve creare le coroutine

ViewModel corsi dovrebbe preferire la creazione di coroutine invece di esporre le funzioni di sospensione per eseguire attività logica. La sospensione delle funzioni in ViewModel può essere utile se invece di utilizzando un flusso di dati, deve essere emesso 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. Rimanda invece questa responsabilità al ViewModel. In questo modo la tua attività logica più semplice da testare poiché gli oggetti ViewModel possono essere testati per unità, anziché utilizzare i test di strumentazione necessari per testare le viste.

Inoltre, le tue coroutine sopravviveranno ai cambiamenti di configurazione automaticamente se il lavoro viene avviato nell'viewModelScope. Se crei coroutine usando invece lifecycleScope, dovrai gestirlo manualmente. Se la coroutina deve superare i limiti di ViewModel, controlla la Creazione di coroutine nella sezione del livello dati e aziendale.

Non esporre i tipi modificabili

Preferisci esporre tipi immutabili ad altre classi. In questo modo, tutte le modifiche il tipo modificabile è centralizzato in un'unica classe, semplificando il debug qualcosa va storto.

// 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 dati e business deve esporre le funzioni di sospensione e i flussi

Le classi nei livelli dati e business in genere espongono funzioni da eseguire le chiamate one-shot o di ricevere notifiche sulle modifiche ai dati nel tempo. Classi in quelle dovrebbero esporre le funzioni di sospensione delle chiamate one-shot e Flow to inviare notifiche in caso di 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 permette al chiamante, di solito a livello di presentazione, di controllare l'esecuzione e il ciclo di vita del lavoro in questi livelli e annulla quando necessario.

Creazione di coroutine nel livello dati e aziendale

Per le classi nel livello dati o aziendali che devono creare coroutine per per diversi motivi, ci sono opzioni diverse.

Se il lavoro da svolgere nelle coroutine è rilevante solo quando l'utente sta presente nella schermata attuale, deve seguire il ciclo di vita del chiamante. Nella maggior parte dei casi, casi, il chiamante sarà il ViewModel e la chiamata verrà annullata quando l'utente esce dalla schermata e il valore ViewModel viene cancellato. In questo caso, coroutineScope oppure supervisorScope di destinazione.

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, purché l'app sia aperta e il lavoro sia non è vincolato a una determinata schermata, il lavoro dovrebbe durare più a lungo durante il ciclo di vita di attività. In questo scenario, è necessario utilizzare un elemento CoroutineScope esterno illustrato nel documento Coroutines & Modelli di post del blog che non devono essere annullati.

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 con una durata superiore a schermata corrente, potrebbe essere gestita dalla classe Application o da un ViewModel con ambito a un grafico di navigazione.

Inserisci TestDispatcher nei test

Un'istanza di TestDispatcher devono essere inseriti nei tuoi corsi nei test. Ce ne sono due disponibili le implementazioni Raccolta kotlinx-coroutines-test:

  • StandardTestDispatcher: Metti in coda le coroutine iniziate con uno scheduler ed esegue quando il thread di test non è occupato. Puoi sospendere il thread di test per le altre coroutine in coda vengono eseguite con metodi come advanceUntilIdle

  • UnconfinedTestDispatcher: Esegue con entusiasmo le nuove coroutine, bloccandole. Questo in genere rende la scrittura più semplice, ma offre un controllo minore sul funzionamento delle coroutine eseguite durante il test.

Per ulteriori dettagli, consulta la documentazione relativa all'implementazione di ciascun supervisore.

Per testare le coroutine, utilizza la runTest costruttore di coroutine. runTest utilizza un TestCoroutineScheduler per evitare ritardi nei test e permettere di controllare il tempo virtuale. Puoi anche utilizzare questo programma di pianificazione per creare altri committenti di test, se necessario.

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

Tutti i TestDispatchers devono condividere lo stesso programma di pianificazione. Questo consente di esegui tutto il codice della coroutina sul singolo thread di test per effettuare i test deterministici. runTest attenderà tutte le coroutine nello stesso numero scheduler o siano figli della coroutina di prova da completare prima di tornare.

Evita GlobalScope

Questa procedura è simile alla best practice per inserire i committenti. Utilizzando GlobalScope, stai impostando come hardcoded la CoroutineScope usata da una classe, il che porta ad alcuni svantaggi con cui puoi trovare:

  • Promuove i valori di hardcoded. Se imposti GlobalScope come hardcoded, potresti hardcoded di Dispatchers.

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

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

Prova invece a inserire un CoroutineScope per il lavoro che deve sopravvivere nell'ambito attuale. Consulta le Creazione di coroutine nella sezione del livello dati e aziendale per saperne 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 Coroutine e Modelli di post del blog che non devono essere annullati.

Rendere la coroutine annullabile

La cancellazione nelle coroutine è cooperativa, il che significa che quando il Job viene annullato, la coroutine non viene annullata fino a quando non viene sospesa o controllato per l'annullamento. Se esegui operazioni di blocco in una coroutine, assicurati che la coroutine è annullabile.

Ad esempio, se leggi più file dal disco, prima di iniziare leggendo ciascun file, controlla se la coroutine è stata annullata. Sola andata per verificare l'annullamento, chiama ensureActive personalizzata.

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

Tutte le funzioni di sospensione da kotlinx.coroutines, come withContext e delay sono annullabili. Se la tua coroutine chiama, non dovresti farlo lavoro aggiuntivo.

Per ulteriori informazioni sulla cancellazione nelle coroutine, dai un'occhiata alla Annullamento nel post del blog di Coroutines.

Fai attenzione alle eccezioni

Eventuali eccezioni non gestite generate nelle coroutine possono causare l'arresto anomalo dell'app. Se fanno eccezioni colpisca nel corpo delle 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)
                // Notify view user logged in successfully
            } catch (exception: IOException) {
                // Notify view login attempt failed
            }
        }
    }
}

Per saperne di più, consulta il post del blog Eccezioni nelle coroutine, o Gestione delle eccezioni di Coroutine nella documentazione di Kotlin.

Scopri di più sulle coroutine

Per altre risorse sulle coroutine, consulta Risorse aggiuntive per coroutine e flussi di Kotlin .