Impulsojunte-se à Impulso

19/11/19

15 min de leitura

Modularização Android Parte 3

Iago FucoloIago Fucolo

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.

Nós usamos cookies para melhorar sua experiência no site. Ao aceitar, você concorda com nossa Política de Privacidade

Assine nossa newsletter

Toda semana uma News com oportunidades de trabalho, conteúdos selecionados, eventos importantes e novidades sobre o Mundo da Tecnologia.

Pronto, em breve você vai receber novidades 👍