Home Android & Kotlin Books Advanced Android App Architecture

15
VIPER 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.

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

In the last chapter, you learned the theory behind VIPER. You learned how all of its layers work and why this architecture is an excellent option for building maintainable and scalable Android apps.

In this chapter, you’ll apply that knowledge to rebuild the WeWatch app using VIPER. Specifically, you’ll learn how to:

  • Create a Presenter that acts as a bridge between all of the components in your app.
  • Create an Interactor that communicates with your app’s backend.
  • Create a Router that handles the navigation between your Views.
  • Use Cicerone to implement your Routers.
  • Use Interfaces as contracts to implement the layers of VIPER.

Getting started

Using Android Studio, open the starter project for this chapter project by going to File ▸ New ▸ Import Project and selecting build.gradle in the root of the project.

The starter project contains the basic structure you’ll use for this app, and it contains the following packages:

  • Data: All of the backend code that your app needs to work correctly, including the Entities, a Repository and the Room database components.
  • Interactor: The interactors of your app. Package should currently be empty.
  • View: The activities, fragments and adapters.
  • Presenter: The Presenters of your app. Package should currently be empty.

Note: If you don’t see the interactor or presenter packages, then go ahead and just add them to the project by right clicking on the com.raywenderlich.wewatch package and selecting New ▸ Package.

For didactic purposes, this code was organized with a structure according to VIPER layer names, but in a real-world app you often want to structure your project in packages according to the different modules in your app such as MainModule, AddModule or SearchModule.

Take some time to familiarize yourself with the rest of the starter project and the features included out-of-the-box, like the adapters and layouts.

Once the starter project finishes loading and building, run the app on a device or emulator.

Currently, it’s an empty canvas, but that’s about to change!

