Chapters

Hide chapters

Advanced Android App Architecture

First Edition · Android 9 · Kotlin 1.3 · Android Studio 3.2

Before You Begin

Section 0: 3 chapters
Show chapters Hide chapters

18. MVI Sample
Written by Aldo Olivares

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

In the previous chapter you learned all the theory behind the Model-View-Intent architecture pattern. You learned how MVI works and how each of its layers interact with each other.

Now you are going to use your new knowledge to rebuild the movies app using MVI. Along the way, you will also learn:

  • How to create an RxJava Observable from scratch.
  • How to create RxJava Observables from previously created objects with methods such as just().
  • How to use a PublishSubject.
  • How to use a Disposable and a CompositeDisposable.
  • How to integrate Room with RxJava.
  • How to integrate Retrofit with RxJava.

Ready? Lets get started.

Getting started

Start by opening the starter project for this chapter. The project contains the following packages:

  • data: contains the Room database components such as your DAOS and your MovieDatabase, your Models and your RetrofitClient.
  • domain: contains the MovieState class which you will use to represent the state of your App.
  • view: contains the activities and adapters.

Take some time to familiarize yourself with the code because you will be spending a lot of time with each file during this chapter.

Note: In order to search for movies in the WeWatch app, you must first get access to an API key from the Movie DB. To get your API own key, sign up for an account at www.themoviedb.org. Then, navigate to your account settings on the website, view your settings for the API, and register for a developer API key. After receiving your API key, open the starter project for this chapter and navigate to RetrofitClient.kt. There, you can replace the existing value for API_KEY with your own.

Build and Run the App to verify that everything is working properly.

Right now it is just an empty canvas, but that is about to change.

Going Reactive

One of the advantages of working with popular libraries developed by reliable sources such as Google is that they often include support for other popular libraries from the Android community such as RxJava.

implementation "com.squareup.retrofit2:adapter-rxjava2:$retrofitVersion"
implementation "androidx.room:room-rxjava2:$roomVersion"
val retrofit = Retrofit.Builder()
    .baseUrl(TMDB_BASE_URL)
    .addConverterFactory(GsonConverterFactory.create())
    .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
    .client(okHttpClient)
    .build()
@GET("search/movie")
fun searchMovie(@Query("api_key") api_key: String, @Query("query") q: String): Observable<MoviesResponse>
fun searchMovies(query: String): Observable<MoviesResponse> {
  return moviesApi.searchMovie(API_KEY, query)
}
//1
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(movie: Movie): Single<Long>
//2
@Query("select * from movie")
fun getAll(): Observable<List<Movie>>
//3
@Delete
fun delete(movie: Movie): Completable

Creating Interactors and State

Room and Retrofit are now returning Observable callbacks, but they are only emitting plain old objects such as Movie or Long. In the last chapter you learned that one of the main concepts of MVI is that you need to represent the State of your App such as Loading, Error or Data based on your models. You will use an Interactor as a way to interact with Retrofit and Room and to transform the responses into the corresponding State.

sealed class MovieState {
  object LoadingState : MovieState() //1
  data class DataState(val data: List<Movie>) : MovieState() //2
  data class ErrorState(val data: String) : MovieState() //3
  data class ConfirmationState(val movie: Movie) : MovieState() //4
  object FinishState : MovieState() //5
}
class MovieInteractor : Interactor
private val retrofitClient = RetrofitClient()
private val movieDao = db.movieDao()
//1
override fun getMovieList(): Observable<MovieState> {
  return movieDao.getAll()
      .map<MovieState> { MovieState.DataState(it) }
      .onErrorReturn {
        MovieState.ErrorState("Error")
      }
}
//2
override fun deleteMovie(movie: Movie): Observable<Unit> = movieDao.delete(movie).toObservable()
//3
override fun searchMovies(title: String): Observable<MovieState> = retrofitClient.searchMovies(title)
    .observeOn(Schedulers.io())
    .map<MovieState> { it.results?.let { MovieState.DataState(it) } }
    .onErrorReturn { MovieState.ErrorState("Error") }
//4
override fun addMovie(movie: Movie): Observable<MovieState> = movieDao.insert(movie)
    .map<MovieState> {
      MovieState.FinishState
    }.toObservable()

Creating the Presenters

To connect the View with an Interactor you are going to need a Presenter for each of your activities.

Creating the MainPresenter

Create a new package under the root directory named presenter. Inside this package create a new Kotlin class named MainPresenter.

