Práticas recomendadas para corrotinas no Android

Esta página apresenta diversas práticas recomendadas que têm impacto positivo ao tornar seu app mais escalonável e testável com o uso de corrotinas.

Injetar dispatchers

Não fixe Dispatchers no código ao criar novas corrotinas ou chamar 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) { /* ... */ }
}

Esse padrão de injeção de dependência facilita os testes, porque você pode substituir esses dispatchers em testes de unidade e de instrumentação por um dispatcher de teste para que eles fiquem mais determinísticos.

As funções de suspensão precisam ser seguras para chamada pela linha de execução principal

As funções de suspensão precisam ser protegidas, ou seja, é seguro chamá-las pela linha de execução principal. Se uma classe estiver realizando operações de bloqueio de longa duração em uma corrotina, ela vai ser responsável por remover a execução da linha principal usando withContext. Isso se aplica a todas as classes do app, independentemente da parte da arquitetura em que elas estejam.

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

Esse padrão torna seu app mais escalonável, porque as classes que chamam as funções de suspensão não precisam se preocupar com qual Dispatcher será usado para cada tipo de trabalho. Essa responsabilidade é da classe que faz o trabalho.

O ViewModel precisa criar corrotinas

As classes ViewModel precisam preferir criar corrotinas (em vez de expor funções de suspensão) para executar a lógica de negócios. Suspender funções em ViewModel poderá ser útil se, em vez de expor o estado usando um fluxo de dados, apenas um valor único precisar ser emitido.

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

As visualizações não podem acionar diretamente as corrotinas para realizar a lógica de negócios. Em vez disso, delegue a responsabilidade ao ViewModel. Isso facilita o teste da lógica de negócios, já que os objetos ViewModel podem ser testados por unidade, em vez de usar testes de instrumentação necessários para as visualizações.

Além disso, as corrotinas sobreviverão automaticamente às mudanças de configuração se o trabalho for iniciado no viewModelScope. Se você criar corrotinas usando lifecycleScope, precisará processar isso manualmente. Se a corrotina precisar durar mais que o escopo do ViewModel, consulte a seção Como criar corrotinas na seção de camada de negócios e dados.

Não expor tipos mutáveis

Prefira expor tipos imutáveis a outras classes. Dessa forma, todas as mudanças no tipo mutável ficam centralizadas em uma única classe, o que facilita a depuração quando algo dá errado.

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

    /* ... */
}

A camada de dados e de negócios precisa expor funções de suspensão e fluxos

As classes nas camadas de dados e de negócios geralmente expõem funções para realizar chamadas únicas ou serem notificadas sobre mudanças de dados ao longo do tempo. As classes nessas camadas precisam expor funções de suspensão para chamadas únicas e fluxo para notificar sobre mudanças de dados.

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

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

Essa prática recomendada possibilita que o autor da chamada, geralmente a camada de apresentação, consiga controlar a execução e o ciclo de vida do trabalho que está acontecendo nessas camadas e cancelá-los quando necessário.

Como criar corrotinas na camada de negócios e de dados

Para classes na camada de dados ou de negócios que precisam criar corrotinas por diferentes motivos, há opções distintas.

Se o trabalho a ser feito nessas corrotinas for relevante somente quando o usuário estiver presente na tela atual, ele precisará seguir o ciclo de vida do autor da chamada. Na maioria dos casos, o autor da chamada será o ViewModel, e a chamada será cancelada quando o usuário sai da tela e o ViewModel for limpo. Neste caso, use coroutineScope ou supervisorScope (links em inglês).

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 o trabalho a ser feito for relevante enquanto o app estiver aberto e não estiver vinculado a uma tela específica, ele precisará durar mais que o ciclo de vida do autor da chamada. Nesse cenário, um CoroutineScope externo precisa ser usado, conforme explicado na postagem do blog Corrotinas e padrões do trabalho que não pode ser cancelado (link em inglê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
    }
}

