Android Room+MVVM Part 1: Room, ViewModel, and RecyclerView
I create a Demo for MVVM and ROOM after I practiced on Google Codelabs of Room. This is part one. I added a custom application, thread manager for Executors. The project isn’t complete at this end but you will display the sample data from the database on the screen. Let’s begin at the Gradle stage.
-==== — — <M E M U >— — ====-
🔌1. Setup Gradle
💊2. Room Database: UserDatabase
📐3. UI Design: RecyclerView and ItemView
🗿 4. MainActivity←ViewModel← Repository
🐞5. Test Main Activity
🔌1. Setup Gradle
==> Menu
In this project, the dependencies are included Kotlin, RecyclerView, Lifecycle, RxJava, and Room.
The build.gradle(Project):
buildscript {
ext.kotlin_version = "1.3.72"
repositories {
...
maven { url "https://oss.jfrog.org/libs-snapshot" }
}
dependencies {...}
}allprojects { ... }
task clean(type: Delete) { ... }ext {
// UI
appCompatVersion = '1.1.0'
recyclerViewVersion = '1.1.0'
cardViewVersion = '1.0.0'
constraintLayoutVersion = '1.1.3'
// test
espressoVersion = '3.2.0'
junitVersion = '4.13'
legacySupportVersion = '1.0.0'
// jetpack
lifecycleVersion = '2.2.0'
lifecycleExtensionsVersion = '2.2.0'
roomVersion = '2.2.5'
savedStateVersion = '2.2.0'
// kotlin
kotlinCore = '1.3.1'
kotlinVM = '2.2.0'
// rxjava
rxJavaV = '3.0.4'
rxAndroidV = '3.0.0'
}
The build.gradle(app): plugin
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'kotlin-kapt'
Java 8:
android {
compileSdkVersion 29
buildToolsVersion "30.0.1"
defaultConfig {...} compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
} buildTypes { ... }
}
Dependencies:
dependencies {
implementation "androidx.appcompat:appcompat:$appCompatVersion"
implementation "androidx.legacy:legacy-support-v4:$legacySupportVersion"
// UI
implementation "androidx.cardview:cardview:$cardViewVersion"
implementation "androidx.recyclerview:recyclerview:$recyclerViewVersion"
implementation "androidx.constraintlayout:constraintlayout:$constraintLayoutVersion"
implementation 'com.google.android.material:material:1.1.0'
implementation 'androidx.gridlayout:gridlayout:1.0.0'
// Lifecycle
implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleExtensionsVersion"
implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion"
// Kotlin
implementation "androidx.core:core-ktx:$kotlinCore"
implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$kotlinVM"
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
// RxJava
implementation "io.reactivex.rxjava3:rxandroid:$rxAndroidV"
implementation "io.reactivex.rxjava3:rxjava:$rxJavaV"
// Room (use 1.1.0-beta2 for latest beta)
implementation "androidx.room:room-runtime:$roomVersion"
kapt "androidx.room:room-compiler:$roomVersion"
// RxJava support for Room
implementation "androidx.room:room-rxjava2:$roomVersion"
// Test
testImplementation "junit:junit:$junitVersion"
testImplementation "androidx.room:room-testing:$roomVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$espressoVersion"
}
Sync.
💊2. Room Database: UserDatabase
==> Menu
The Room is a SQLite object mapping library. It eases our work to deal with SQLite. It forms by 3 parts:
1. Entity: Database ==> Java/Kotlin Class
2. Dao: Database operation
3. Database
Database Package:
Create an Entity : UserTable.kt
With User example:
@Entity
class UserTable {
@PrimaryKey(autoGenerate = true)
var uid = 0
var name: String? = null
var address: String? = null
override fun toString(): String {
return "UserTable {" +
"uid=" + uid +
", name='" + name + '\'' +
", address='" + address + '\'' +
'}'
}
}
The Entity is the column titles of a database table. In this example, the User table holds three items: uid, name, and address.
DAO — Set SQLite Operation: UserDao.kt
Let’s set up some SQL commands so we don’t need to manually input later.
@Dao
interface UserDao {
@Insert
fun insert(user: UserTable)
@Insert
fun insertAll(users: List<UserTable>?)
@Delete
fun delete(user: UserTable)
@Delete
fun deleteAll(users: List<UserTable>?)
@Update
fun update(user: UserTable)
@Query("SELECT * FROM UserTable")
fun getAllByLiveData(): LiveData<List<UserTable>>?
@Query("SELECT * FROM UserTable WHERE uid = :uid")
fun getByUid(uid: Int): LiveData<UserTable>?
}
Create Database: UserDatabase.kt
To create the database is simple, let’s create the UserDatabase class with the entity.
// Room: Table Version
@Database(entities = [UserTable::class], version = 1)
abstract class UserDatabase : RoomDatabase() {}
Of course, you need a Dao instance.
abstract fun userDao(): UserDao?
In this project, I use LiveData to check database creation.
// LiveData: check database created?
private val mIsDbCreated = MutableLiveData<Boolean>()
open fun getDbCreated(): LiveData<Boolean?>? = mIsDbCreated
open fun setDbCreated() = mIsDbCreated.postValue(true)private fun updateDatabaseCreated(context: Context?) {
if (context!!.getDatabasePath(DATABASE_NAME).exists()) {
setDbCreated();
}
}
At UserDatabase.kt, companion object{}:
My logs:
// logcat
val tag = "MYLOG "+"UserDatabase"
fun lgi(s: String) { Log.i(tag, s)}
fun lgd(s: String) { Log.d(tag, s)}
fun lge(s: String) { Log.e(tag, s)}
Variables,
@Volatile
private var userDb: UserDatabase? = null
@VisibleForTesting
val DATABASE_NAME = "companyDb"
// Simulation time range: 1s = 1000ms
val DELAY_MS: Long = 3000
We use synchronized to lock on the database thread.
fun getInstance(): UserDatabase? {
lgd("getInstance()")
lgd("userDb: ${userDb.toString()}")
if (userDb == null) {
lgd("create demo data")
synchronized(UserDatabase::class.java) {
if (userDb == null) {
val mApp = UserApplication.instance
val mExecutors = mApp!!.appExecutors
userDb = mExecutors?.let { buildDatabase(mApp, it) }
userDb!!.updateDatabaseCreated(mApp)
}
}
}
return userDb
}
There two new files in the function: UserApplication and AppExecutors.
Custom Application: UserApplication.kt
The AppExecutors is created with UserApplication:
The AppExecutors.kt (2 constructors): it controls three kinds of thread: local thread, network thread, and the main thread.
class AppExecutors(
diskIO: Executor,
networkIO: Executor,
mainThread: Executor
) {
private val mDiskIO: Executor = diskIO
private val mNetworkIO: Executor = networkIO
private val mMainThread: Executor = mainThread
fun diskIO() = mDiskIO
fun networkIO() = mNetworkIO
fun mainThread() = mMainThread
constructor() : this(
Executors.newSingleThreadExecutor(),
Executors.newFixedThreadPool(3),
MainThreadExecutor()
) {}
private class MainThreadExecutor : Executor {
private val mainThreadHandler: Handler =
Handler(Looper.getMainLooper())
override fun execute(@NonNull command: Runnable?) {
mainThreadHandler.post(command!!)
}
}
}
Add new application name in the manifest,
The UserApplication.kt:
class UserApplication : Application() {
private var mAppExecutors: AppExecutors? = null
override fun onCreate() {
super.onCreate()
instance = this
mAppExecutors = AppExecutors()
}
val database: UserDatabase?
get() = UserDatabase.getInstance()
val appExecutors: AppExecutors?
get() = mAppExecutors
companion object {
var instance: UserApplication? = null
private set
}
}
UserDatabase.kt: buildDatabase()
At UserDatabase.kt, companion object{}: we build a database with a new executor.
private fun buildDatabase(
appContext: Context,
executors: AppExecutors
): UserDatabase? {
lgd("buildDatabase()")
return Room.databaseBuilder<UserDatabase> (
appContext,
UserDatabase::class.java,
DATABASE_NAME
// room callback
).addCallback(object : Callback() {
// room callback
override fun onCreate(
@NonNull db: SupportSQLiteDatabase) {
super.onCreate(db)
executors.diskIO().execute {
// Simulate a long-running operation
delaySimulation() // new database
val database: UserDatabase? = getInstance() // Generate the data for pre-population
val users: List<UserTable> =
DataGenerator.generateUsers() insertData(database, users) // notify that the database was created
database?.setDbCreated()
}
}
})
.build()
}private fun insertData(
database: UserDatabase?,
users: List<UserTable>) {
database!!.runInTransaction {
database.userDao()!!.insertAll(users)
}
}
private fun delaySimulation() {
try {
Thread.sleep(DELAY_MS)
} catch (ignored: InterruptedException) {
}
}
Test Data
Here is the DataGenerator.kt:
object DataGenerator {
private val NAME = arrayOf(
"General Edition", "New", "Fresh", "Qualified", "Used"
)
private val ADDRESS = arrayOf(
"Two-headed Python", "Plastic Yoyo", "Pink Panda", "Unicorn", "West Zone"
)
fun generateUsers(): List<UserTable> {
val userEntities: MutableList<UserTable> = ArrayList()
for (i in NAME.indices) {
val product = UserTable()
product.name = NAME[i]
product.address = ADDRESS[i]
userEntities.add(product)
}
return userEntities
}
}
📐3. UI Design: RecyclerView and ItemView
==> Menu
In activity_main.xml, I will use RecyclerView to display the data content.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
...
tools:context=".MainActivity">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/userview_rv"
.../>
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/addUser_fab"
.../>
</androidx.constraintlayout.widget.ConstraintLayout>
RecyclerView is required item layout, userview_item.xml.
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView
...>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<TableLayout
android:id="@+id/table"
...>
<TableRow android:layout_width="match_parent">
<TextView
.../>
<TextView
.../>
</TableRow>
<TableRow>
<TextView
android:id="@+id/userId_tv"
.../>
<TextView
android:id="@+id/userName_tv"
.../>
</TableRow>
</TableLayout>
<View
android:id="@+id/divider".../> </androidx.constraintlayout.widget.ConstraintLayout>
</androidx.cardview.widget.CardView>
Some cells have ids to contain data from the database.
RecyclerView Adapter: UserViewAdapter.kt
Every RecyclerView has an adapter to handle the viewgroup. Mine is UserViewAdapter.kt.
class UserViewAdapter internal constructor( context: Context):
RecyclerView.Adapter<UserViewAdapter.UserViewHolder>() {
private val inflater: LayoutInflater =
LayoutInflater.from(context)
private var allUsers =
emptyList<UserTable?>() // Cached all users
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int): UserViewHolder {
val itemView = inflater.inflate(
R.layout.userview_item, parent, false)
return UserViewHolder(itemView)
}
override fun getItemCount(): Int = allUsers.size
override fun onBindViewHolder(
holder: UserViewHolder,
position: Int) {
val current = allUsers[position]
holder.userIdView.text = current!!.uid.toString()
holder.userNameView.text = current.name
}
internal fun setUsers(users: List<UserTable?>) {
this.allUsers = users
notifyDataSetChanged()
}
class UserViewHolder(itemView: View):
RecyclerView.ViewHolder(itemView) {
val userNameView: TextView =
itemView.findViewById(R.id.userName_tv)
val userIdView: TextView =
itemView.findViewById(R.id.userId_tv)
}
}
🗿 4. MainActivity←ViewModel← Repository
==> Menu
At MainActivity.kt, we need to initial Recyclerview and ViewModel.
// viewModel
private lateinit var userViewModel:UserViewModel
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
lgi("MainActivity: onCreate()")
// RecyclerView
val recyclerView = findViewById<RecyclerView>(R.id.userview_rv)
val adapter = UserViewAdapter(this)
recyclerView.adapter = adapter
recyclerView.layoutManager = LinearLayoutManager(this) as RecyclerView.LayoutManager?
// viewModel
lgd("create userViewModel")
userViewModel = ViewModelProvider(this).get(UserViewModel::class.java)
userViewModel.getObservableUsers()!!.observe(
this,
Observer<List<UserTable?>?> { users ->
if (users != null) {
adapter.setUsers(users)
lgd("user size: ${adapter.itemCount}")
}
})
}
}
companion object {
// logcat
val tag = "MYLOG "+"MainActivity"
fun lgi(s: String) { Log.i(tag, s)}
fun lgd(s: String) { Log.d(tag, s)}
fun lge(s: String) { Log.e(tag, s)}
// toast
fun msg(context: Context, s: String) {
Toast.makeText(context, s, Toast.LENGTH_LONG).show()
}
// code for new user activity
val newUserActivityRequestCode = 1011
}
ViewModel: UserViewModel.kt
The ViewModel pushes data to activity in oneway stream to avoid the data leak. Here is UserViewModel.kt.
class UserViewModel(application: Application) : AndroidViewModel(application) {
// user repository to get data.
private val userRepository: UserRepository
// LiveData: all users
var mUserList: MutableLiveData<List<UserTable>?>? = null
private var mObservableUsers: LiveData<List<UserTable>>? = null
init {
// LiveData
if (mUserList == null) {
mUserList = MutableLiveData()
}
userRepository = UserRepository.getInstance()
}
// LiveData => all users
fun getUserByLiveData() {
mObservableUsers = userRepository.getAllByLiveData()
}
// LiveData => users
fun getObservableUsers(): LiveData<List<UserTable>>? {
if (mObservableUsers != null) {
lgd("observableUsers: ${mObservableUsers.toString()}")
} else {
getUserByLiveData()
}
return mObservableUsers
}
companion object {
// logcat
val tag = "MYLOG "+"UserViewModel"
fun lgi(s: String) { Log.i(tag, s)}
fun lgd(s: String) { Log.d(tag, s)}
fun lge(s: String) { Log.e(tag, s)}
}
}
The ViewModel calls it right-hand man, UserRepository.kt to communicate with the database.
class UserRepository() {
// user id
private var id: Int = -1
// database
val database: UserDatabase = UserDatabase.getInstance()!!
// LiveData: all users
fun getAllByLiveData(): LiveData<List<UserTable>>? {
lgd("getAllByLiveData()")
return database.userDao()!!.getAllByLiveData()
}
companion object {
// logcat
val tag = "MYLOG "+"UserRepository"
fun lgi(s: String) { Log.i(tag, s)}
fun lgd(s: String) { Log.d(tag, s)}
fun lge(s: String) { Log.e(tag, s)}
fun getInstance(): UserRepository {
lgd ("getInstance()")
return UserRepository()
}
}
}
The repository is simple, now. It returns all users in the database.
🐞5. Test Main Activity
==> Menu
So far, we can test the app to display the data. You will miss the click function on RecyclerView and create the new user in the second activity. The bugs have existed in every stage you have finished. So I start the bug fix journey: commit the file on the cloud; run and fix bug and redo the process again. Here is the final result: