Android App Architectires: VIPER

VIPER stands for View, Interactor, Presenter, Entity and Router. VIPER is an architectural pattern whose main purpose is to achieve separation of concerns through a clear distinction between the roles of each of its layers. VIPER is similar to the MVP architecture but adds another two layers of abstraction to provide the highest level of modularity. This five-layer structure aims to balance the workload between the different entities to provide the maximum level of modularity.

The role of each of the Entities are as follows:

  • The View displays the UI and informs the Presenter of user actions. Views can only interact with the presenter to communicate UI updates. When the user performs an action that requires business logic, such as pressing a button or tapping a list item, the task is immediately delegated to the Presenter, and the View waits for a response on what should happen next. The View knows nothing about how things get done. In Android, Views correspond to an Activity or Fragment, and the goal is to make them as simple as possible.
  • The Interactor performs any action and logic related to the entities. The Interactor takes care of performing any actions that the Presenter requests and handles the interaction with your backend modules, such as web services or local databases. Similar to the ViewModel from the MVVM architecture pattern, the Interactor should be completely independent from your Views.
  • The Presenter acts as a Head of Department. It tells the View what to display, tells the router to navigate to other screens and tells the Interactor about any necessary updates.
  • The Entity represents your app's data. The Entity corresponds to the Model in other architecture patterns such as MVP or MVVM. Its main responsibility is to expose relevant data to your Interactors using properties or methods.
  • The Router handles the navigation in your app. The Router is also in charge of passing data from one View to another, such as View to View or Fragment to Fragment.

The project 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.
  • View. The activities, fragments and adapters.
  • Presenter. The Presenters of your app.

Dependencies

Start by opening build.gradle in your app directory. Add the following line inside the dependencies block:

# apply plugin: 'kotlin-kapt'

def cardviewVersion = "1.0.0"
implementation "androidx.cardview:cardview:$cardviewVersion"

def constraintLayoutVersion = "2.0.0-beta3"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion"

implementation 'com.squareup.retrofit2:converter-gson:2.6.0'
implementation 'androidx.recyclerview:recyclerview:1.1.0-rc01'

def roomVersion = "1.0.0"
implementation "android.arch.persistence.room:runtime:$roomVersion"
kapt "android.arch.persistence.room:compiler:$roomVersion"

def lifecycle_version = '2.1.0'
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycle_version"

AndroidManifest.xml

<application android:name=".App" ...>
    <activity android:name=".view.activity.MainActivity">
        <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
</application>

root directory

File App.kt

lateinit var db: PostDatabase

class App: Application() {
    companion object {
        lateinit var INSTANCE: App
    }

    init {
        INSTANCE = this
    }

    override fun onCreate() {
        super.onCreate()
        db = PostDatabase.getInstance(this)
        INSTANCE = this
    }
}

data directory

File /data/Interactor.kt

interface Interactor {
    fun getData(): PostState
    fun addPost(post: Post)
}

File /data/PostInteractor.kt

class PostInteractor: Interactor {
    private val retrofitClient = RetrofitClient()
    private val postDao = db.postDao()

    override fun getData(): PostState {
        var state: PostState = PostState.LoadingState

        if (postDao == null) state = PostState.ErrorState("Error")
        else {
            state = PostState.LiveDataState(postDao.getAll())
        }

        return state
    }

    override fun addPost(post: Post) {
        thread { postDao.insert(post) }
    }
}

File /data/db/PostDao.kt

@Dao
interface PostDao {
    @Insert(onConflict = OnConflictStrategy.REPLACE)
    fun insert(post: Post)

    @Query("select * from Post")
    fun getAll(): LiveData<List<Post>>

    @Update
    fun update(post: Post)

    @Query("DELETE FROM Post WHERE id = :id")
    fun delete(id: Int?)
}

File /data/db/PostDatabase.kt

@Database(entities = [Post::class], version = 1)
abstract class PostDatabase : RoomDatabase() {
    abstract fun postDao(): PostDao

    companion object {
        private val lock = Any()
        private const val DB_NAME = "PostDatabase"
        private var INSTANCE: PostDatabase? = null

        fun getInstance(application: Application): PostDatabase {
            synchronized(PostDatabase.lock) {
                if (PostDatabase.INSTANCE == null) {
                    PostDatabase.INSTANCE =
                        Room.databaseBuilder(application, PostDatabase::class.java, PostDatabase.DB_NAME)
                            .build()
                }
            }
            return INSTANCE!!
        }
    }
}

File /data/model/Post.kt

