MVVM, Koin, Rx, Room, Databinding e um pouco mais…

No primeiro artigo da série, fizemos o setup inicial do projeto e criamos o nosso module domain.

  • Domain
  • Data
  • Presentation

Data Module

Todos os projetos Android possuem dados, os quais precisam ser fornecidos de algum lugar, e é justamente isso que o module data faz para nós. Esses dados podem vir de qualquer lugar, como de alguma API ou database.

Quando a domain pede algum dado, ela não sabe de onde eles são fornecidos, pois isso é responsabilidade do modulo data.

Conteúdo do data:

  • Api: temos aqui todos os endpoints que vamos utilizar para requisitar dados do backend.
  • Model: aqui ficam as entidades que vêm do backend ou da cache, ou seja, o dado puro, que só é utilizado no modulo data.
  • Mapper: onde vamos mapear nossos models para as entidades exigidas pela domain.
  • RepositoryImpl: aqui implementamos a interface repository da domain e é onde vamos decidir de qual lugar vamos pegar os dados se da cache ou do backend.
  • CacheDataSource: interface de comunicação que é implementada na nossa cache para pegar os dados localmente.
  • CacheDataSourceImpl: aqui vamos salvar os dados na cache (no nosso caso, room database) e fornecê-los já mapeados.
  • RemoteDataSource: interface de comunicação que é implementada no remote para pegar dados do backend.
  • RemoteDataSourceImpl: aqui vamos chamar a nossa server api, para pegar os dados do backend e enviar para quem solicitou já mapeados.

Diagrama de fluxo do modulo data:

A domain solicita algum dado para o seu repository, que está implementado no RepositoryImpl, que então decide de onde vai buscar os dados solicitados.

Primeiro, chamamos a nossa cache para verificar se temos algum dado para retornar e, caso não tenha, chamamos o remote e salvamos esses dados na cache e, então, retornamos os dados requeridos para a domain.

Agora que já temos uma ideia de como funciona o nosso modulo data, vamos criá-lo. O processo é praticamente o mesmo explicado no artigo anterior, sendo que a única diferença é que, ao invés de selecionar uma java Library, vamos selecionar Android Library.

Vamos começar o desenvolvimento do módulo:

A primeira coisa que faremos é adicionar as dependências necessárias ao nosso arquivo dependencies.gradle, para o modulo data.

Como teremos chamadas para o backend, utilizaremos Retrofit e, para a nossa cache, vamos utilizar Room.

A ideia não é aprofundar conhecimentos sobre as bibliotecas, por isso, vamos explicar brevemente ao longo do desenvolvimento e, se houver dúvida, basta entrar nos links destas (ao longo do artigo, temos alguns links de como utilizá-las). Mas, caso ainda tenha dúvidas, sinta-se à vontade para entrar em contato.

Ficarão assim nossas dependências atualizadas:

ext {


    //Android Config
    minSDK = 20
    targetSDK = 28
    compileSDK = 28

    kotlinVersion = '1.3.21'

    buildTools = '3.4.0-beta04'

    //Rx
    rxJavaVersion = '2.2.7'
    rxKotlinVersion = '2.2.0'
    rxAndroidVersion = '2.1.1'

    //Koin
    koinVersion = '1.0.2'

    //Retrofit
    retrofitVersion = '2.3.0'

    //Gson
    gsonVersion = '2.8.5'

    //Room version
    roomVersion = '2.1.0-alpha02'

    dependencies = [
        kotlin: "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion",
        rxJava: "io.reactivex.rxjava2:rxjava:$rxJavaVersion",
        rxKotlin: "io.reactivex.rxjava2:rxkotlin:$rxKotlinVersion",
        rxAndroid: "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion",
        koin: "org.koin:koin-android:$koinVersion",
        retrofit: "com.squareup.retrofit2:retrofit:$retrofitVersion",
        retrofitRxAdapter: "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion",
        retrofitGsonConverter: "com.squareup.retrofit2:converter-gson:$retrofitVersion",
        gson: "com.google.code.gson:gson:$gsonVersion",
        room: "androidx.room:room-runtime:$roomVersion",
        roomRxJava: "androidx.room:room-rxjava2:$roomVersion",
        roomCompiler: "androidx.room:room-compiler:$roomVersion"
    ]
}

Obs: Se quiser utilizar versões diferentes ou outras libs, fique à vontade, pois o conceito é independente das libs utilizadas.

E agora vamos chamar nossas libs no gradle do módulo data:

apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt'

android {
    def globalConfiguration = rootProject.extensions.getByName("ext")

    compileSdkVersion globalConfiguration["compileSDK"]

    defaultConfig {
        minSdkVersion globalConfiguration["minSDK"]
        targetSdkVersion globalConfiguration["targetSDK"]
    }

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }
}

