Published on

Android ViewModel: Must-Know Knowledge and Accompanying Tools

Authors

So, we have explored and clarified all three architectural patterns: MVC, MVP, and MVVM. From this chapter onwards, we will only discuss MVVM because of its advantages and high applicability in the current job market.

In MVVM Android, the most crucial aspect is the ViewModel. This is where logic is organized, events are directed, and appropriate data types are returned. Mastering how to organize code in the ViewModel and applying it with libraries like LiveData, SharedFlow, StateFlow, or RxJava will make your code more readable, maintainable, and easier to write Unit Tests for.

You can skip reading this Blog if you already have experience and jump straight into reading the code on my GitHub.

Lifecycle of Android ViewModel

Each ViewModel created requires a ViewModelStoreOwner. The ViewModelStoreOwner is where ViewModels are stored in a Map structure (key - value) and directly manages the lifecycle of those ViewModels.

You can create ViewModels yourself through constructors and manage them according to your personal purposes. However, that would be a foolish approach, wasting your time and the time of those reading the code later.

I 100% recommend using the ViewModelStoreOwner from the Activity or Fragment that you need.

A special feature in the Android operating system is that when an Activity or Fragment is destroyed and recreated due to a configuration change event (not intentionally by the user), the ViewModelStoreOwner's data is still retained. Therefore, the data in the Android ViewModel in this case is still available and will be reused as soon as the Activity/Fragment is successfully recreated.

The explanation for this is lengthy, but you can understand it in the following three sentences:

  • ViewModel is stored in ViewModelStoreOwner

  • When there is a Destroy event, if there are Configuration Changes, the viewModelStore variable in Activity will not clear data

  • ViewModelStoreOwner is stored in NonConfigurationInstances, and is always checked each time it is retrieved

Master this and remember it. Many Android developers know that the ViewModel is retained after Configuration Change events, but they cannot understand and explain why an Activity can recover the original data of the ViewModelStoreOwner even after being destroyed and recreated.

All variables inside an Android ViewModel have the lifecycle of the ViewModel that contains it. Taking advantage of this, developers can use LiveData, SharedFlow, StateFlow, or RxJava to serve their feature development purposes.

In the next part, Danh will guide you to learn and understand these tools and know how to use them in specific cases.

RxJava

RxJava is an old library, written in the Java language. It can be used for both Backend and Android apps.

RxJava simplifies the manipulation of Background Threads and returning values to the Main Thread.

// Example if we don't use RxJava
bgThreadHandler.post{
	try{
		val result = heavyCalculation()
		mainThreadHandler.post{
			textView.text = "$result"
		}
	} catch(e: Exception) {
		Log.d("Thread", "bgThread error $e")
	}
}

// Example if we use RxJava
Single.create(emitter -> {
	try{
		emitter.onSuccess(heavyCalculation())
	}catch(e: Exception){
			emitter.onError(e)
	}
})
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({ result ->
	textView.text = "$result"
}, { e ->
	Log.d("RxJava", "Single Error $e")
});

In addition to the above convenience and safety, RxJava also provides us with many operators to manipulate the return values of each RxJava Stream to create the desired result.

val numbers = Observable.just(1, 2, 3, 4, 5)
numbers
	.map { it * 2 } // map operator
  .subscribe { println(it) }

val letters = Observable.just("A", "B", "C")
letters
    .flatMap { letter ->
        Observable.just("${letter}1", "${letter}2") // flatMap operator
    }
    .subscribe { println(it) }

Some types of streams that RxJava supports:

  • Observable
  • Single
  • PublishSubject, BehaviorSubject, Flowable

LiveData

LiveData is also a type of Observable (an Object whose value changes can be observed). When observing a LiveData, we can listen to the changes in the LiveData's values each time it is updated.

The special feature and also the limitation of LiveData is that it can only be used within the scope of the Activity or Fragment lifecycle.

Another interesting thing is that when observing a LiveData, the callback function will always receive the latest value of the LiveData. This makes updating interfaces according to Data easier within the lifecycle of a Fragment or Activity.

liveData.observe(lifecycle) { it ->
	Log.d("LiveDataObserver", "$it")
} // Thực hiện obseve lên LiveData