class MainPresenter(private val movieInteractor: MovieInteractor)
private lateinit var view: MainView
private val compositeDisposable = CompositeDisposable()
//1
fun bind(view: MainView) {
  this.view = view
  compositeDisposable.add(observeMovieDeleteIntent())
  compositeDisposable.add(observeMovieDisplay())
}
//2
fun unbind() {
  if (!compositeDisposable.isDisposed) {
    compositeDisposable.dispose()
  }
}
//3
private fun observeMovieDeleteIntent() = view.deleteMovieIntent()
    .subscribeOn(AndroidSchedulers.mainThread())
    .observeOn(Schedulers.io())
    .flatMap<Unit> { movieInteractor.deleteMovie(it) }
    .subscribe()
//4
private fun observeMovieDisplay() = movieInteractor.getMovieList()
    .observeOn(AndroidSchedulers.mainThread())
    .doOnSubscribe { view.render(MovieState.LoadingState) }
    .doOnNext { view.render(it) }
    .subscribe()

Creating the AddPresenter

Create a new class under the presenter package and name it AddPresenter. Replace everything inside with the following:

class AddPresenter(private val movieInteractor: MovieInteractor) {
  //1
  private val compositeDisposable = CompositeDisposable()
  private lateinit var view: AddView
  //2
  fun bind(view: AddView) {
    this.view = view
    compositeDisposable.add(observeAddMovieIntent())
  }
  //3
  fun unbind() {
    if (!compositeDisposable.isDisposed) {
      compositeDisposable.dispose()
    }
  }
  //
  fun observeAddMovieIntent() = view.addMovieIntent()
      .observeOn(Schedulers.io())
      .flatMap<MovieState> { movieInteractor.addMovie(it) }
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe { view.render(it) }
}

Creating the SearchPresenter

Create a new class under the presenter package and name it SearchPresenter. Replace everything inside with the following:

class SearchPresenter(private val movieInteractor: MovieInteractor) {
  //1
  private val compositeDisposable = CompositeDisposable()
  private lateinit var view: SearchView
  //2
  fun bind(view: SearchView) {
    this.view = view
    compositeDisposable.add(observeMovieDisplayIntent())
    compositeDisposable.add(observeAddMovieIntent())
    compositeDisposable.add(observeConfirmIntent())
  }
  //3
  fun unbind() {
    if (!compositeDisposable.isDisposed) {
      compositeDisposable.dispose()
    }
  }
  //4
  private fun observeConfirmIntent() = view.confirmIntent()
      .observeOn(Schedulers.io())
      .flatMap<MovieState> { movieInteractor.addMovie(it) }
      .observeOn(AndroidSchedulers.mainThread())
      .subscribe { view.render(it) }

  //5
  private fun observeAddMovieIntent() = view.addMovieIntent()
      .map<MovieState> { MovieState.ConfirmationState(it) }
      .subscribe { view.render(it) }

  //6
  private fun observeMovieDisplayIntent() = view.displayMoviesIntent()
      .flatMap<MovieState> { movieInteractor.searchMovies(it) }
      .subscribeOn(Schedulers.io())
      .observeOn(AndroidSchedulers.mainThread())
      .doOnSubscribe { view.render(MovieState.LoadingState) }
      .subscribe { view.render(it) }
}

Creating the Views

Now that the Presenter layer of your MVI architecture is ready, creating the Views should be a piece of cake :]

Creating the MainView

Open the MainActivity.kt file under the view/activity package. Right now this is an empty activity with a toolbar and a simple method to navigate to the AddMovieActivity once the user presses the + button, but that is about to change.

class MainActivity : BaseActivity(), MainView {
interface MainView {
  fun render(state: MovieState)
  fun deleteMovieIntent(): Observable<Movie>
}
private lateinit var presenter: MainPresenter
presenter = MainPresenter(MovieInteractor())
presenter.bind(this)
//1
val observable = Observable.create<Movie> { emitter ->
  //2
  val helper = ItemTouchHelper(
      object : ItemTouchHelper.SimpleCallback(
          0,
          ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
      ) {
        //3
        override fun onMove(
            recyclerView: RecyclerView,
            viewHolder: RecyclerView.ViewHolder,
            target: RecyclerView.ViewHolder
        ): Boolean {
          return false
        }
        //4
        override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
          val position = viewHolder.getAdapterPosition()
          val movie = (moviesRecyclerView.adapter as MovieListAdapter).getMoviesAtPosition(position)
          emitter.onNext(movie)
        }
      })
  //5
  helper.attachToRecyclerView(moviesRecyclerView)
}
return observable
when (state) {
  is MovieState.LoadingState -> renderLoadingState()
  is MovieState.DataState -> renderDataState(state)
  is MovieState.ErrorState -> renderErrorState(state)
}
//1
private fun renderLoadingState() {
  moviesRecyclerView.isEnabled = false
  progressBar.visibility = View.VISIBLE
}
//2
private fun renderDataState(dataState: MovieState.DataState) {
  progressBar.visibility = View.GONE
  moviesRecyclerView.apply {
    isEnabled = true
    (adapter as MovieListAdapter).setMovies(dataState.data)
  }
}
//3
private fun renderErrorState(dataState: MovieState.ErrorState) = longToast(dataState.data)
override fun onStop() {
  presenter.unbind()
  super.onStop()
}