O externalScope precisa ser criado e gerenciado por uma classe que dure mais tempo que a tela atual. Ele pode ser gerenciado pela classe Application ou por um ViewModel com escopo para um gráfico de navegação.

Injetar TestDispatchers em testes

Uma instância de TestDispatcher precisa ser injetada nas suas classes em testes. Há duas implementações disponíveis na biblioteca kotlinx-coroutines-test:

  • StandardTestDispatcher (link em inglês): filtra as corrotinas iniciadas com um programador e as executa quando a linha de execução de teste não está ocupada. Você pode suspender a linha de execução de teste para permitir que outras corrotinas em fila sejam executadas usando métodos como advanceUntilIdle.

  • UnconfinedTestDispatcher (link em inglês): executa novas corrotinas de forma a criar bloqueios. Geralmente, isso facilita a criação de testes, mas oferece menos controle sobre como as corrotinas são executadas.

Consulte a documentação sobre cada implementação de dispatcher para saber mais.

Para testar as corrotinas, use o builder de corrotinas runTest. O runTest usa um TestCoroutineScheduler (link em inglês) para pular atrasos nos testes e permitir que você controle o tempo virtual. Também é possível usar esse agendador para criar outros agentes de teste, conforme necessário.

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

Todos os TestDispatchers precisam compartilhar o mesmo agendador. Assim, você consegue executar todo o código de corrotina na linha de execução de teste única para que seus testes sejam determinísticos. O runTest vai aguardar a conclusão de todas as corrotinas que estão no mesmo agendador ou são filhas da corrotina de teste antes de retornar.

Evitar GlobalScope

Esta prática recomendada é semelhante à de Injetar dispatchers. Ao usar o GlobalScope (link em inglês), você está fixando no código o CoroutineScope que uma classe usa e gerando algumas desvantagens:

  • Promove valores de codificação. Se você fixar GlobalScope no código, também poderá estar codificando Dispatchers.

  • Torna o teste muito difícil, uma vez que o código é executado em um escopo não controlado e você não poderá controlar a execução dele.

  • Não é possível ter um CoroutineContext comum para executar para todas as corrotinas integradas ao escopo.

Em vez disso, considere injetar um CoroutineScope para um trabalho que precise durar mais que o escopo atual. Confira a seção Como criar corrotinas na seção de camada de negócios e de dados para saber mais sobre esse assunto.

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

Saiba mais sobre GlobalScope e as alternativas a ele na postagem do blog Corrotinas e padrões do trabalho que não pode ser cancelado (link em inglês).

Tornar a corrotina cancelável

O cancelamento em corrotinas é cooperativo, o que significa que, quando o Job de uma corrotina for cancelado, ela não será cancelada até que seja suspensa ou confirme o cancelamento. Se você fizer operações de bloqueio em uma corrotina, confira se ela é cancelável.

Por exemplo, se você estiver lendo vários arquivos do disco, antes de começar a ler cada arquivo, confirme se a corrotina foi cancelada. Uma maneira de verificar o cancelamento é chamando a função ensureActive (link em inglês).

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

Todas as funções de suspensão de kotlinx.coroutines, como withContext e delay, são canceláveis. Caso sua corrotina as chame, não será necessário fazer mais nada.

Para mais informações sobre o cancelamento em corrotinas, consulte a postagem do blog Cancelamento em corrotinas (link em inglês).

Atenção às exceções

Exceções não processadas que forem geradas em corrotinas poderão causar falhas no app. Se houver a probabilidade de exceções, capture-as no corpo de corrotinas criadas com 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
            }
        }
    }
}

Para ver mais informações, consulte a postagem do blog Exceções em corrotinas ou Processamento de exceções de corrotina (links em inglês) na documentação do Kotlin.

Saiba mais sobre corrotinas

Para ver mais recursos de corrotinas, consulte a página Outros recursos para corrotinas e fluxo Kotlin.