MVVM, Koin, Rx, Room, Databinding e um pouco mais…
Nos dois primeiros artigos, falamos dos módulos domain e data.
- Domain
- Data
- Presentation
Presentation Module
Finalmente, chegamos no último module do nosso projeto! Para os nossos usuários, é o mais importante, pois é aqui que vamos apresentar nossos dados no app.
Conteúdo da presentation:
- View: são nossas Acitivities/Fragments, onde vamos apresentar nossos dados.
- ViewModel: é onde vamos gerenciar os dados relacionados as nossas Views, ou seja, chamar nosso repositório para consumir dados ou observar dados, por exemplo.
Diagrama de fluxo do módulo presentation:
A nossa view solicita nossa lista de jobs, para o ViewModel, que utiliza o repository para buscar os dados a serem apresentados.
Lá no início, quando criamos nosso projeto, o module app foi criado, e é ele que vamos utilizar como nosso presentation module.
Vamos começar a desenvolver o Presentation module:
A primeira coisa que faremos é adicionar as dependências necessárias ao nosso arquivo dependencies.gradle para o módulo presentation.
Adicionaremos o Koin ViewModels, para injetar os nossos ViewModels, e também Databindind e ViewModel. Em geral, o restante a fazer são coisas comuns no desenvolvimento, mas caso tenha alguma dúvida, coloco-me à disposição para tentar saná-la.
ext {
//Android Config
minSDK = 20
targetSDK = 28
compileSDK = 28
buildTools = '3.4.0-beta04'
appCompactVersion = '1.0.2'
kotlinVersion = '1.3.21'
AndroidArchVersion = '1.1.1'
databindingVersion = '3.1.4'
lifeCycleVersion = '2.0.0'
ktxVersion = '1.0.1'
constrainVersion = '1.1.3'
cardViewVersion = '1.0.0'
recyclerViewVersion = '1.0.0'
//Rx
rxJavaVersion = '2.2.7'
rxKotlinVersion = '2.2.0'
rxAndroidVersion = '2.1.1'
//Koin
koinVersion = '2.0.0-rc-1'
//Retrofit
retrofitVersion = '2.3.0'
//Gson
gsonVersion = '2.8.5'
//Room version
roomVersion = '2.1.0-alpha06'
//Test
junitVersion = '4.12'
espressoVersion = '3.1.1'
runnerVersion = '1.1.1'
dependencies = [
kotlin: "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion",
appCompact: "androidx.appcompat:appcompat:$appCompactVersion",
constraintlayout: "androidx.constraintlayout:constraintlayout:$constrainVersion",
cardView: "androidx.cardview:cardview:$cardViewVersion",
recyclerView: "androidx.recyclerview:recyclerview:$recyclerViewVersion",
viewModel: "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifeCycleVersion",
lifeCycle: "android.arch.lifecycle:extensions:$AndroidArchVersion",
dataBinding: "com.android.databinding:compiler:$databindingVersion",
ktx: "androidx.core:core-ktx:$ktxVersion",
rxJava: "io.reactivex.rxjava2:rxjava:$rxJavaVersion",
rxKotlin: "io.reactivex.rxjava2:rxkotlin:$rxKotlinVersion",
rxAndroid: "io.reactivex.rxjava2:rxandroid:$rxAndroidVersion",
koin: "org.koin:koin-android:$koinVersion",
koinViewModel: "org.koin:koin-androidx-viewmodel:$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"
]
testDependecies = [
junit: "junit:junit:$junitVersion",
espresso: "androidx.test.espresso:espresso-core:$espressoVersion",
runner: "androidx.test:runner:$runnerVersion"
]
}
E agora, vamos chamar nossas libs no gradle do módulo presentation:
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
android {
compileSdkVersion 28
defaultConfig {
applicationId "com.example.mymoduleexample"
minSdkVersion 20
targetSdkVersion 28
versionCode 1
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
dataBinding {
enabled true
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
dependencies {
implementation project(path: ':domain')
implementation project(path: ':data')
def dependencies = rootProject.ext.dependencies
def testDependencies = rootProject.ext.testDependecies
implementation dependencies.appCompact
implementation dependencies.constraintlayout
testImplementation testDependencies.junit
androidTestImplementation testDependencies.runner
androidTestImplementation testDependencies.espresso
implementation dependencies.cardView
implementation dependencies.recyclerView
implementation dependencies.kotlin
implementation dependencies.ktx
implementation dependencies.viewModel
implementation dependencies.lifeCycle
implementation dependencies.koin
implementation dependencies.koinViewModel
implementation dependencies.rxJava
implementation dependencies.rxKotlin
implementation dependencies.rxAndroid
kapt dependencies.dataBinding
}
Para utilizar o Databindig, precisamos acrescentar:
dataBinding { enable true }
O nosso módulo precisa dos outros módulos para funcionar. Por isso, os implementamos aqui.
Adicionei uma nova variável chamada testDependencies, que serve somente para organizar as dependências de test. Mas não estamos implementando testes nesse tutorial.
Aqui, vamos falar de cada package e sobre o que cada um representa e seus respectivos conteúdos:
Extensions: onde armazenamos nossas extensions, que podemos utilizar no nosso module, e, nesse caso, temos 3 classes.
- Context extensions: um conjunto de extensions relacionados ao context do Android. Mas aqui temos apenas uma, que é para mostrar um toast, e, para utilizá-la dentro de uma activity, por exemplo, precisamos apenas chamar toast(MENSAGEM). Caso queira adicionar mais alguma extension relacionada ao context, é necessário somente adicionar o método dentro dessa classe (o mesmo serve para as duas seguintes).
inline fun Context.toast(message: CharSequence, duration: Int = Toast.LENGTH_SHORT) {
Toast.makeText(this, message, duration).show()
}
- View extensions: é um conjunto de extensions relacionados a views. Aqui, temos a visible, que serve para mudar a visibilidade de alguma view. E, para utilizá-la, bastar chamar por exemplo txtName.visible(true).
fun View.visible(visible: Boolean = false) {
visibility = if (visible) View.VISIBLE else View.GONE
}
- ViewGroup extensions: um conjunto de extensions relacionados à ViewGroup. Aqui, temos um inflate, que é extremamente útil para utilizar em adapters, como, por exemplo, parent.inflate(R.layout.item_android_job), que está sendo usado no nosso AndroidJobsAdapter (mais para frente poderão checar).
fun ViewGroup.inflate(layoutId: Int, attachToRoot: Boolean = false): View {
return LayoutInflater.from(context).inflate(layoutId, this, attachToRoot)
}
- Viewmodel: aqui temos o nosso BaseViewModel, que todos as nossas classes do tipo viewmodel irão estender. E também temos nossa StateMachine, que vai gerenciar os estados das nossas chamadas do repository dentro do viewmodel.
- BaseViewModel: simplesmente estende a classe ViewModel e também é onde temos um disposables, que é um gerenciador do ciclo de vida dos Observables, os quais são as nossas chamadas do useCase (estes são do tipo Single<T>, mas poderiam ser Observables<T> ).
open class BaseViewModel: ViewModel() {
val disposables = CompositeDisposable()
override fun onCleared() {
disposables.clear()
super.onCleared()
}
}
No método onCleared(), que é chamado quando o ViewModel morre, limpamos todos os Obsevables armazenados no nosso disposables. É importante fazer isso porque, se não dermos um fim nos observables, eles podem ficar rodando e ocasionar algum leak de memória. E usamos o CompositeDisposable(), que nada mais é do que um container de disposables.
- ViewState: um gerenciador de estados, para que possamos mostrar o estado certo na view, de acordo com o que for emitido.
sealed class ViewState<out T> {
object Loading : ViewState<Nothing>()
data class Success<T>(val data: T) : ViewState<T>()
data class Failed(val throwable: Throwable) : ViewState<Nothing>()
}
class StateMachineSingle<T>: SingleTransformer<T, ViewState<T>> {
override fun apply(upstream: Single<T>): SingleSource<ViewState<T>> {
return upstream
.map {
ViewState.Success(it) as ViewState<T>
}
.onErrorReturn {
ViewState.Failed(it)
}
.doOnSubscribe {
ViewState.Loading
}
}
}
Primeiro, temos os 3 estados possíveis:
- Loading: esse estado é emitido no .doOnSubscribe, pois queremos mostrar o loader assim que nossa stream começar. Nesse estado não se precisa de nenhum dado para ser emitido, pois na criação usamos ViewState<Nothing>.
- Success: esse estado é emitido no .map, que aplica algo específico no item emitido. Nesse caso, emite o Success juntamente com o dado da stream (nossa lista de jobs).
- Failed: quando acontecer algo de errado na nossa stream, vai ser emitido o estado Failed no onErrorReturn, junto com o throwable(erro) emitido.
StateMachineSingle: vamos aplicá-la nas nossas chamadas do tipo Single, do repository, para nos emitir os estados que mencionamos acima.
Como já foi explicada cada parte da stream, vamos seguir em frente. Mais adiante, vamos entender melhor o funcionamento, mas, por enquanto, recomendo um vídeo interessante que explica detalhadamente o que fizemos.
Feature
Utilizamos essa nomenclatura para cada feature, e, no nosso caso, temos duas features/pacotes: list e main.
- Main: esta tela foi criada apenas para termos uma navegação até a nossa lista, mas vamos ver como ela funciona:
MainViewModel: aqui manipulamos os dados que utilizaremos em nossa view.
class MainViewModel: BaseViewModel() {
val showAndroidJobsLiveData = MutableLiveData<Boolean>()
val outAppLiveData = MutableLiveData<Boolean>()
fun onShowAndroidJobsRequire() {
showAndroidJobsLiveData.postValue(true)
}
fun onOutAppLiveData() {
outAppLiveData.postValue(true)
}
}
Como podemos ver, temos 2 liveDatas, das quais estamos mudando os valores nos métodos onShowAndroidJobsRequire e onOutAppRequire, que estão sendo chamados no xml. Para que isso seja possível, usamos databinding.
<?xml version="1.0" encoding="utf-8"?>
<layout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<data>
<import type="android.view.View"/>
<variable
name="viewModel"
type="com.example.mymoduleexample.feature.main.MainViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".feature.main.MainActivity">
<androidx.appcompat.widget.AppCompatButton
android:id="@+id/btnShowList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="8dp"
android:text="@string/show_list"
android:onClick="@{() -> viewModel.onShowAndroidJobsRequire()}"
app:layout_constraintBottom_toTopOf="@+id/btnOut"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.501"/>
<TextView
android:id="@+id/btnOut"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:text="@string/out"
android:gravity="center"
android:onClick="@{() -> viewModel.onOutAppLiveData()}"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.5"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/btnShowList"/>
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
Esse é o layout da nossa view. Agora, vamos explicar como o vinculamos ao nosso ViewModel com DataBinding.
Para utilizar o nosso MainViewModel em nosso layout, primeiro, precisamos colocar todo nosso layout dentro do bloco layout;
Em seguida, criamos um bloco data – onde colocaremos nossa variável que pode interagir dentro do xml (que, nesse caso, é o nosso viewmodel) – e setamos o name e, depois, o type, que na verdade é a referência da classe que utilizaremos.
Por fim, podemos utilizar o viewmodel.onShowAndroidJobsRequire e viewmodel.onOutAppLiveData, nos onClicks do Button e textView.
E, toda vez que clicarmos nessas views, o método vinculado no onClick será invocado no viewmodel.
MainActivity: aqui está nossa view, que utiliza o MainViewModel e o layout acima:
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModel()
private lateinit var binding: ActivityMainBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_main)
binding.viewModel = viewModel
binding.lifecycleOwner = this
setupViewModel()
}
private fun setupViewModel() {
viewModel.showAndroidJobsLiveData.observe(this, Observer { isShow ->
when(isShow) {
true -> { startActivity(AndroidJobsListActivity.launchIntent(this)) }
}
})
viewModel.outAppLiveData.observe(this, Observer { isOut ->
when(isOut) {
true -> { finish() }
}
})
}
}
Inicialmente, temos o ViewModel injetado na view e, para isso, estamos utilizando o Koin, pois com o simples by viewModel() temos a referência do viewmodel.
Como estamos usando DataBinding, precisamos vinculá-lo a nossa View. Para isso, criamos uma variável lateinit var databinding, pois garantimos que ele sempre será inicializado.
No método onCreate, primeiro setamos o layout da activity no DataBinding para vincular as referências do layout; depois, setamos o ViewModel no DataBinding; e, por fim, setamos o lifecycle owner ao databindng para que possamos escutar as mudanças dos LiveDatas do viewmodel.
Agora, fazemos o setup dos observers dos LiveDatas do nosso ViewModel. E aqui vai uma coisa interessante que o Koin faz para nós, pois, normalmente, temos que fazer o seguinte para inicializar o ViewModel:
ViewModelProviders.of(this).get(MyViewModel::class.java)
Mas, utilizando o Koin, ele faz isso para nós, e não precisamos mais repetir tal procedimento em toda a nova View que vamos criar para inicializar nossos Viewmodels.
E, por fim, mas não menos importante, vamos observar os eventos dos nossos LiveDatas, que são acionados nos clicks de cada botão da view. Os dois esperam receber um true para fazer alguma coisa: showAndroidJobsLiveData vai mudar para AndroidJobListActivity e o outLiveData simplesmente fecha o app. O fluxo onde essas ações acontecem é o seguinte:
button Click — > onShowAndroidJobsRequire() — > showAndroidJobsLiveData.postValue(true) — > startActivity
- List: tudo que fizemos até aqui nos últimos artigos e no conteúdo acima é para que possamos finalmente mostrar nossa lista de android jobs. Depois de vermos bastante conteúdo, agora vamos ver como tudo fez sentido.
AndroidJobListViewModel: Lembra do useCase que criamos lá no primeiro artigo? Então, aqui está ele, no construtor do viewmodel, e também temos o uiScheduler, que será onde vamos observar o resultado do nosso useCase.
class AndroidJobListViewModel(
val useCase: GetJobsUseCases,
val uiScheduler: Scheduler
): BaseViewModel() {
val state = MutableLiveData<ViewState<List<AndroidJob>>>().apply {
value = ViewState.Loading
}
fun getJobs(forceUpdate: Boolean = false) {
disposables += useCase.execute(forceUpdate = forceUpdate)
.compose(StateMachineSingle())
.observeOn(uiScheduler)
.subscribe(
{
//onSuccess
state.postValue(it)
},
{
//onError
}
)
}
fun onTryAgainRequired() {
getJobs(forceUpdate = true)
}
}
Primeiro temos o LiveData da nossa StateMachine, e inicialmente o nosso estado será Loading.
O primeiro método getJobs é o local onde chamamos o useCase para nos fornecer a lista de jobs que estamos buscando. O simples useCase.execute, executa tudo o que já foi criado nos dois artigos anteriores.
Como podemos ver, foi adicionado um compose com o StateMachineSingle(), que vai aplicar alguma função de transformação. No nosso caso, será emitir os StateMachines em nossa chamada do useCase(cmd + f ViewState, em caso de dúvida).
Depois adicionamos o nosso uiScheduler, no observerOn, ou seja, vamos observar na ui thread. Por fim, esperamos o resultado final no subscribe e, assim que o recebermos, setamos seu valor no nosso LiveData. A segunda chave está vazia, pois na StateMachineSingle já retornamos o estado de erro.
E, para quem não sabe quais são os estados das duas funções abertas no subscribe, a primeira é onSuccess, e a segunda é onError.
Antes de irmos para a view “final”, vamos mostrar rapidamente o nosso adapter.
AndroidJobsAdapter: simplesmente nosso adapter, onde setamos cada job da nossa lista na view.
class AndroidJobsAdapter: RecyclerView.Adapter<AndroidJobsAdapter.ViewHolder>() {
var jobs: List<AndroidJob> = listOf()
inner class ViewHolder(parent: ViewGroup): RecyclerView.ViewHolder(parent.inflate(R.layout.item_android_job)) {
fun bind(androidJob: AndroidJob) = with(itemView) {
txtTitle.text = androidJob.title
txtCountry.text = androidJob.country
txtYears.text = androidJob.experienceTimeRequired
chkNative.isChecked = androidJob.native
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder = ViewHolder(parent)
override fun getItemCount(): Int = jobs.size
override fun onBindViewHolder(holder: ViewHolder, position: Int) = holder.bind(jobs[position])
}
Não vamos aprofundar como funciona um adapter, pois provavelmente você já fez alguma lista nessa vida (caso não tenha feito, confira a documentação).
Um ponto relevante aqui é que usamos a extension inflate para inflar o layout. E, por fim, setamos cada item do layout com nossas infos da entidade androidJob da lista.
AndroidJobsListActivity: temos, enfim, nossa tela que vai mostrar a nossa lista de jobs.
class AndroidJobsListActivity: AppCompatActivity() {
private val viewModel: AndroidJobListViewModel by viewModel()
private val androidJobAdapter: AndroidJobsAdapter by inject()
private lateinit var binding: ActivityAndroidJobsListBinding
companion object {
fun launchIntent(context: Context): Intent {
return Intent(context, AndroidJobsListActivity::class.java)
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_android_jobs_list)
binding.viewModel = viewModel
binding.lifecycleOwner = this
setupView()
setupRecyclerView()
setupViewModel()
}
private fun setupView() {
setSupportActionBar(binding.toolbar);
binding.toolbar.setNavigationIcon(R.drawable.abc_ic_ab_back_material)
binding.toolbar.setNavigationOnClickListener{
finish()
}
}
private fun setupViewModel() {
viewModel.getJobs()
viewModel.state.observe(this, Observer { state ->
when(state) {
is ViewState.Success -> {
androidJobAdapter.jobs = state.data
setVisibilities(showList = true)
}
is ViewState.Loading -> {
setVisibilities(showProgressBar = true)
}
is ViewState.Failed -> {
setVisibilities(showError = true)
}
}
})
}
private fun setupRecyclerView() = with(binding.recyclerView) {
layoutManager = LinearLayoutManager(context)
adapter = androidJobAdapter
}
private fun setVisibilities(showProgressBar: Boolean = false, showList: Boolean = false, showError: Boolean = false) {
binding.progressBar.visible(showProgressBar)
binding.recyclerView.visible(showList)
binding.btnTryAgain.visible(showError)
}
}
Primeiro, injetamos o viewmodel e nosso adapter (no próximo tópico, mostrarei onde estão sendo providas essas injeções).
Temos um método lauchIntent, para que outras views possam inicializar essa utilizando esse método, como fizemos na view anterior.
Setamos o databinding, como fizemos na tela anterior.
Temos uma actionbar e setamos ela no método setupView, inclusive a ação de back, dentro do setNavigationOnClickListener, que vai fechar a activity.
Estamos utilizando binding.toolbar, pois no binding temos acesso às views do nosso layout.
Temos nosso setupViewModel, onde chamamos nosso viewmodel para nos fornecer nossos dados, e, em seguida, temos nosso liveDataObserver, para cada state.
- Success: setamos a lista no adapter e deixamos o recyclerView visível, e o resto invisível.
- Loading: mostramos um progressBar, e escondemos o resto.
- Failed: mostramos um botão de try again, para caso algo dê errado, e escondemos as outras views.
Também fazemos o setup do recycler view setando seu adapter com o nosso androidJobAdapter, que foi injetado.
E, por fim, temos um método para trocar as visibilidades das views que temos no layout utilizando a extension visible.
Di (dependency injection)
Aqui ficam nossos modules Koin, que gerenciam nossas dependências.
- PresentationModule: aqui provemos tudo de que nossa view e os viewmodels precisam para funcionar.
val presentationModule = module {
factory { AndroidJobsAdapter() }
viewModel { MainViewModel() }
viewModel { AndroidJobListViewModel(
useCase = get(),
uiScheduler = AndroidSchedulers.mainThread()
)
}
}
Primeiro provemos o nosso adapter, que foi injetado na AndroidJobsListActivity (factory: toda vez quando requerido, cria uma nova instância).
private val androidJobAdapter: AndroidJobsAdapter by inject()
Em seguida, algo novo nos nossos modules Koin: os ViewModels. Nada mais é do que uma DSL extension para os nossos ViewModels, a fim de que eles possam ser injetados posteriormente nas nossas Views, como fizemos anteriormente:
private val viewModel: AndroidJobListViewModel by viewModel()
private val viewModel: MainViewModel by viewModel()
Como nossa mainViewModel, não é necessária nenhuma outra dependência; somente a provemos sem nada no construtor. Mas, para o AndroidJobListViewModel, precisamos passar o useCase (provido na DomainModule.kt) e AndroidSchedulers.mainThread (main thread do android).
Mas como esses módulos vão funcionar???
Precisamos fazer o startKoin na classe Application do projeto:
class MyModuleApplication: Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyModuleApplication)
modules(domainModule + dataModules + listOf(presentationModule))
}
}
}
Chamamos o startkoin e, primeiro, provemos o contexto e, em seguida, todos os koin Modules que criamos no projeto.
Obs: Não esqueça de setar a classe Application no manifest
<application android:name=”.MyModuleApplication”
SHOW ME:
Bem, galera, chegamos ao fim da primeira parte da série, pois pretendo fazer mais artigos em cima desse projeto, com testes e algumas coisas a mais.
Ficou com alguma dúvida? Comente aqui embaixo que terei o prazer de responder ou me envie um email.