Chamadas assíncronas no Room, explorando as opções (RxJava, LiveData e Coroutines)

Levi Albuquerque
Android Dev BR
Published in
11 min readMay 30, 2019

--

Imagem de GregoryButler por Pixabay

Desde seu lançamento alguns anos atrás, o Room tem se tornado a opção padrão de persistência de dados usando SQL em Android. Isso se deve a diversos fatores, como a maturidade da biblioteca, o fato dela ser a opção oficial do Google e como aos poucos a própria comunidade a moldou para as necessidades do dia a dia de um desenvolvedor Android. Hoje, veremos como essa biblioteca evoluiu através do seu modo de fazer chamadas assíncronas.

Direto do site oficial do Room:

A biblioteca de persistência Room, provê uma camada de abstração sobre o SQLite, permitindo assim um acesso mais robusto ao banco de dados com todo poder do SQLite.

Então essa camada de abstração foi criada para facilitar a comunicação com o a já existente API de banco de dados do Android, a necessidade do Room surgiu por muitos motivos:

  • A API de banco de dados do Android é um tanto trabalhosa e dependendo do tamanho do banco utilizado pode se tornar um pesadelo para administrar e manter. Por isso, ao longo dos anos, muitas bibliotecas surgiram para abstrair essa API.
  • O código para acesso ao banco é muitas vezes repetitivo.
  • Queries SQL eram muito suscetíveis a erro durante o desenvolvimento.

Por isso e por outros motivos foi criado o Room. Mas a ideia deste post é ir um pouco além do porquê da sua criação. Todo desenvolvedor Android sabe que leituras de arquivos devem ser feitas fora da thread principal, e isso não seria diferente para o Room, já que o banco de dados é um arquivo estruturado com algumas regras. Todas as consultas do Room devem ser feitas de forma assíncrona, mais precisamente fora da thread principal, e esse post traz algumas maneiras de fazer essa leitura. Utilizarei o código neste repositório para exemplificar. Para refrescar a memória sobre o Room e outros assuntos que serão mencionados aqui, recomendo os artigos:

A app é completamente offline e serve simplesmente para criar Times dentro de Organizações. Cada Time pode ter várias Pessoas, então é uma app somente para ilustrar a utilização do Room dentro da arquitetura MVVM.

A entidade Person é uma das fundamentais e é simplesmente uma classe contendo algumas informações sobre as pessoas que podem estar dentro de um Time. O restante deste post será baseado nessa classe e em seu DAO, o qual será definido de diferentes maneiras dependendo do tipo de acesso que será realizado.

Leitura Direta

A leitura direta não é nada mais do que a leitura síncrona do banco de dados. Por default o Room não permite leitura síncrona na thread principal e lançará uma exceção caso isso seja feito. Isso não que dizer que não podemos definir chamadas síncronas e na hora da utilização trocarmos de thread para evitar a exceção. Após definir o banco, entidades e DAOs, podemos escrever algumas Queries no nosso DAO que acessam o banco de forma síncrona:

A utilização desses métodos deve ser feita fora da thread principal. Para ativar queries nessa thread (o que não é recomendado pois isso pode travar a App e causar um ANR) na hora da criação do banco pode-se escolher a opção allowMainThreadQueries().

A utilização de chamadas síncronas é recomendada quando não se pode criar uma consulta no próprio banco para retornar os dados desejados. Por exemplo, retornar todos as Pessoas com ids em uma lista.

Se essa lista for muito grande não poderemos criar uma consulta utilizando “Where In”, então temos a opção de pesquisar ids individualmente utilizando o método síncrono em uma thread separada.

Testes no Room

Para escrever testes utilizando as chamadas diretas, precisaremos escrever testes que rodam em um dispositivo (colocaremos os arquivos em androidTest). Testes que rodam em dispositivo são mais lentos do que aqueles que rodam na JVM, entretanto como o Room não precisa criar elementos de UI ele deve rodar mais rapidamente que testes de UI. Uma classe de teste simples utilizando o suporte de test do Room pode ser encontrada abaixo:

O exemplo é bem simplório mas dá pra ter uma ideia do que se pode fazer. Para fazer testes o room oferece a opção de criamos o banco em memória, notem que utilizamos a opção inMemoryDatabaseBuilder para criar uma instância do banco de dados. Utilizando uma classe como esta pode-se testar os métodos do seu DAO, isso é importante se eles fazem chamadas complexas para a camada de SQL e é preciso certificar-se que funcionam de forma esperada.

Utilizando LiveData

A primeira forma assíncrona que pode ser utilizada é o famoso LiveData. Aliás o Room e o LiveData foram feitos um para outro, inclusive as versões iniciais do Room e primeiros tutoriais foram trazidos utilizando-se LiveData. Dessa forma, a utilização de LiveData deve-se iniciar na definição do DAO, onde selecionamos o tipo de retorno devolvido por uma consulta:

Em uma arquitetura MVVM podemos repassar esse LiveData como uma variável observável pela View e aguardar mudanças para postar os dados na UI, nosso repositório poderia ser definido assim:

Passando os dados para o ViewModel:

E finalmente a utilização é bem simples:

Uma observação interessante sobre essa integração entre LiveData e Room é que o último observa as tabelas do banco e avisa aos observers inscritos caso ocorra alguma mudança em alguma delas. Dessa forma, no exemplo descrito acima, ao adicionarmos uma nova Pessoa na tabela o observer será automaticamente avisado e poderemos atualizar a UI com os novos dados retornados pela consulta.

Testes no Room (com Live Data)

É preciso fazer algumas mudanças na forma como os testes são escritos para acomodar as consultas que retornam LiveData:

Duas observações sobre a nova classe de teste:

  1. A utilização do LiveData, um wrapper assíncrono, exige que as chamadas que o utilizam sejam convertidas para chamadas síncronas, afinal os testes para não serem flaky* precisam ser previsíveis, incluindo quando os eventos acontecem. Para tanto é preciso utilizar uma Rule que converte o executor usado pelos Componentes de Arquitetura por um que executa as tarefas de forma síncrona, por isso utilizou-se o InstantTaskExecutorRule. Para ter acesso a essa classe é preciso adicionar a seguinte dependência:

2. Os retornos das consultas que utilizam LiveData precisam ser convertidos nos objetos finais delas, por isso utiliza-se uma função que pode ser encontrada no repositório de códigos dos componentes de arquitetura:

Concluindo, para fazer testes utilizando o LiveData, além das configurações padrões do Room é preciso utilizar uma função especial e uma regra de execução (e por extensão uma nova dependência).

Resumindo

Algumas vantagens de se utilizar LiveData:

  • Tipos de dados consistentes desde a camada de persistência: Não existe a necessidade de transformar os tipos de wrappers, utilizaremos LiveData desde as camadas mais profundas até chegar a UI. Muitos desenvolvedores preferem utilizar Rx (por exemplo) nas camadas mais profundas (rede e dados) e transformar isso em LiveData para ganhar o life-cycle awareness. Utilizando LiveData desde o início evita a necessidade de transformações.
  • Perfeita integração com outros componentes de Arquitetura: LiveData e Room foram feitos um para o outro, portanto espera-se que a utilização com outros componentes de arquitetura (LifeCycle e ViewModel) seja mais facilitada. Espera-se também o perfeito encaixe em uma arquitetura MVVM.
  • Dados reagem a mudanças no banco e são atualizados na View mais facilmente.
  • Não é preciso se preocupar se os dados estão sendo recuperados ou não na thread principal, isso é transparente para o desenvolvedor.

Mas nem tudo são flores, algumas desvantagens existem também:

  • LiveData são containers de dados “observáveis” e portanto não são capazes, por si só, de emitir estados de sucesso e erro. Assim, se a chamada ao Room falhou por algum motivo, não existe uma funcionalidade dentro do LiveData capaz de reportar isso nativamente. Por conta disso, começamos a ver uma classe Resource aparecer em exemplos de código que o utilizam para representar estados de sucesso e erro em chamadas.
  • LiveData não foram criados para rodarem uma vez e pronto. Vários wrappers e classes de ajudam devem ser criadas se o objetivo for acioná-lo apenas uma vez.
  • Por conta do LifeCycle diferenciado, a utilização de LiveData em Fragments pode apresentar comportamentos inesperados.