liveData.value = 0 // set value cho LiveData trên Main Thread
liveData.postValue(1) // set value cho LiveData trên background Thread

In LiveData, all value update events are run on the Main Thread. However, setting the value for LiveData can be done on both the Main Thread and the Background Thread.

  • liveData.value = 0 —> this is how to assign a value to a LiveData when on the Main Thread. If you call this logic on the Background Thread, the program will crash.

  • liveData.postValue(1) —> this is a way that can be used in any Thread. The value assigned in the postValue function will be brought to the Main Thread and the liveData.value function will be called again afterwards.

Kotlin Coroutines

Coroutines is a new library from the Kotlin language, which runs stably on JVM as well as Android and environments that support the Kotlin language.

Kotlin Coroutines' approach is similar to RxJava - both use Thread Pools and switch threads when necessary. However, Kotlin Coroutines are at a higher level of suitability, save more resources, and have more interesting features.

To use Koltin Coroutines well, we need to grasp the following definitions:

  • CoroutineScope: This is the class representing the execution environment of Kotlin Coroutines. You can simply understand CoroutineScope as the class used to manage the lifecycle, execution order, and status of Jobs inside it.
  • Job: This can be understood as the object executed on CoroutineScope (similar to Runnable in Java Thread)
  • Dispatcher: This is the class used to determine which Thread your Job will run on when executed in CoroutineScope. We usually use one of two: Dispatchers.Main and Dispatchers.IO
  • suspend function: is a type of function that, when executed, has the ability to pause, change threads, and continue execution without blocking the current thread. Please read the withContext section to better understand how to use it.
  • withContext: This is a suspend function used to switch Context (which can be understood as switching Threads). The interesting thing here is that when we use withContext, the outer suspend function will pause to wait for the result from withContext and then continue execution.
// Cach viet su dung Java Thread
mainHandler.post{
	textView.text = "Begin"
	backgroundThreadHandler.post {
		val heavyCalculation = heavyCalculation()
		mainHandler.post {
			textView.text = "Result $result"
		}
	}
}

// Cach viet su dung Kotlin Coroutines x suspend function
coroutineScope.launch(Dispatchers.Main) {
	textView.text = "Begin"
	val result = withContext(Distpatchers.IO){
		heavyCalculation()
	}
	textView.text = "Result $result"
}
  • launch : Is an extension function of CoroutineScope. Launch is seen as the entry point for us to call suspend functions from a normal environment. When using launch, we do not expect to be able to receive a return value
  • async await : async is an extension function of CoroutineScope. async is different from launch, async can receive a return value through the suspend function await
lifecycleScope.launch(Dispatchers.IO) {
    val task1 = async {
        heavyTask1()
    }
    val task2 = async {
        heavyTask2()
    }
    Log.d("CampaignFragment", "task1: ${task1.await()} task2: ${task2.await()}")
}

Flow, SharedFlow, StateFlow

Flow is the base, representing the Observable Flow. Flows have the ability to emit data for subscribers to collect and process.

val sampleFlow = flow {
    var i = 0
    while (true) {
        delay(1000)
        emit(i++)
    }
}

lifecycleScope.launch {
    sampleFlow.collect {
        Log.d("SampleFlow", "collected $it")
    }
}

SharedFlow

SharedFlow is a Flow but has some special properties

  • SharedFlow initialization does not require a starting value
val stateFlow: MutableStateFlow = MutableStateFlow(0)
  • replay: we can set this value n ≥ 0 so that when SharedFlow has a new subscriber, that subscriber will immediately receive the n most recent values of SharedFlow in the correct previous order.
  • When SharedFlow emits 2 consecutive equal values, subscribers will receive the 2 values in turn. (not distinct on change)

StateFlow

  • StateFlow initialization requires a starting value
val stateFlow: MutableStateFlow = MutableStateFlow(0)
  • Default replay = 1
  • Default distinct Until Change (does not allow emitting 2 consecutive equal values)

SharedFlow and StateFlow are implicitly recognized as being born to replace LiveData in Android. Many people know that, but cannot really answer the question of why SharedFlow and StateFlow can replace LiveData?