@Entity
data class Post (
    @PrimaryKey(autoGenerate = true)
    var id: Int? = null,
    var userId: Int = 0,
    var title: String = "",
    @SerializedName("body")
    var text: String = ""
)

File /data/model/PostResponse.kt

data class PostResponse (
    var id: Int = 0,
    var userId: Int = 0,
    var title: String = "",
    @SerializedName("body")
    var text: String = ""
)

File /data/net/PostApi.kt

interface PostApi {}

File /data/net/RetrofitClient.kt

class RetrofitClient {}

domain directory

File /domain/PostState.kt

sealed class PostState {
    object LoadingState: PostState()
    data class LiveDataState(val data: LiveData<List<Post>>): PostState()
    data class DataState(val data: List<Post>): PostState()
    data class ErrorState(val data: String): PostState()
}

presenter directory

File /presenter/MainPresenter.kt

class MainPresenter(private val postInteractor: PostInteractor) {
    private lateinit var view: MainView

    fun bind(view: MainView) {
        this.view = view
        displayPosts()
    }

    private fun displayPosts() {
        val state = postInteractor.getData()
        when(state) {
            is PostState.LoadingState -> view.render(state)
            is PostState.ErrorState -> view.render(state)
            is PostState.LiveDataState -> {
                state.data.observe(view, Observer { posts ->
                    // for testing
                    if (posts.isEmpty()) {
                        postInteractor.addPost(
                            Post(userId = 1, title = "Post 1", text = "Body 1"))
                        postInteractor.addPost(
                            Post(userId = 1, title = "Post 2", text = "Body 2"))
                    }
                    view.render(PostState.DataState(posts))
                })

                Timer().schedule(5000) {
                    postInteractor.addPost(
                        Post(userId = 2, title = "Post 3", text = "Body 3"))
                }
            }
        }
    }
}

view directory

File /view/MainView.kt

interface MainView: LifecycleOwner {
    fun render(state: PostState)
}

File /view/activity/MainActivity.kt

class MainActivity: AppCompatActivity(), MainView {
    private lateinit var presenter: MainPresenter
    private lateinit var adapter: PostListAdapter

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

        setupPresenter()
        setupViews()
    }

    private fun setupPresenter() {
        presenter = MainPresenter(PostInteractor())
        presenter.bind(this)
    }

    override fun render(state: PostState) {
        when (state) {
            is PostState.LoadingState -> renderLoadingState()
            is PostState.DataState -> renderDataState(state)
            is PostState.ErrorState -> renderErrorState(state)
        }
    }

    private fun renderLoadingState() {
        rvItems.isEnabled = false
        Log.d("TAG", "LOADING");
    }

    private fun renderDataState(dataState: PostState.DataState) {
        rvItems.apply {
            isEnabled = true
            (adapter as PostListAdapter).setPosts(dataState.data)
        }
    }

    private fun renderErrorState(dataState: PostState.ErrorState) {
        Toast.makeText(this@MainActivity, dataState.data, Toast.LENGTH_LONG).show()
    }

    private fun setupViews() {
        rvItems.layoutManager = LinearLayoutManager(this)
        adapter = PostListAdapter(mutableListOf())
        rvItems.adapter = adapter
    }
}

File activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".view.activity.MainActivity">

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvItems"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:layout_centerInParent="true" />
</RelativeLayout>

File /view/adapter/PostListAdapter.kt

class PostListAdapter(private val posts: MutableList<Post>)
    : RecyclerView.Adapter<PostListAdapter.PostHolder>() {

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): PostHolder {
        val view = LayoutInflater.from(parent.context)
            .inflate(R.layout.item_post_main, parent, false)
        return PostHolder(view)
    }

    override fun getItemCount(): Int = posts.size ?: 0

    override fun onBindViewHolder(holder: PostHolder, position: Int) {
        holder.bind(posts[position])
    }

    fun setPosts(items: List<Post>) {
        this.posts.clear()
        this.posts.addAll(items)
        notifyDataSetChanged()
    }

    inner class PostHolder(val view: View) : RecyclerView.ViewHolder(view) {
        fun bind(post: Post) = with(view) {
            tvTitle.text = post.title
            tvBody.text = post.text
        }
    }
}

File item_post_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_height="wrap_content"
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_margin="8dp"
    app:cardCornerRadius="6dp"
    app:cardElevation="2dp"
    app:contentPadding="8dp">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:paddingBottom="10dp">

        <TextView
            android:id="@+id/tvTitle"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:textSize="20sp"
            android:textStyle="bold"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            tools:text="Post Title" />

        <TextView
            android:id="@+id/tvBody"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:textSize="14sp"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/tvTitle"
            tools:text="Post Body" />
    </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>