Utilizando RxJava

Para aqueles que já faziam chamadas assíncronas e multithread com RxJava, desde o início o Room dá suporte a retornos do tipos Single, Maybe, Flowable (e mais recentemente Observables e Completables também). A definição da utilização de RxJava se dá também no DAO:

Algumas observações sobre a utilização desses tipos no Room:

  • Ao utilizarmos Single como retorno precisamos nos certificar que o banco retornará algo segundo a consulta definida. No exemplo acima caso não existam usuários com o ID especificado o método lançará uma exceção. Outra observação importante é que o Single não receberá mais atualizações caso o banco seja atualizado, pois ele chamará o seu onComplete logo após retornar o resultado da consulta.
  • Caso não tenhamos certeza se o método vai retornar algo aconselha-se utilizar Maybe. Assim caso nenhuma row do banco satisfaça a consulta o stream chamará o onComplete e encerrará.
  • Para ter um resultado parecido com o do LiveData onde o banco emite atualizações aconselha-se a utilização de Observable/Flowable, assim o fluxo só emite quado houver algo que satisfaça a consulta e ainda fica escutando futuras atualizações do banco.

Podemos encaixar a utilização do Room com Rx e os componentes de arquitetura facilmente utilizando a integração dos dois providas pela biblioteca:

Assim pode-se utilizar RxJava na camada de persistência, mas ao chegar no ViewModel utiliza-se o método LiveDataReactiveStreams.fromPublisher() para converter um stream RxJava em LiveData.

Contudo é preciso ter bastante cuidado, pois o tratamento de erros do RxJava e do LiveData exigem uma adaptação para que possam funcionar em conjunto. Alguns exemplos oficiais do Google trazem a utilização de um objeto wrapper que encapsula o estado do LiveData, dessa forma as funções de tratamento de erro do RxJava como onErrorReturn são utilizadas para retornar diferentes versões desse objeto dependendo se houve algum erro ou não no fluxo.

Testes no Room (com RxJava)

RxJava já saiu de fábrica preparado para testes, entretanto isso não é suficiente para que os mesmos rodem. A classe de teste foi modificada para acomodar as mudanças necessárias para rodas os testes quando utiliza-se RxJava:

Observe que ao utilizar o método test() em um tipo Rx retornaremos um TestObserver com vários métodos auxiliares para testes. Como exemplo, temos o assertValue e o assertComplete. A regra para troca de Executor dos componentes de arquitetura ainda está presente e acrescentou-se uma nova para a troca de thread dos tipos Rx:

Resumindo

Algumas vantagens de utilizar Rx com o Room:

  • RxJava já e uma tecnologia consolidada que tem muitos adeptos e soluciona vários problemas conhecidos. Um sistema que já utiliza Rx em outras partes seria mais facilmente adaptado a utilizar o Room com ele, já que não seria necessário fazer mudanças muito drásticas para a adaptação de uma nova tecnologia.
  • Muitos consideram as funções de transformações do Rx como uma vantagem da sua utilização.
  • RxJava dá ao desenvolvedor mais controle sobre como as threads são mantidas, de onde elas vêm e o trabalho que será executado nelas.

E as desvantagens:

  • Para aqueles que nunca utilizaram RxJava (ou Reactive Streams de uma forma geral), aprender do zero tem uma curva de aprendizado um tanto ingrime.
  • RxJava não considera o LifeCycle da sua Activity, portanto o desenvolvedor é o responsável por se preocupar em proteger a aplicação contra possíveis leaks.
  • Facilmente pode levar a utilização excessiva e errônea. RxJava resolve situações específicas e pode sim ser adaptado para várias outras, entretanto é muito fácil utilizá-lo de forma excessiva para resolver problemas que podem ser resolvidos de maneira mais simples tudo por vontade de utilizar a “ferramente nova”.
  • Não permite a emissão de nulls durante um stream.

Utilizando Coroutines

Finalmente o último método que pode ser utilizado (disponível na versão 2.1 do room, em alfa) são coroutines. Para realizar esses testes, certifique-se que sua versão do Room é a 2.1.0 (Kotlin 1.3+ também é necessário), além disso uma nova dependência é necessária, tal qual quando foi adicionado suporte a Rx:

