Android App Architectires: MVVM

MVVM stands for Model-View-ViewModel. MVVM 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:

  • View is responsible for displaying the interface, and it is usually represented in Android as Activities or Fragments. The View also informs ViewModels about user actions.
  • ViewModel retrieves the necessary information from the Model, applies the necessary operations and exposes any relevant data for the Views.
  • Model retrieves information from your datasource and exposes it to the ViewModels. It should also receive any events from the ViewModel that it needs to create, read, update or delete any necessary data from the backend.

The data package has three packages related to the backend of your app:

  • The db package contains the files required for your Room database: the PostDatabase.kt and the PostDao.kt files.
  • The model package contains the models for your app: the Post model and the PostResponse model.

The net package contains the files required by Retrofit to communicate with the web service: PostAPI.kt and RetrofitClient.kt.

The view package contains three packages related to the front end of your app such as the Activities and Adapters.

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"

def retrofitVersion = "2.4.0"
implementation "com.squareup.retrofit2:retrofit:$retrofitVersion"
implementation "com.squareup.retrofit2:converter-gson:$retrofitVersion"

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"
implementation "androidx.lifecycle:lifecycle-viewmodel:$lifecycle_version"

AndroidManifest.xml

<application android:name=".App" ...>
    <activity android:name=".view.activities.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/PostRepository.kt

interface PostRepository {
    fun list(): LiveData<List<Post>>
    fun save(post: Post)
    fun delete(post: Post)
}

File /data/PostRepositoryImpl.kt

class PostRepositoryImpl: PostRepository {
    private val postDao: PostDao = db.postDao()
    private val allPosts: LiveData<List<Post>>

    init {
        allPosts = postDao.getAll()
    }

    override fun delete(post: Post) {
        thread {
            db.postDao().delete(post.id)
        }
    }

    override fun list() = allPosts

    override fun save(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 {
    private val postApi: PostApi

    companion object {
        const val BASE_URL = "https://jsonplaceholder.typicode.com/"
    }

    init {
    val builder = OkHttpClient.Builder()
    val okHttpClient = builder.build()
    val retrofit = Retrofit.Builder()
        .baseUrl(BASE_URL)
        .addConverterFactory(GsonConverterFactory.create())
        .client(okHttpClient)
        .build()
    postApi = retrofit.create(PostApi::class.java)
    }
}

view directory

File /view/activities/MainActivity.kt

class MainActivity : AppCompatActivity() {
    private lateinit var viewModel: MainViewModel
    private val adapter = PostListAdapter(mutableListOf())

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

        viewModel = ViewModelProviders.of(this).get(MainViewModel::class.java)
        rvItems.adapter = adapter

        showLoading()

        viewModel.getSavedPosts().observe(this, Observer { posts ->
            posts?.let {
                if (posts.isEmpty()) {
                    viewModel.add(Post(userId = 1, title = "Post 1", text = "Body 1"))
                    viewModel.add(Post(userId = 1, title = "Post 2", text = "Body 2"))
                }
                hideLoading()
                adapter.setPosts(posts)
                Log.d("TAG", "$posts");
            }
        })
    }

    private fun showLoading() {
        rvItems.isEnabled = false
        progressBar.visibility = View.VISIBLE
    }

    private fun hideLoading() {
        rvItems.isEnabled = true
        progressBar.visibility = View.GONE
    }
}

File activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 
    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=".view.activities.MainActivity">

    <ProgressBar
        android:id="@+id/progressBar"
        style="?android:attr/progressBarStyle"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/rvItems"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="8dp"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintHorizontal_bias="0.0"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

File /view/adapters/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>

viewmodel directory

File /viewmodel/MainViewModel.kt

class MainViewModel(private val repository: PostRepository = PostRepositoryImpl()) : ViewModel() {
    private val allPosts = MediatorLiveData<List<Post>>()

    init {
        getAllPosts()
    }

    fun getSavedPosts() = allPosts

    private fun getAllPosts() {
        allPosts.addSource(repository.list()) { posts ->
            allPosts.postValue(posts)
        }
    }

    fun delete(post: Post) {
        repository.delete(post)
    }

    fun add(post: Post) {
        repository.save(post)
    }
}