Android Architecture Components com Kotlin: operações assíncronas e boas práticas

Dados rodando ao mesmo tempo

No artigo onde aprendemos a configurar o Room e fazer operações de inserção e busca de dados, tiveram alguns detalhes que não foram resolvidos, como por exemplo, configurar o Database para que funcione em uma thread síncrona, como também, manter uma única instância do database ao invés de ficar criando todas as vezes…

Considerando esses detalhes e outros que serão apresentados, neste artigo vamos conhecer técnicas e bibliotecas para solucionar esses detalhes. Ansioso para saber? Então bora começar!

bb-8 do Star Wars fazendo joinha

Este artigo faz parte da nova série Android Architecture Components com Kotlin que tem o objetivo de agregar todo o conteúdo relacionado aos novos componentes da Google para uma arquitetura de Apps mais robustas, testáveis e fácil manutenção. Confira:


Salvando dados de maneira assíncrona

Como primeiro passo, vamos lidar diretamente com a operação assíncrona. No Android, podemos utilizar a famosa AsyncTask. Sendo assim, vamos começar com a função que salva um produto.

A AsyncTask é uma classe abstrata, ou seja, precisamos ou criar uma classe que herde dela, ou então, utilizar o Object Expression. Considerando o Object Expression, teríamos o seguinte resultado em código:

private fun saveProduct() {
val createdProduct = create()
val asyncTask = object: AsyncTask<Void, Void, Void>(){
override fun doInBackground(vararg p0: Void?): Void? {
productDao.add(createdProduct)
finish()
return null
}
}
asyncTask.execute()
}

Esse código funciona, porém, a própria IDE apresenta um warning com a seguinte mensagem:

“This AsyncTask class should be static or leaks might occur…”

Em outras palavras, essa implementação pode ocasionar em memory leak, principalmente quando é implementada em um Fragment ou Activity. (Para mais detalhes do motivo, segue a discussão do StackOverFlow).

Para evitar esse problema, basta apenas criarmos uma Inner Class, da mesma maneira como fazemos no ViewHolder do RecyclerView:

private fun saveProduct() {
SaveNote().execute()
}

inner class SaveNote : AsyncTask<Void, Void, Void>() {
override fun doInBackground(vararg p0: Void?): Void? {
val createdProduct = create()
productDao.add(createdProduct)
finish()
return null
}
}

Com apenas essa modificação, podemos remover a chamada da função allowMainThreadQueries() para criar o Database, pois estamos evitando a exception por travar a UI Thread. Ao testar a App e tentar salvar um produto, tudo funciona como esperado.

Buscando os produtos de maneira assíncrona

Da mesma maneira que fizemos no formulário, podemos também fazer na lista de produtos:

inner class LoadProduct : AsyncTask<Void, Void, List<Product>>() {
override fun doInBackground(vararg p0: Void?): List<Product> {
return productDao.all()
}

override fun onPostExecute(products: List<Product>) {
adapter.replaceAllProducts(products)
}
}

override fun onResume() {
super.onResume()
LoadProduct().execute()
}
Note que nesta implementação foi necessário o uso do onPostExecute() para evitar a exception que é lançada quando uma Thread diferente da UI Thread acessa a tela.

Então, da mesma maneira, não há mais a necessidade de chamar o método allowMainThreadQueries() no momento que construímos o Database para a Activity de lista de produtos.

O problema da solução atual

Por mais que a solução por meio de AsyncTask funcione, precisamos ficar atentos com alguns detalhes:

  • Todas as vezes que a Activity entrar em onResume() será executada uma nova AsyncTask;
  • Se em algum momento a Activity for fechada durante a execução da AsyncTask precisamos ficar atentos em cancelá-la, dado que o retorno da mesma mexe diretamente com a tela;
  • Além disso, o código de implementação com AsyncTask não é nada elegante.

Pensando em todos esses detalhes, a própria equipe do Android criou o LiveData que, basicamente, tem o objetivo de manter dados e atualizá-los de maneira assíncrona.

Conhecendo o LiveData

O LiveData utiliza um conceito conhecido como Observable, muito comum em programação reativa, que nos permite observar eventos, por exemplo, quando o dado mantido pelo LiveData for modificado, somos capazes de observar esse evento e tomar uma ação desejada.

A princípio, pode parecer abstrato, ou até mesmo, similar a soluções que já conhecemos, mas não só isso! A grande sacada do LiveData, é que ele foi desenvolvido sob um conceito chamado de Lifecycle-aware.

O conceito básico do Lifecycle-aware