Com tais mudanças pode-se agora definir o DAO utilizando suspend em seus métodos:

Com a introdução de coroutines, será preciso também modificar o ViewModel. As coroutines precisam de um Scope para rodar, faz parte da API e ajuda a controlar o tempo de vida delas. Rodar coroutines dentro de um Scope ajuda a evitar leaks, já que pode-se controlar a execução de todas as coroutines dentro de um Scope, cancelando-as se necessário.

Para adaptar o ViewModel para que ele possa conter e rodar coroutines, uma opção, é fazê-lo implementar a interface CoroutineScope. A implementação dessa interface faz com que todas as coroutines iniciadas nesse escopo possam ser agrupadas (e posteriormente canceladas quando o ViewModel é destruído, evitando leaks).

Uma outra opção, adicionada na versão 2.1.0 da lib LifeCycle, é a utilização do viewModelScope que já administra a limpeza das coroutines juntamente com o ciclo de vida do ViewModel.

Abaixo está o ViewModel adaptado utilizando a primeira opção:

A utilização em no Fragment é exatamente igual aos casos anteriores, já que sempre continua-se a usar LiveData entre a View(Fragment) e o ViewModel. Para realizarmos o carregamento dos dados, basta chamar o método loadPeople() e eles serão atualizados no LiveData personList. Note que utilizamos o Scope para agregar as coroutines que são iniciadas e por fim no onCleared cancelar qualquer uma que ainda esteja em execução no momento que o VM é destruído.

Testes no Room (com Coroutines)

Para rodar testes com coroutines, nada mais simples do que utilizar runBlocking.

Pode-se perceber que a classe de teste ficou bem mais simples, isso por que não são necessárias regras para controle de thread quando se utiliza coroutines, simplesmente utilizamos o método runBlocking que iniciará a função do DAO e esperará o seu retorno síncronamente.

Resumindo

Algumas vantagens de utilizamos coroutines:

  • O multithread será controlado utilizando apenas recursos da própria linguagem.
  • O código escrito é “sequencial”, o que torna-o mais legível e fácil de entender.
  • Consegue lidar com nulls mais facilmente que Rx, onde streams não podem transmitir nulls.

Existem desvantagens também:

  • Os conceitos que envolvem coroutines ainda são bem novos e a curva de aprendizado pode não ser aceitável em todos os projetos. É bem mais simples de aprender do que Rx, mas ainda assim exige tempo, o que pode não ser uma opção.
  • A administração das Threads de execução pode não ser tão transparente quanto desejado, por exemplo LiveData abstrai isso para o desenvolvedor.

Cada um dos três jeitos de utilizar o Room com chamadas assíncronas tem suas vantagens e desvantagens e aplicam-se a projetos diferentes.

Em projetos já consolidados e completamente baseados em RxJava, por exemplo, a melhor opção seria continuar utilizando a biblioteca e incorporar Room + Rx. Por outro lado, depois do surgimento dos componentes de arquitetura, muitos projetos surgem utilizando LiveData, VM e LifeCycle, assim a fórmula Room + LiveData parece ser mais adequada. Finalmente, coroutines tem ganhado bastante suporte nos últimos meses e há sinais claros tanto da comunidade como das empresas em adotá-las como alternativa para chamadas assíncronas, assim a fórmula Room + coroutines parece se a mais adequada. Manter a consistência das tecnologias utilizadas, muitas vezes, é a melhor opção.

Para projeto que estão iniciando no momento, cabe uma avaliação de qual seria a melhor abordagem. Isso vai depender da maturidade dos desenvolvedores (já desenvolvem em Rx, LiveData, já experimentaram implementações com coroutines?), velocidade do projeto (existe tempo hábil para ainda aprender um novo jeito de fazer chamadas assíncronas?) e tecnologias que serão utilizadas. Portanto, não existe uma fórmula perfeita, mas aquela que melhor se adéqua a cada caso.

*flaky tests são testes que não funcionam sempre do mesmo jeito. Testes devem funcionar de forma previsível, para uma mesma entrada ele sempre produz a mesma saída, flaky tests não seguem esse padrão e por isso são indesejáveis.

--

--