Melhorar o desempenho do app com corrotinas de Kotlin

As corrotinas de Kotlin (link em inglês) permitem escrever um código assíncrono limpo e simplificado que mantém o app responsivo enquanto gerencia tarefas de longa duração, como chamadas de rede ou operações de disco.

Este tópico apresenta uma visão detalhada das corrotinas no Android. Se não conhecê-las, leia sobre corrotinas de Kotlin no Android antes deste tópico.

Gerenciar tarefas de longa duração

As corrotinas são criadas com base em funções regulares, adicionando duas operações para lidar com tarefas de longa duração. Além de invoke (ou call) e return, as corrotinas adicionam suspend e resume:

  • suspend pausa a execução da corrotina atual, salvando todas as variáveis locais.
  • resume continua a execução de uma corrotina suspensa do local onde ela foi suspensa.

É possível chamar funções suspend somente por outras funções suspend ou usando um builder de corrotinas, por exemplo, launch, para iniciar uma nova corrotina.

O exemplo a seguir mostra uma implementação simples de corrotina para uma tarefa hipotética de longa duração:

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

Nesse exemplo, get() ainda é executado na linha de execução principal, mas suspende a corrotina antes de iniciar a solicitação de rede. Quando a solicitação de rede é concluída, get retoma a corrotina suspensa em vez de usar um callback para notificar a linha de execução principal.

O Kotlin usa um frame de pilha para gerenciar qual função está sendo executada com qualquer variável local. Ao suspender uma corrotina, o frame de pilha atual é copiado e salvo para mais tarde. Ao retomar, o frame de pilha é copiado de onde ele foi salvo, e a função começa a ser executada novamente. Mesmo que o código possa parecer uma solicitação de bloqueio sequencial comum, a corrotina garante que a solicitação de rede evite o bloqueio da linha de execução principal.

Usar corrotinas para a segurança principal

As corrotinas Kotlin usam agentes para determinar quais linhas de execução são usadas para a execução da corrotina. Para executar o código fora da linha de execução principal, você pode pedir para as corrotinas Kotlin realizarem o trabalho no agente Padrão ou no de E/S. No Kotlin, todas as corrotinas precisam ser executadas em um agente, mesmo quando estiverem em execução na linha de execução principal. As corrotinas podem suspender a si mesmas, e o agente é responsável por reiniciá-las.

Para especificar onde as corrotinas precisam ser executadas, o Kotlin fornece três agentes para uso:

  • Dispatchers.Main: use este agente para executar uma corrotina na linha de execução principal do Android. Ele só deve ser usado para interagir com a IU e realizar um trabalho rápido. Exemplos incluem chamar funções suspend, executar operações de framework de IU do Android e atualizar objetos LiveData.
  • Dispatchers.IO: este agente é otimizado para executar E/S de disco ou rede fora da linha de execução principal. Exemplos incluem uso do componente Room, leitura ou gravação de arquivos e execução de qualquer operação de rede.
  • Dispatchers.Default: este agente é otimizado para realizar trabalho intensivo da CPU fora da linha de execução principal. Exemplos de casos de uso incluem classificação de uma lista e análise de JSON.

Continuando com o exemplo anterior, você pode usar os agentes para redefinir a função get. Dentro do corpo de get, chame withContext(Dispatchers.IO) para criar um bloco que é executado no pool de linhas de execução de E/S. Qualquer código que você inserir nesse bloco sempre será executado por meio do agente IO. Como withContext é em si uma função de suspensão, a função get também é.

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

Com corrotinas, você pode enviar linhas de execução com controle refinado. Como withContext() permite controlar o pool de linhas de execução de qualquer linha de código sem introduzir callbacks, é possível aplicá-lo a funções muito pequenas, como ler um banco de dados ou executar uma solicitação de rede. Uma boa prática é usar withContext() para garantir que todas as funções sejam protegidas, o que significa que você pode chamar a função a partir da linha de execução principal. Dessa forma, o autor da chamada nunca precisa pensar sobre qual linha de execução tem que ser usada para executar a função.

No exemplo anterior, fetchDocs() é executado na linha de execução principal. No entanto, ele pode chamar com segurança get, que executa uma solicitação de rede em segundo plano. Como as corrotinas são compatíveis com suspend e resume, a corrotina na linha de execução principal é retomada com o resultado get assim que o bloco withContext é concluído.

Desempenho de withContext()

withContext() não adiciona mais sobrecarga em comparação com uma implementação equivalente baseada em callback. Além disso, é possível otimizar chamadas withContext() além de uma implementação equivalente baseada em callback em algumas situações. Por exemplo, se uma função faz dez chamadas para uma rede, você pode dizer ao Kotlin para alternar as linhas de execução apenas uma vez usando um withContext() externo. Em seguida, mesmo que a biblioteca de rede use withContext() várias vezes, ela permanece no mesmo agente e evita alternar linhas de execução. Além disso, o Kotlin otimiza a alternância entre Dispatchers.Default e Dispatchers.IO para evitar alternância de linhas de execução sempre que possível.