Note: Before moving on to the next section, remember to get an API Key from TMDB API (https://developers.themoviedb.org/3/getting-started/introduction) and to substitute the value inside RetrofitClient.kt.

Defining your app’s contract

For this project, every module is represented as an Interface contract that needs to be implemented by several associated classes. The contract represents which VIPER layers you must implement in every module and the actions the layers need to perform.

interface MainContract {

  interface View {
    fun showLoading()
    fun hideLoading()
    fun showMessage(msg: String)
    fun displayMovieList(movieList: List<Movie>)
    fun deleteMoviesClicked()
  }

  interface Presenter {
    fun deleteMoviesClick(selectedMovies: HashSet<*>)
    fun onViewCreated()
    fun onDestroy()
    fun addMovieClick()
  }

  interface Interactor {
    fun loadMovieList(): LiveData<List<Movie>>
    fun delete(movie: Movie)
    fun getAllMovies()
  }

  interface InteractorOutput {
    fun onQuerySuccess(data: List<Movie>)
    fun onQueryError()
  }
}

Implementing the Main Module

Now that you have the contract defined for the main module, it’s time to apply the contract to each individual component. You’ll start with the View.

The View

Open MainActivity.kt inside view/activities and make it so MainActivity implements MainContract.View:

class MainActivity : BaseActivity(), MainContract.View {
override fun showLoading() {
  moviesRecyclerView.isEnabled = false
  progressBar.visibility = View.VISIBLE
}

override fun hideLoading() {
  moviesRecyclerView.isEnabled = true
  progressBar.visibility = View.GONE
}
toast(msg)
lateinit var presenter: MainContract.Presenter
private lateinit var adapter: MovieListAdapter
adapter = MovieListAdapter(movieList)
moviesRecyclerView.adapter = adapter
presenter.deleteMovies(adapter.selectedMovies)
presenter.addMovie()
override fun onResume() {
  super.onResume()
  presenter.onViewCreated()
}

override fun onDestroy() {
  super.onDestroy()
  presenter.onDestroy()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
  when (item.itemId) {
    R.id.action_delete -> this.deleteMoviesClicked()
    else -> toast(getString(R.string.error))
  }
  return super.onOptionsItemSelected(item)
}

The Router

As you learned in the previous chapter, the Router layer is in charge of the navigation across the multiple Views in your app. In a traditional VIPER implementation you would have a Presenter that receives actions from the View that are then translated as commands for the Interactor or the Router.

def ciceroneVersion = "2.1.0"
implementation "ru.terrakok.cicerone:cicerone:$ciceroneVersion"
lateinit var cicerone: Cicerone<Router>

private fun initCicerone() {
  this.cicerone = Cicerone.create()
}
this.initCicerone()
//1
companion object {
  val TAG: String = "MainActivity"
}
//2
private val navigator: Navigator? by lazy {
  object : Navigator {
    //3
    override fun applyCommand(command: Command) {   // 2
      if (command is Forward) {
        forward(command)
      }
    }
    //4
    private fun forward(command: Forward) {
      when (command.screenKey) {
        AddMovieActivity.TAG -> startActivity(Intent(this@MainActivity, AddMovieActivity::class.java))
        else -> Log.e("Cicerone", "Unknown screen: " + command.screenKey)
      }
    }
  }
}
App.INSTANCE.cicerone.navigatorHolder.setNavigator(navigator)
App.INSTANCE.cicerone.navigatorHolder.removeNavigator()
private val router: Router? by lazy { App.INSTANCE.cicerone.router }

The Presenter

Create a new package named presenter, and then add a new file named MainPresenter.kt. Replace everything inside with the following:

//1
class MainPresenter(private var view: MainContract.View?,
                    private var interactor: MainContract.Interactor?,
                    private val router: Router?) : MainContract.Presenter, MainContract.InteractorOutput {
  //2
  override fun addMovie() {
    router?.navigateTo(AddMovieActivity.TAG)
  }
  //3
  override fun deleteMovies(selectedMovies: HashSet<*>) {
    for (movie in selectedMovies) {
      interactor?.delete(movie as Movie)
    }
  }
  //4
  override fun onViewCreated() {
    view?.showLoading()
    interactor?.loadMovieList()?.observe((view as MainActivity), Observer { movieList ->
      if (movieList != null) {
        onQuerySuccess(movieList)
      } else {
        onQueryError()
      }
    })
  }
  //5
  override fun onDestroy() {
    view = null
    interactor = null
  }
  //6
  override fun onQuerySuccess(data: List<Movie>) {
    view?.hideLoading()
    view?.displayMovieList(data)
  }
  //7
  override fun onQueryError() {
    view?.hideLoading()
    view?.showMessage("Error Loading Data")
  }
}

The Interactor

Next, you need to implement the Interactor for the Main Module.

//1
class MainInteractor : MainContract.Interactor {
  //2
  private val movieList = MediatorLiveData<List<Movie>>()
  private val repository: MovieRepositoryImpl = MovieRepositoryImpl()
  //3
  init {
    getAllMovies()
  }
  //4
  override fun loadMovieList() = movieList
  //5
  override fun delete(movie: Movie) = repository.deleteMovie(movie)
  //6
  override fun getAllMovies() {
    movieList.addSource(repository.getSavedMovies()) { movies ->
      movieList.postValue(movies)
    }
  }
}
companion object {
  val TAG: String = "AddMovieActivity"
}
companion object {
  val TAG: String = "SearchMovieActivity"
}
presenter = MainPresenter(this, MainInteractor(), router)
presenter.onViewCreated()

Implementing the AddMovie module

With the main module out the way, it’s time to apply the same architecture to the add movie module. You’ll again start with the View. Be sure to review the contract for the add movie module in the AddContract.kt file found at the root package.

The View

Open AddMovieActivity.kt inside view/activities, and make AddMovieActivity implement AddContract.View:

class AddMovieActivity : BaseActivity(), AddContract.View {
var presenter: AddContract.Presenter? = null
private val router: Router? by lazy { App.INSTANCE.cicerone.router }

private val navigator: Navigator? by lazy {
  object : Navigator {
    override fun applyCommand(command: Command) {
      if (command is Back) {
        back()
      }
      if (command is Forward) {
        forward(command)
      }
    }

    private fun forward(command: Forward) {
      when (command.screenKey) {
        SearchMovieActivity.TAG -> startActivity(Intent(this@AddMovieActivity, SearchMovieActivity::class.java)
            .putExtra("title", titleEditText.text.toString()))
        MainActivity.TAG -> startActivity(Intent(this@AddMovieActivity, MainActivity::class.java)
            .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK))
        else -> Log.e("Cicerone", "Unknown screen: " + command.screenKey)
      }
    }

    private fun back() {
      finish()
    }
  }
}
addLayout.snack((msg), Snackbar.LENGTH_LONG) {
  action(getString(R.string.ok)) {
  }
}
presenter?.addMovies(titleEditText.text.toString(), yearEditText.text.toString())
presenter?.searchMovies(titleEditText.text.toString())
override fun onResume() {
  super.onResume()
  App.INSTANCE.cicerone.navigatorHolder.setNavigator(navigator)
}

override fun onDestroy() {
  super.onDestroy()
  presenter?.onDestroy()
}

override fun onPause() {
  super.onPause()
  App.INSTANCE.cicerone.navigatorHolder.removeNavigator()
}

The Presenter

Create a new file inside the presenter package, name it AddPresenter.kt and replace everything inside with the following:

//1
class AddPresenter(private var view: AddContract.View?,
                   private var interactor: AddContract.Interactor?,
                   private val router: Router?) : AddContract.Presenter {
  //2
  override fun onDestroy() {
    view = null
    interactor = null
  }
  //3    
  override fun addMovies(title: String, year: String) {
    if (title.isNotBlank()) {
      val movie = Movie(title = title, releaseDate = year)
      interactor?.addMovie(movie)
      router?.navigateTo(MainActivity.TAG)
    } else {
      view?.showMessage("You must enter a title")
    }
  }
  //4
  override fun searchMovies(title: String) {
    if (title.isNotBlank()) {
      router?.navigateTo(SearchMovieActivity.TAG)
    } else {
      view?.showMessage("You must enter a title")
    }
  }
}

The Interactor

Create a new file inside interactor, name it AddInteractor and replace everything inside with the following:

class AddInteractor : AddContract.Interactor {

  private val repository: MovieRepositoryImpl = MovieRepositoryImpl()

  override fun addMovie(movie: Movie) = repository.saveMovie(movie)

}
presenter = AddPresenter(this, AddInteractor(), router)

Implementing SearchMovie

There’s only one module remaining: SearchMovie.

The View

Open SearchMovieActivity.kt inside view/activities and make SearchMovieActivity implement SearchContract.View:

class SearchMovieActivity : BaseActivity(), SearchContract.View {
private var presenter: SearchContract.Presenter? = null
private val router: Router? by lazy { App.INSTANCE.cicerone.router }

private val navigator: Navigator? by lazy {
  object : Navigator {
    override fun applyCommand(command: Command) {
      if (command is Back) {
        back()
      }
      if (command is Forward) {
        forward(command)
      }
    }

    private fun forward(command: Forward) {
      when (command.screenKey) {
        MainActivity.TAG -> startActivity(Intent(this@SearchMovieActivity, MainActivity::class.java)
            .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK))
        else -> Log.e("Cicerone", "Unknown screen: " + command.screenKey)
      }
    }

    private fun back() {
      finish()
    }
  }
}
override fun showLoading() {
  searchProgressBar.visibility = View.VISIBLE
  searchRecyclerView.isEnabled = false
}

override fun hideLoading() {
  searchProgressBar.visibility = View.GONE
  searchRecyclerView.isEnabled = true
}
searchLayout.snack(getString(R.string.network_error), Snackbar.LENGTH_INDEFINITE) {
  action(getString(R.string.ok)) {
    val title = intent.extras.getString("title")
    presenter?.searchMovies(title)
  }
}
adapter = SearchListAdapter(movieList) { movie -> presenter?.movieClicked(movie) }
searchRecyclerView.adapter = adapter
searchLayout.snack("Add ${movie?.title} to your list?", Snackbar.LENGTH_LONG) {
  action(getString(R.string.ok)) {
    presenter?.addMovieClicked(movie)
  }
}
override fun onResume() {
  super.onResume()
  val title = intent.extras.getString("title")
  presenter?.searchMovies(title)
  App.INSTANCE.cicerone.navigatorHolder.setNavigator(navigator)
}

override fun onPause() {
  super.onPause()
  App.INSTANCE.cicerone.navigatorHolder.removeNavigator()
}

override fun onDestroy() {
  super.onDestroy()
  presenter?.onDestroy()
}

The Presenter

Create a new file inside presenter, name it SearchPresenter.kt and replace everything inside with the following:

//1
class SearchPresenter(private var view: SearchContract.View?, private var interactor: SearchContract.Interactor?, val router: Router?) : SearchContract.Presenter, SearchContract.InteractorOutput {
  //2
  override fun searchMovies(title: String) {
    view?.showLoading()
    interactor?.searchMovies(title)?.observe(view as SearchMovieActivity, Observer { movieList ->
      if (movieList == null) {
        onQueryError()
      } else {
        onQuerySuccess(movieList)
      }
    })
  }
  //3
  override fun addMovieClicked(movie: Movie?) {
    interactor?.addMovie(movie)
    router?.navigateTo(MainActivity.TAG)
  }
  //4
  override fun movieClicked(movie: Movie?) {
    view?.displayConfirmation(movie)
  }
  //5
  override fun onDestroy() {
    view = null
    interactor = null
  }
  //6
  override fun onQuerySuccess(data: List<Movie>) {
    view?.hideLoading()
    view?.displayMovieList(data)
  }
  //7
  override fun onQueryError() {
    view?.hideLoading()
    view?.showMessage("Error")
  }
}

The Interactor

Create a new file inside interactor, name it SearchInteractor.kt and replace everything inside with the following:

//1
class SearchInteractor : SearchContract.Interactor {
  //2
  private val repository: MovieRepositoryImpl = MovieRepositoryImpl()
  //3
  override fun searchMovies(title: String): LiveData<List<Movie>?> = repository.searchMovies(title)
  //4
  override fun addMovie(movie: Movie?) {
    movie?.let {
      repository.saveMovie(movie)
    }
  }
}
presenter = SearchPresenter(this, SearchInteractor(), router)

Key points

  • View is the component that receives UI actions from the user and sends them to the Presenter.
  • Interactor is the component in charge of interacting with your backend such as your databases and web services.
  • Presenter is like the commander of your architecture. It’s in charge of coordinating your Views, Interactors and Routers.
  • Entity is the component that represents your app’s data. It’s usually represented as data classes in Kotlin.
  • Router is the component that manages the navigation between the Views in your app.

Where to go from here?

VIPER is a great architecture pattern that focuses on providing the maximum level of modularity for your Android projects. It allows you to create app’s that are maintainable, scalable and easy to test.

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

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 raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.