Android App Architectires: MVP

MVP stands for Model-View-Presenter. MVP 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:

  • Model is the data layer, responsible for the business logic.
  • View displays the UI and listens to user actions. This is typically an Activity (or Fragment).
  • Presenter talks to both Model and View and handles presentation logic. Any code that does not directly handle UI or other Android framework-specific logic should be moved out of the View and into the Presenter class.

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 ...>
    <activity android:name=".main.MainActivity">
        <intent-filter>
        <action android:name="android.intent.action.MAIN" />
        <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
</application>

main directory

File /main/MainActivity.kt

class MainActivity: AppCompatActivity(), MainContract.ViewInterface {
    private lateinit var mainPresenter: MainContract.PresenterInterface
    private lateinit var adapter: MainAdapter

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

        setupPresenter()
        setupViews()
    }

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

    private fun setupPresenter() {
        val dataSource = LocalDataSource(application)
        mainPresenter = MainPresenter(this, dataSource)
        mainPresenter.getPostsList()
    }

    override fun displayItems(items: List<Post>) {
        adapter.setPosts(items)
        rvItems.visibility = View.VISIBLE
        tvNoItems.visibility = View.GONE
    }

    override fun displayNoItems() {
        rvItems.visibility = View.GONE
        tvNoItems.visibility = View.VISIBLE
    }

    override fun displayMessage(message: String) {
        Toast.makeText(this@MainActivity, message, Toast.LENGTH_LONG).show()
    }

    override fun displayError(message: String) {
        displayMessage(message)
    }
}

File activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    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"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".main.MainActivity">

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

    <TextView
        android:id="@+id/tvNoItems"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerInParent="true"
        android:text="No items!" />
</RelativeLayout>

File /main/MainContract.kt

class MainContract {
    interface PresenterInterface {
        fun getPostsList()
    }

    interface ViewInterface: LifecycleOwner {
        fun displayItems(movieList: List<Post>)
        fun displayNoItems()
        fun displayMessage(message: String)
        fun displayError(message: String)
    }
}

File /main/MainPresenter.kt

class MainPresenter(private var viewInterface: MainContract.ViewInterface, 
    private var dataSource: LocalDataSource): MainContract.PresenterInterface {

    private val TAG = "MainPresenter"
    private var allPosts: LiveData<List<Post>> = dataSource.allPosts

    override fun getPostsList() {
        allPosts.observe(viewInterface, Observer { posts ->
            // for testing
            if (posts.isEmpty()) {
                dataSource.insert(Post(userId = 1, title = "Post 1", text = "Body 1"))
                dataSource.insert(Post(userId = 1, title = "Post 2", text = "Body 2"))
            }

            if (posts.isEmpty()) {
                viewInterface.displayNoItems()
            } else {
                viewInterface.displayItems(posts)
            }
        })
    }
}

File /main/MainAdapter.kt

class MainAdapter(private val posts: MutableList<Post>)
    : RecyclerView.Adapter<MainAdapter.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>

model directory

File /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 /model/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 /model/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 /model/LocalDataSource.kt

open class LocalDataSource(application: Application) {
    private val postDao: PostDao
    open val allPosts: LiveData<List<Post>>

    init {
        val db = PostDatabase.getInstance(application)
        postDao = db.postDao()
        allPosts = postDao.getAll()
    }

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

net directory

File /net/PostApi.kt

interface PostApi {}

File /net/RetrofitClient.kt

class RetrofitClient {}