dependencies {
    def dependencies = rootProject.ext.dependencies

    implementation project(":domain")

    implementation dependencies.kotlin
    implementation dependencies.rxJava

    implementation dependencies.retrofit
    implementation dependencies.retrofitRxAdapter
    implementation dependencies.retrofitGsonConverter
    implementation dependencies.gson

    implementation dependencies.room
    implementation dependencies.roomRxJava
    kapt dependencies.roomCompiler

    implementation dependencies.koin
}

É possível que você não tenha reparado, mas nas dependências colocamos 3 propriedades: minSdk, targetSdk e compileSdk, que estão sendo utilizadas no arquivo gradle do data.

Esse module necessita da domain, pois é aqui que vamos implementar os repositórios para retornar os dados que estamos buscando.

Agora, com as dependências configuradas, podemos começar a desenvolver nosso module.

Como já vimos anteriormente, vamos mostrar uma lista de AndroidJobs no final:

{
     "jobs": [{
             "title": "Android developer",
             "native": true,
             "required_experience_years": 3,
             "country": "Brazil"
         },
         …
     ]
 }

CacheData

Vamos começar implementando a nossa cache e deixando-a preparada para salvar nossos dados:

  • Model: aqui vamos criar a entidade que utilizaremos em nosso database.
@Entity(tableName = "jobs")
data class AndroidJobCache(
    @PrimaryKey(autoGenerate = true)
    var id: Int = 0,
    var title: String = "",
    var requiredExperienceYears: Int = 0,
    var native: Boolean = false,
    var country: String = ""
)

Essa é nossa entidade que será utilizada para o nosso database.

  • DataBase: precisamos criar o nosso banco de dados e, também, de alguma forma, interagir com ele. Por isso, aqui temos duas classes:

JobsDao: nossa interface de interação com o banco de dados, como a própria expressão “DAO” já deixa claro (Data Access Object):

@Dao
interface JobsDao {

    @Query("SELECT * FROM jobs")
    fun getJobs(): Single<List<AndroidJobCache>>

    @Transaction
    fun updateData(users: List<AndroidJobCache>) {
        deleteAll()
        insertAll(users)
    }

    @Insert
    fun insertAll(users: List<AndroidJobCache>)

    @Query("DELETE FROM jobs")
    fun deleteAll()
}

Temos 4 métodos de interação com o banco de dados:

  • getJobs: vai retornar uma lista de AndroidJobsCache do banco jobs (que setamos anteriormente Entity(“tableName = jobs”)).
  • inserAll: insere uma lista de AndroidJobs no banco jobs.
  • deleteAll: deleta todos os dados do banco jobs.
  • updateData: deleta os dados antigos e atualiza com os novos que recebemos.

JobsDataBase: é simplesmente a classe que cria o nosso banco de dados:

@Database(version = 1, entities = [AndroidJobCache::class])
abstract class JobsDataBase : RoomDatabase() {
    abstract fun jobsDao(): JobsDao


    companion object {
        fun createDataBase(context: Context) : JobsDao {
            return Room
                    .databaseBuilder(context, JobsDataBase::class.java, "Jobs.db")
                    .build()
                    .jobsDao()
        }
    }
}

Aqui criamos o nosso database, referenciando nossa entidade criada anteriormente, e também setamos o nosso jobsDao como meio de acesso aos dados do banco. Temos o método createDataBase, que cria o nosso banco. Se quiser aprofundar mais sobre o Room, recomendo o seguinte artigo.

  • Mapper: aqui vamos mapear os dados para salvar na cache e também mapear os dados da cache para serem enviados corretamente.
object AndroidJobCacheMapper {

    fun map(cacheData: List<AndroidJobCache>) = cacheData.map { map(it) }

    private fun map(cacheData: AndroidJobCache) = AndroidJob(
        title = cacheData.title,
        experienceTimeRequired = cacheData.requiredExperienceYears,
        native = cacheData.native,
        country = cacheData.country
    )

    fun mapJobsToCache(jobs: List<AndroidJob>) = jobs.map { map(it) }

    private fun map(data: AndroidJob) = AndroidJobCache(
        title = data.title,
        requiredExperienceYears = data.experienceTimeRequired,
        native = data.native,
        country = data.country
    )
}

Simplesmente numa classe object com funções para mapear os dados, podemos ver a utilização do mapper na seguinte interface JobsCacheSourceImpl na implementação do getJobs.

Quando recebemos os jobs no getDao, eles estão vindo como entidades do database, mas, na verdade, o que precisamos retornar é uma lista de AndroidJobs;

