Guia completo sobre viewModelScope e conceitos de Coroutines
Bom, se você já utiliza Coroutines
e sua arquitetura é a MVVM ou então Clean Architecture, ou até uma mistura dos dois, você provavelmente já ouviu falar de viewModelScope
.
De forma bem resumida, o viewModelScope
nada mais é do que um CoroutineScope
, ou seja, um bloco onde eu posso executar minhas coroutines
sem travar a thread em que estou executando.
Ele respeita o ciclo de vida do seu ViewModel
, o que facilita o cancelamento de coroutines
que não são mais necessárias, evitando assim o temido memory leak 👻 .
Tá… então se eu tenho uma suspend fun
e quero executar do meu ViewModel
, é só colocar dentro do viewModelScope
que vai estar tudo certo, e meu app não vai travar e minha MainThread
vai ficar livre??
Não exatamente… o viewModelScope
vai sim liberar sua MainThread
, mas ele ainda vai executar na MainThread
...
Para entender tudo isso, vamos esmiuçar o viewModelScope
.
Composição do viewModelScope
Ele é uma variável que executa no contexto do ViewModel
, e por fim, ele retorna um CoroutineScope
.
Vamos separar por tópicos:
- CoroutineScope
- SupervisorJob
- Dispatcher.Main.Imediate
CoroutineScope
public interface CoroutineScope {
public val coroutineContext: CoroutineContext
}
É uma interface que possui uma variável do tipo CoroutineContext
.
Basicamente uma interface
com um set
(list) de CoroutineContext
, que podem ser do tipo:
- Dispatcher
- Job
- CoroutineName
- Outros
É bem comum ver algo do tipo:
O coroutine context está criando uma lista com esses valores:
E para que exatamente isso serve? 🤔
Bom, o contexto é importante para coroutine
saber onde vai executar e de que forma vai ser executada, o contexto "configura" a coroutine para uma determinada Thread
com um determinado Dispatcher
e cria um Job()
(falaremos mais abaixo).
Primeiro vamos entender o que é um Job
.
Job
é algo cancelável, que possui um ciclo de vida e que tem por objetivo completar o ciclo.
Trazendo para o nosso contexto, Job
é a coroutine
em si, quando você lança uma coroutine
, está lançando um Job
, e quando ele finaliza, ele entra em estado de completo e te devolve a informação desejada, caso seja cancelado, de forma intencional ou não, ele entra em estado de cancelado e por ai vai…
CoroutineScope
também é um Job
, e toda coroutine
lançada dentro dele, vai respeitar o seu ciclo de vida.
Tudo se resume em Job
Trazendo para o código, teríamos algo assim:
- - Scope Job → viewModelScope
1.1 - Job 1 → launch
1.1.1 - Job 1 → launch
1.1.2 - Job 2 → launch
- 1.3 - Job 3 → launch
Implicitamente, o launch { }
está pegando o Job pai e atribuindo a ele mesmo, dessa forma ele consegue manter a referência.
Todos os launch { }
estão abaixo do viewModelScope
, então eles respeitam o ciclo de vida.
Quando você sai da sua Activity
ou Fragment
, o ViewModel
vai cancelar o viewModelScope
, com isso, todos os Jobs
lançados dentro desse scope
serão cancelados junto.
O principal ponto do Job
dentro de um scope
, é que eles trabalham em conjunto, por exemplo.
Você precisa fazer chamadas a uma API, e depois unifica as informações e devolve pra UI.
Nesse cenário, não faz sentido retornar somente o name
, precisamos também do lastName
, então caso a função getLastNameFromApi()
falhe, eu também quero cancelar o getNameFromApi()
.
Com o Job()
isso já é feito, ele propaga o cancelamento para os outros Jobs
que estão dentro do mesmo escopo.
Agora que já entendemos o que é e como funciona um Job
, vamos de fato para o SupervisorJob.
SupervisorJob
Ele é um Job
, então todo o conceito se aplica a ele, exceto um, o cancelamento.
É aí que mora toda a diferença, vamos voltar para o exemplo da chamada da API.
Nesse cenário, quando o getLastNameFromApi
falhar, o getNameFromApi
não vai ser cancelado, e sim mantido. 🙌🏽
E o viewModelScope
utiliza esse mecanismo, quando um Job
falha, ele não vai propagar a falhar para os outros, e porque disso?
o viewModeScope
é único para o seu ViewModel
, e ele envolve todos os seus Jobs
(coroutines) dentro do seu escopo, então no seu viewModelScope
você precisar fazer várias chamadas de diferentes lugares, que as vezes nem sempre são relacionados, então você precisa tratar de forma individual, exemplo:
Faria sentido o setUser
ser cancelado e propagar o cancelamento para o setUserConfigurations
? Não, né?!
Esse é o motivo do viewModelScope
ser um SupervisorJob
e não um simples Job
.
👉🏽 Vale reforçar que, o
ViewModel
sendo cancelado pelaActivity
ouFragment
, ambossetUser
esetUserConfigurations
serão cancelados para não criar memory leak.
Para completar a estrutura, faltou falar apenas sobre o Dispatchers
Primeiro de tudo, o que é um Dispatcher?
Nada mais é do que um kotlin object
que define em qual tipo de Thread
a coroutine
vai ser executada, que tem as seguintes variáveis:
- Main
- Default
- IO
- Unconfined
Dispatchers.Main
Executa coroutines
dentro da MainThread
, ou seja, essa operação vai concorrer com suas operações de UI, como: Listeners, UI draws e tudo que envolve UI.
Então o que devo executar?
Animações, sets de UI e buscas simples que retornam dados para UI.
Dispatchers.IO
Executa coroutines dentro de um conjunto de Threads separadas para IO (input/output).
Esse dispatcher tem basicamente duas utilizações:
- Chamadas de API (Retrofit)
- Chamadas de Banco de dados (Room)
Dispatchers.Default
Executa coroutines dentro de um conjunto de Threads, assim como o IO, porém o foco dele é em operações pesadas do próprio app.
Quando utilizar?
- Operações em listas (sort, filter, map)
- Transformação de imagem (resize, png to bitmap)
- E outros
Dispatchers.Unconfined
Por fim, temos o Unconfined, como o nome já diz, ele não define nenhum tipo de Thread para ser executado, ele é executado com o Dispatcher que o chamar, e podendo ser finalizar em outro Dispatcher.
Seu uso não é recomendado em código real.
Agora que entendemos os tipos principais de Dispatchers, faltou entender o que ser o Immediate.
Dispatchers.Main.immediate
Sabemos que o Dispatchers.Main executa uma coroutine dentro da MainThread, certo?
Mas a questão é: Quando ele executa a coroutine?
A resposta é 🥁 : DEPENDE
Depende do que está sendo executado na MainThread
, por exemplo:
A MainThread
está desenhando os elementos de UI com o onDraw
, está adicionando os Listeners
, e no meio disso tudo você chama uma coroutine
, e o que acontece? Ela joga para o final da lista de execução.
- onDraw
- Listeners
- Sua coroutine
Então o immediate
faz com que sua coroutine
seja executada na hora que você a chamar, "passando" ela na frente de outras operações.
- Sua coroutine 🏃🏽♂️
- Listeners
- onDraw
💡 Vale ressaltar que essa mudança não vai impactar a criação em si do seu layout e nem provocar travamentos.
Então o immediate
do Dispatchers.Main
serve para acelerar o processo da criação/execução da sua coroutine.
Voltando ao viewModelScope
Depois de detalhamento de todos os elementos que envolvem o viewModelScope
,
Vamos relembrar os principais elementos de coroutines utilizados:
- CoroutineScope
- CoroutineContext
- SupervisorJob
- Dispatchers
E os o principais pontos de entendimento do viewModelScope:
- É um escopo que pode ser utilizado somente dentro do
ViewModel
- É cancelado quando o
ViewModel
é cancelado por umaActivity
ouFragment
- Ele não propaga cancelamento, ou seja, se um
Job
dentro dele falhar, os outros permanecem - E ele roda da MainThread
Formas de utilizar o viewModelScope
Agora fica mais fácil a utilização, então vamos lá.
A forma mais simples de utilizar é:
Neste exemplo simples, ele está fazendo o seguinte:
- Utilizando um scope previamente criado pelo ViewModel
- Criando um Job com o launch
- Passando como parâmetro para minha coroutine dois contextos
- SupervisorJob
2. Dispatchers.Main.immediate
Então neste simples bloco, o viewModelScope
se encarrega de fazer toda configuração necessária para criação de coroutine
.
Sabendo que o viewModelScope
utiliza Dispatcher.Main.immediate
, quando realizarmos uma chamada de API, ele utilizará a MainThread
?🤔
SIM! Será feito na MainThread. E como evitar isso?
Tem basicamente duas formas de evitar:
- Trocando o Dispatcher
- Movendo a responsabilidade da troca de Dispatcher para camada de dados
Trocando o Dispatcher do launch por Dispatcher.IO
⚠️ Devemos tomar cuidado ao fazer isso.
Toda vez que eu troco o Dispatcher
, o meu código é executado em outra Thread
, certo?!
Então, imagina que você precisa atualizar um LiveData
que está dentro do seu ViewModel
com o valor da sua chamada.
Neste cenário, o Android Studio mostrará uma mensagem bem amigável falando que você não pode alterar elementos da sua MainThread
a partir de outra Thread
.
E como resolver isso de forma prática?
Bem simples, trocando o value
por postValue
Dessa forma, o valor não será atribuído no momento exato, será criado uma Task
para a MainThread
atribuir o valor.
E isso faz diferença?
Sim, faz diferença.
Neste caso, o primeiro valor atribuído será o result 2
, e em seguida o result 1
, pois o value
é executado de forma imediata e o postValue
é executado através de uma Task
na MainThread
.
Movendo a responsabilidade da troca de Thread para camada de dados
Se você está utilizado MVVM, é bem provável que você tenha um Repositoy, e no caso de Clean Architecture, UseCase.
No exemplo, vamos utilizar um UseCase.
Detalhando o funcionamento do exemplo:
- O viewModelScope vai criar um Job com o launch na MainThread
- Dentro do launch, é chamado o UseCase
💡 O
withContext
faz a troca de contexto, ou seja, vem até ele comDispatcher.Main.immediate
, ele faz a troca para oDispatcher.IO
, executa a coroutine naThread
deIO
, e no seu retorno, ele volta para aMainThread
.
- Por fim, atribuímos o valor do UseCase no LiveData
Com essa abordagem, podemos deixar nossa MainThread
livre de operações de IO
e não precisamos nos preocupar com a troca de contexto que ocorre no UseCase.
Coroutines que precisam “viver” além do ciclo de vida do ViewModel
Imagina que no seu app, você tem uma tela onde você faz um update no seu banco de dados, e volta para a tela principal, seu código seria:
Até ai tudo certo.
Só que tem um porém…
Este update, é um update bem rápido, mas pode ser que por algum motivo ele demore alguns milisegundos a mais.
E se isso acontecer, e o usuário apertar o botão de voltar e sua operação ainda não estiver sido concluída, ela simplesmente não vai ser, pois o ViewModel vai ser cancelado com a finalização da activity/fragment e vai cancelar o viewModelScope, que vai cancelar seu Job, no caso, o seu update.
Esse é um caso clássico, e a solução é bem simples.
Toda vez que damos um launch, ele cria um Job, certo?! E para criar o fluxo de Parent Job, Child Job, o Job utiliza o coroutineContext.job para manter a referência.
E para “quebrar” essa cadeia, vamos fazer da seguinte forma:
Quando atribuímos um SupervisorJob ou um Job para o launch, quebramos a referência do pai, por simplesmente não passarmos o coroutineContext.job em seu construtor.
Dessa forma, quando o viewModelScope for cancelado, ele não tem referência para cancelar o Job filho criado e com isso sua coroutine não é encerrada.
Tome cuidado ao utilizar…
Como ela não é cancelada, você pode acabar causando memory leak, então tenha cuidado ao utilizar, utilize apenas em casos que são realmente necessários.