A ideia desse conceito é garantir que as atualizações vindas do LiveData, sejam feitas apenas em estados seguros do ciclo de vida de entidades comuns no Android, como Activities, Fragments ou Services.

Em outras palavras, logo depois que uma Activity chamar o onStart() ou onResume() o evento de mudança do dado pode ocorrer para quem estiver observando, por outro lado, quando acontecer o onPause() ou onDestroy(), quem estava observando, automaticamente deixa de receber o evento de mudança.

Benefícios do LiveData com o Room

De uma maneira geral, temos os seguintes benefícios utilizando o LiveData:

  • Garantimos que os dados batem com a interface do usuário;
  • Evitamos vazamento de memória;
  • Evitamos falhas em Activities que entraram em estado PAUSE;
  • Evitamos a atualização dos dados de maneira manual;
  • Mantém sempre os dados atualizados mesmo perdendo estado.

Todos esses benefícios e outros são mencionados e explicados com mais detalhes na página oficial do Android Developers.

“Muito legal, mas como podemos utilizar o LiveData?”

Adicionando o LiveData no projeto

O LiveData faz parte de uma das bibliotecas do conjunto Architecture Components, ou seja, precisamos adicioná-lo como dependência do projeto:

dependencies {
def lifecycle_version = "1.1.1"
implementation "android.arch.lifecycle:livedata:$lifecycle_version"
    // others dependencies
}
Veja que utilizamos o pacote lifecycle que representa toda base das entidades responsáveis pelo Lifecycle-aware. Para mais detalhes recomendo fortemente a leitura da documentação.

Pronto, somos capazes de utilizar o LiveData.

Configurando o Room para utilizar o LiveData

Como comentei, o LiveData tem a finalidade de manter dados, ou seja, ao invés de devolver uma lista de produtos, devolvemos um LiveData que vai manter a lista de produtos:

@Dao
interface ProductDao {

@Query("SELECT * FROM product")
fun all(): LiveData<List<Product>>

@Insert
fun add(vararg product: Product)

}

Com essa modificação, dentro do onCreate() da ProductsListActivity, podemos chamar o LiveData e apagar todo o código que criamos no onResume() e AsyncTask para buscar e atualizar os dados que foram salvos:

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_products_list)
val database = Room.databaseBuilder(
this,
AppDatabase::class.java,
"techstore-database")
.build()
productDao = database.productDao()
val productsLiveData = productDao.all()
configureRecyclerView()
configureFabAddProduct()
}

Com o LiveData em mãos, precisamos configurar a ação que será observada. Para isso chamamos a função observe() que espera dois argumentos como parâmetros:

  • LifeCycleOwner ou Lifecycle: componente que permite gerenciar o comportamento com base no ciclo de vida, como disparar eventos no início da Activity e não disparar quando ela for parada;
  • Observer: interface que indica a ação que será executada quando o evento de mudança de dado for disparado.

Como primeiro parâmetro, podemos enviar a própria referência da AppCompatActivity, dado que, internamente, ela faz extensão da SupportActivity, que por sua vez, implementa a interface LifeCycleOwner. Então, como segundo parâmetro, podemos implementar o Observer por meio de uma expressão lambda:

productsLiveData.observe(this, Observer {

}
)

Agora, precisamos apenas indicar o que deve ser feito caso ocorra uma atualização nos dados, nesse caso, podemos substituir todos os produtos do adapter, pois serão retornados todos os produtos do banco de dados de uma vez:

productsLiveData.observe(this, Observer { products ->
products?.let {
adapter.replaceAllProducts(it)
}
}
)

Se testarmos o nosso código ele funciona apenas quando entramos no onCreate()!

“Poxa Alex, mas você falou que o LiveData iria atualizar cada mudança de dados sem ter que ficar chamando novamente, não é verdade?

Exatamente! Porém, para que isso seja possível, precisamos manter uma única instância do componente Database em toda a App. Portanto, vamos fazer essa implementação agora.

Criando uma única instância

O jeito mais fácil de fazer Singleton no Kotlin é por meio do Object Expression. Considerando o nosso código, podemos criar um Object Expression dentro do arquivo do AppDatabase.kt:

@Database(entities = [Product::class], version = 1, exportSchema = false)
abstract class AppDatabase : RoomDatabase() {
abstract fun productDao(): ProductDao
}

object Database {

}

Então podemos criar um atributo que vai manter a instância única do AppDatabase:

object Database {

val database: AppDatabase

}

Agora basta apenas fazer a inicialização, porém, a inicialização do Database exige uma referência de Context que precisa ser recebida via parâmetro, afinal, qualquer entidade do Android pode fazer uso do nosso Database.