Para isso chamamos no map o AndroidJobCacheMapper.map(DATA), passando os dados que o getDao retornou (nesse caso representado por it, que seria a lista de jobs que estão no banco de dados) para serem traduzidos para List<AndroidJob> que é o dado que getJobs precisa retornar.

  • Source: é composto por uma interface que vai solicitar dados da cache e sua respectiva implementação.
  • JobsCacheDataSource: interface utilizada para que o repository possa solicitar dados da cache.
interface JobsCacheDataSource {
    fun getJobs(): Single<List<AndroidJob>>

    fun insertData(list: List<AndroidJob>)
    fun updateData(list: List<AndroidJob>)
}

getJobs: retorna a lista de AndroidJobs mapeados do banco de dados.

insertData: insere nossa lista de AndroidJobs no banco de dados.

updateData: atualiza nosso banco de dados.

  • JobsCacheSourceImpl: aqui temos a implementação da interface acima.
class JobsCacheSourceImpl(private val jobsDao: JobsDao): JobsCacheDataSource {

    override fun getJobs(): Single<List<AndroidJob>> {
        return jobsDao.getJobs()
            .map { AndroidJobCacheMapper.map(it) }
    }

    override fun insertData(list: List<AndroidJob>) {
        jobsDao.insertAll(AndroidJobCacheMapper.mapJobsToCache(list))
    }

    override fun updateData(list: List<AndroidJob>) {
        jobsDao.updateData(AndroidJobCacheMapper.mapJobsToCache(list))
    }
}

Dentro da cada implementação, o jobsDao executa a ação que estamos solicitando. Vale destacar que nas implementações de insert e update mapeamos os dados para salvar na nossa cache, e no getJobs mapeamos os dados da cache para os tipo requerido. O jobsDao é injetado no construtor.

RemoteData

Agora que já temos a nossa cache, vamos nos preparar para receber os dados do backend.

  • Model: dado puro que vem do backend.
class JobsPayload(
    @SerializedName("jobs") val jobsPayload: List<AndroidJobPayload>
)

class AndroidJobPayload(
    @SerializedName("native") val native: Boolean,
    @SerializedName("title") val title: String,
    @SerializedName("required_experience_years") val requiredExperienceYears: Int,
    @SerializedName("country") val country: String
)

São as nossas entidades que representam o json que vem do backend, ou seja, o dado puro.

  • Api: simplesmente a interface que contém os endPoints, os quais vamos chamar para se comunicar com o backend.
interface ServerApi {

    @GET("/android-jobs")
    fun fetchJobs(): Single<JobsPayload>
}

Temos apenas uma chamada e nela estamos dizendo que esperamos o JobsPayload como retorno.

  • Mapper: aqui vamos mapear os dados puros do backend, nosso payloads, em AndroidJobs, que estão sendo pedidos pela domain.
object AndroidJobMapper {

    fun map(payload: JobsPayload) = payload.jobsPayload.map { map(it) }

    private fun map(payload: AndroidJobPayload) = AndroidJob(
        title = payload.title,
        experienceTimeRequired = payload.requiredExperienceYears,
        native = payload.native,
        country = payload.country
    )
}

Simplesmente uma classe object com funções para mapear os dados.

  • Source: é composto por uma interface que vai solicitar dados do backend e sua respectiva implementação:
  • RemoteDataSource: interface utilizada para que o repository possa solicitar dados do backend.
interface RemoteDataSource {
    fun getJobs(): Single<List<AndroidJob>>
}

getJobs: retorna a lista de AndroidJobs, mapeadas do backend.

  • RemoteDataSourceImpl: implementação da interface acima.
class RemoteDataSourceImpl(private val serverApi: ServerApi):
    RemoteDataSource {

    override fun getJobs(): Single<List<AndroidJob>> {
        return serverApi.fetchJobs()
            .map { AndroidJobMapper.map(it) }
    }
}

Dentro da implementação do método getJobs, chamamos o endpoint da Api para solicitar os dados do backend, e, quando o recebemos, mapeamos o payload para o dado que foi solicitado. A serverApi é injetada no construtor.

RepositoryImpl

  • AndroidJobsRepositoryImpl: enfim, chegamos na implementação do AndroidJobsRepository da domain dentro do modulo data.