Creating the AddView

Open the AddMovieActivity.kt file under the view/activity package.

interface AddView {
    fun render(state: MovieState)
    fun addMovieIntent(): Observable<Movie>
}
private lateinit var presenter: AddPresenter
presenter = AddPresenter(MovieInteractor())
presenter.bind(this)
when (state) {
  is MovieState.FinishState -> renderFinishState()
}
private fun renderFinishState() = startActivity(intentFor<MainActivity>().newTask().clearTask())
private val publishSubject: PublishSubject<Movie> = PublishSubject.create()
fun addMovieClick(view: View) {
  if (titleEditText.text.toString().isNotBlank()) {
    publishSubject.onNext(Movie(title = titleEditText.text.toString(), releaseDate = yearEditText.text.toString()))
  } else {
    showMessage("You must enter a title")
  }
}
override fun addMovieIntent() = publishSubject
override fun onStop() {
  super.onStop()
  presenter.unbind()
}

Creating the SearchActivity

Open the SearchMovieActivity.kt file under the view/activity package. Make your SearchMovieActivity class implement the SearchView interface and implement all the missing members.

private lateinit var presenter: SearchPresenter
private val publishSubject: PublishSubject<Movie> = PublishSubject.create<Movie>()
presenter = SearchPresenter(MovieInteractor())
presenter.bind(this)
when (state) {
  is MovieState.LoadingState -> renderLoadingState()
  is MovieState.DataState -> renderDataState(state)
  is MovieState.ErrorState -> renderErrorState(state)
  is MovieState.ConfirmationState -> renderConfirmationState(state)
  is MovieState.FinishState -> renderFinishState()
}
//1
private fun renderFinishState() = startActivity(intentFor<MainActivity>().newTask().clearTask())
//2
private fun renderLoadingState() {
  searchRecyclerView.isEnabled = false
  searchProgressBar.visibility = View.VISIBLE
}
//3
private fun renderConfirmationState(confirmationState: MovieState.ConfirmationState) {
  searchLayout.snack("Add ${confirmationState.movie.title} to your list?", Snackbar.LENGTH_LONG) {
    action(getString(R.string.ok)) {
      publishSubject.onNext(confirmationState.movie)
    }
  }
}
//4
private fun renderDataState(dataState: MovieState.DataState) {
  searchProgressBar.visibility = View.GONE
  searchRecyclerView.apply {
    isEnabled = true
    (adapter as SearchListAdapter).setMovies(dataState.data)
  }
}
//5
private fun renderErrorState(errorState: MovieState.ErrorState) {
  searchProgressBar.visibility = View.GONE
  longToast(errorState.data)
}
//1
override fun displayMoviesIntent(): Observable<String> = Observable.just(intent.extras.getString("title"))

//2
override fun addMovieIntent(): Observable<Movie> = (searchRecyclerView.adapter as SearchListAdapter).getViewClickObservable()

//3
override fun confirmIntent(): Observable<Movie> = publishSubject
override fun onStop() {
  super.onStop()
  presenter.unbind()
}

Final thoughts

Take a closer look at all your Activities. As you can see, there is only one render() method that receives the current State from the Presenter and multiple Intents that represent the actions taken in your UI.

Key points

Where to go from here?

For didactic purposes, you created most of your Observables for this App from scratch, but there are many libraries out there that help you create Observables from almost any kind of object.

Observable.create { emitter ->
  button.setOnClickListener {
    emitter.onNext(queryEditText.text.toString())
  }
}
button.clicks()
val list = listOf("Alpha", "Beta", "Gamma", "Delta", "Epsilon")
list.toObservable() // extension function for Iterables
    .filter { it.length >= 5 }
    .subscribeBy(  // named arguments for lambda Subscribers
            onNext = { println(it) },
            onError =  { it.printStackTrace() },
            onComplete = { println("Done!") }
    )
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now