Entretanto, Object Expression não recebe parâmetros da mesma forma como classes recebem! Sendo assim, precisamos usar uma outra estratégia que possibilite a inicialização de singletons que precisam de argumentos.

Criando Singletons que recebem parâmetros

Uma técnica que podemos considerar é fazendo com que a property database tenha uma inicialização atrasada com o lateinit:

object Database {

lateinit var database: AppDatabase

}

Então criamos uma função que vai receber o Context e vai realizar a inicialização da maneira esperada:

object Database {

lateinit var database: AppDatabase

fun instance(context: Context): AppDatabase {

}

}

Na implementação da função, como primeiro passo, podemos realizar a inicialização da property e retorná-la:

fun instance(context: Context): AppDatabase {
database = Room.databaseBuilder(
context,
AppDatabase::class.java,
"techstore-database")
.build()
return database
}

Em seguida, precisamos garantir que todas as vezes que a função instance() for chamada, vai devolver a mesma instância. Para isso, basta apenas verificarmos se a property foi inicializada, então, se for verdade, retornamos a mesma:

fun instance(context: Context): AppDatabase {
if (::database.isInitialized) return database
database = Room.databaseBuilder(
context,
AppDatabase::class.java,
"techstore-database")
.build()
return database
}
A property por referência isInitialized garante se uma property lateinit tem um valor devolvendo um Boolean, dessa forma, evitamos uma exception por não inicializar a property. Essa solução foi introduzida a partir da versão 1.2 do Kotlin, para mais detalhes dê uma olhada na documentação.

Agora basta apenas modificar a inicialização do AppDatabase em cada uma das Activities:

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_products_list)
val database = Database.instance(this)
// rest of the code
}

Então, se testarmos o projeto, tudo funciona como esperado 😉

Para saber mais

Por mais que o Singleton a partir do Object Expression nos devolva uma única instância, quando lidamos com cenários que envolvam concorrência, essa solução pode apresentar problemas, pois ela não é thread safety.

Considerando esse mesmo exemplo que fizemos, para garantir que seja thread safety, precisamos realizar os seguintes ajustes:

object Database {

@Volatile
private lateinit var database: AppDatabase

fun instance(context: Context): AppDatabase {
synchronized(this) {
if (::database.isInitialized) return database
database = Room.databaseBuilder(
context,
AppDatabase::class.java,
"techstore-database")
.build()
return database
}
}

}
  • @Volatile: notifica a JVM para que cada vez que o backing field da property for escrito, todas as threads que irão acessá-la sejam notificadas do seu valor;
  • sincronized(): indica que todo código que ele envolve será executado de maneira síncrona, ou seja, impede que mais de uma thread o execute ao mesmo tempo.

Existem outras alternativas que auxiliam nessas questões de Singleton e Thread Safety no Kotlin, caso tenha mais interesse no assunto, recomendo que dê uma lida neste ótimo artigo.

Assim como eu, se não estiver convencido, fique à vontade em executar um teste de unidade para garantir o comportamento de singleton em cenários que envolvam concorrência, eu fiz um exemplo de teste e vou commitar no projeto para este artigo também:

@RunWith(MockitoJUnitRunner::class)
class DatabaseTest {

@Mock
private lateinit var context: Context

@Test
fun `should get the same instance of Database when run threads simultaneously`() {
val dbInstances = mutableListOf<AppDatabase>()
repeat(10) {
thread(start = true) {
var element = Database.instance(context)
dbInstances.add(element)
}
}
Thread.sleep(500)

if (dbInstances.isNotEmpty()) {
val iterator = dbInstances.iterator()
val first = iterator.next()
if (iterator.hasNext()) {
assertThat(first, equalTo(iterator.next()))
}
}
}

}

Para executar o teste e notar as mudanças, rode sem o sincronized() e depois com o sincronized() na função instace() 😉

Código fonte do projeto

Caso tenha dúvidas ou queira consultar o projeto com todo o conteúdo que vimos no artigo, fique à vontade em acessar o repositório do GitHub:

Conclusão

Neste artigo, aprendemos como podemos utilizar o Room com operações assíncronas, tanto com o modo nativo via AsyncTask como também com a lib LiveData. Aprendemos os benefícios que o LiveData nos oferece, principalmente pela questão do Lifecycle-Aware.

Por fim, criamos Singletons no Kotlin utilizando o Object Expression, também, vimos que Singletons que recebem argumentos precisa de um cuidado especial e aprendemos como podemos solucionar esse problema no Kotlin.

O que achou deste artigo? Deixe o seu feedback nos comentários 😄