class AndroidJobsRepositoryImpl(
    private val jobsCacheDataSource: JobsCacheDataSource,
    private val remoteDataSource: RemoteDataSource
): AndroidJobsRepository {

    override fun getJobs(forceUpdate: Boolean): Single<List<AndroidJob>> {
        return if (forceUpdate)
            getJobsRemote(forceUpdate)
        else
            jobsCacheDataSource.getJobs()
            .flatMap { listJobs ->
                when{
                    listJobs.isEmpty() -> getJobsRemote(false)
                    else -> Single.just(listJobs)
                }
            }
    }

    private fun getJobsRemote(isUpdate: Boolean): Single<List<AndroidJob>> {
        return remoteDataSource.getJobs()
            .flatMap { listJobs ->
                if (isUpdate)
                    jobsCacheDataSource.updateData(listJobs)
                else
                    jobsCacheDataSource.insertData(listJobs)
                Single.just(listJobs)
            }
    }
}

O único método que temos que implementar aqui é o getJobs, que deve retornar a lista de AndroidJobs.

Primeiro, verificamos se temos que forçar o update. Em caso positivo, chamamos o método getJobsRemote, que vai chamar o remoteDataSource, para pegar dados do backend, e então salvá-los ou atualizá-los na cache por meio do jobsCacheDataSource.

Caso não seja solicitado forçar o update, pegamos a lista da cache e, se ela for vazia, chamamos o getJobsRemote, salvamos na cache e retornamos a nossa lista.

Di (dependency injection)

Aqui ficam nossos modules Koin, que gerenciam nossas dependências.

  • DataCacheModule: responsável por gerir as dependências da cache.
val cacheDataModule = module {
    single { JobsDataBase.createDataBase(androidContext()) }
    factory<JobsCacheDataSource> { JobsCacheSourceImpl(jobsDao = get()) }
}

Primeiro, estamos criando a instância do nosso database, que é um single, ou seja, é criado uma única vez durante o ciclo de vida do app.

Chamamos JobsDataBase.createDatabase para criar essa instância para nós.

Em seguida, criamos nosso provedor da cache, que é factory, ou seja, toda vez que for requerido, será criada uma nova instância onde passamos a interface como provedor e, dentro da classe que o implementa, fornecemos no construtor a dependência requerida (nesse caso, o jobsDao que foi criada por meio do JobsDataBase.createDatabase).

  • DataRemoteModule: responsável por gerir as dependências do backend.
val remoteDataSourceModule = module {
    factory { providesOkHttpClient() }
    single { createWebService<ServerApi>(
        okHttpClient = get(),
        url =  androidContext().getString(R.string.base_url)
    ) }

    factory<RemoteDataSource> { RemoteDataSourceImpl(serverApi = get()) }
}

fun providesOkHttpClient(): OkHttpClient {
    return OkHttpClient.Builder()
        .connectTimeout(30, TimeUnit.SECONDS)
        .readTimeout(30, TimeUnit.SECONDS)
        .writeTimeout(30, TimeUnit.SECONDS)
        .build()
}

inline fun <reified T> createWebService(okHttpClient: OkHttpClient , url: String): T {
    return Retrofit.Builder()
        .addConverterFactory(GsonConverterFactory.create())
        .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
        .baseUrl(url)
        .client(okHttpClient)
        .build()
        .create(T::class.java)
}

Como podemos ver, temos a criação do nosso webService e httpClient, que são coisas do retrofit. Então, recomendo este artigo caso não tenha experiência com Retrofit.

No método createWebService, primeiramente, provemos o nosso OkHttpClient, na criação do webService. Em seguida, passamos à interface ServerApi como um parâmetro reified, que nos possibilita utilizá-la dentro da função. Também passamos a nossa base url, que está nos resources.

Esse método provê o nosso webService, que pode ser requerido por qualquer classe que tem a serverApi no seu construtor. Depois disso, temos um último factory, que possui a interface RemoteDataSource provedor e dentro RemoteDataSourceImpl, que recebe no construtor a dependência requerida (no caso, serverApi).

Duvidas de Koin: https://insert-koin.io/docs/2.0/getting-started/android/ Durante o desenvolvimento do artigo, mudamos a versão do koin para 2.0-rc1

  • DataModule: e, finalmente, criamos a dependência do repository.
val repositoryModule = module {
    factory<AndroidJobsRepository> {
        AndroidJobsRepositoryImpl(
            jobsCacheDataSource = get(),
            remoteDataSource = get()
        )
    }
}

val dataModules = listOf(remoteDataSourceModule, repositoryModule, cacheDataModule)

Agora que já criamos as dependências necessárias para o nosso repositório, criamos o nosso module Koin recebendo-as e, como explicamos anteriormente o factory, será criada uma nova instância toda vez que for requerido.

E, por fim, setamos uma variável chamada dataModule, para juntar os Koin modules e um único lugar.


Finalmente chegamos ao fim de mais um capítulo dessa série. E, embora não tenha imaginado que esta parte ficaria tão grande, acredito que foi positivo passar por todas as etapas.

Você pode conferir o código completo nesse repositório no GitHub.