Iniciar uma corrotina

Você pode iniciar corrotinas de duas maneiras:

  • launch (link em inglês) inicia uma nova corrotina e não retorna o resultado para o autor da chamada. Qualquer trabalho que seja considerado "disparar e esquecer" pode ser iniciado usando launch.
  • async (link em inglês) inicia uma nova corrotina e permite retornar o resultado com uma função de suspensão chamada await.

Normalmente, é necessário launch uma nova corrotina a partir de uma função regular, já que uma função regular não pode chamar await. Use async somente dentro de outra corrotina ou quando estiver em uma função suspensa e realizando a decomposição paralela.

Decomposição paralela

Como todas as corrotinas iniciadas em uma função suspend precisam ser interrompidas quando a função é retornada, você provavelmente precisará garantir que essas corrotinas terminem antes do retorno. Com simultaneidade estruturada em Kotlin, você pode definir um coroutineScope que inicie uma ou mais corrotinas. Em seguida, usando await() (para uma corrotina única) ou awaitAll() (para diversas corrotinas), você pode garantir que essas corrotinas terminem antes de retornar da função.

Como exemplo, vamos definir um coroutineScope que busque dois documentos de forma assíncrona. Chamando await() em cada referência adiada, garantimos que as duas operações async terminem antes de retornar um valor:

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

Você também pode usar awaitAll() nas coleções, conforme mostrado neste exemplo:

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

Mesmo que fetchTwoDocs() inicie novas corrotinas com async, a função usa awaitAll() para aguardar até que as corrotinas sejam concluídas antes de retornar. Observe, no entanto, que mesmo se não tivéssemos chamado awaitAll(), o builder coroutineScope não retomaria a corrotina que chamou fetchTwoDocs até que todas as novas corrotinas fossem concluídas.

Além disso, coroutineScope detecta exceções que as corrotinas lançam e as encaminha de volta para o autor da chamada.

Para saber mais sobre decomposição paralela, consulte Como escrever funções de suspensão (link em inglês).

Conceitos de corrotinas

CoroutineScope

Um CoroutineScope (link em inglês) monitora todas as corrotinas que ele cria usando launch ou async. O trabalho em andamento (ou seja, as corrotinas em execução) pode ser cancelado chamando scope.cancel() a qualquer momento. No Android, algumas bibliotecas KTX fornecem o próprio CoroutineScope para determinadas classes de ciclo de vida. Por exemplo, ViewModel tem um viewModelScope, e Lifecycle tem lifecycleScope. Diferentemente de um agente, no entanto, um CoroutineScope não executa as corrotinas.

viewModelScope também é usado nos exemplos encontrados em Linhas de execução em segundo plano no Android com corrotinas. No entanto, se você precisar criar o próprio CoroutineScope para controlar o ciclo de vida de corrotinas em uma camada específica do app, faça isso da seguinte maneira:

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Um escopo cancelado não pode criar mais corrotinas. Portanto, chame scope.cancel() somente quando a classe que controla o ciclo de vida estiver sendo destruída. Ao usar viewModelScope, a classe ViewModel cancela o escopo automaticamente no método onCleared() do ViewModel.

Job

Um Job (link em inglês) é um handle para uma corrotina. Cada corrotina criada com launch ou async retorna uma instância Job que identifica exclusivamente a corrotina e gerencia o ciclo de vida dela. Você também pode transmitir um Job para um CoroutineScope e gerenciar ainda mais o ciclo de vida, conforme mostrado no exemplo a seguir:

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

Um CoroutineContext (link em inglês) define o comportamento de uma corrotina usando o seguinte conjunto de elementos:

  • Job (link em inglês): controla o ciclo de vida da corrotina.
  • CoroutineDispatcher (link em inglês): envia o trabalho para a linha de execução adequada.
  • CoroutineName (link em inglês): o nome da corrotina, útil para depuração.
  • CoroutineExceptionHandler (link em inglês): processa exceções não capturadas.

Para novas corrotinas criadas em um escopo, uma nova instância Job é atribuída à nova corrotina, e os outros elementos CoroutineContext são herdados do escopo que a contém. Você pode modificar os elementos herdados transmitindo um novo CoroutineContext para a função launch ou async. Observe que transmitir um Job para launch ou async não tem efeito, já que uma nova instância de Job é sempre atribuída a uma nova corrotina.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Outros recursos de corrotinas

Para ver mais recursos de corrotinas, consulte os seguintes links: