At Google I/O 2017, Google introduced Architecture Components, a collection of libraries designed to address core challenges in Android development and provide guidance for app architecture. These components, part of the broader Android Jetpack library suite, include tools like Jetpack ViewModel to simplify state management and lifecycle awareness.
Jetpack ViewModel has long been a popular topic of discussion among developers, often serving as a cornerstone for implementing MV* design patterns, such as MVVM or MVI. It is frequently compared to Microsoft’s original MVVM pattern, highlighting its versatility and adaptation for Android app architecture.
Some developers might prefer implementing ViewModels manually, but Jetpack ViewModel remains a great role in many common scenarios due to its seamless integration with various libraries. It works effectively with Hilt for dependency injection into ViewModel constructors, with Jetpack Navigation for scoping ViewModel lifecycles, and with Jetpack Compose for exposing screen UI states to composable functions, making it a cornerstone of modern Android app architecture.
In this article, you’ll explore ViewModel’s inner workings, diving into its internal implementation and understanding how it operates under the hood featured in Dove Letter. Dove Letter is a subscription repository where you can learn, discuss, and share new insights about Android and Kotlin with industrial Android developer interview questions, tips with code, articles, discussion, and trending news. If you’re interested in joining, be sure to check out “Learn Kotlin and Android With Dove Letter.”
ViewModel and ViewModelImpl
Let’s start by examining the ViewModel
class. ViewModel
is an abstract class that serves as a base for other classes, offering several key functions to manage UI-related data in a lifecycle-conscious way. Its internal implementation (JVM, Android) is outlined in the following code:
public actual abstract class ViewModel { | |
/** | |
* Internal implementation of the multiplatform [ViewModel]. | |
* | |
* **Why is it nullable?** Since [clear] is final, this method is still called on mock objects. | |
* In those cases, [impl] is `null`. It'll always be empty though because [addCloseable] and | |
* [getCloseable] are open so we can skip clearing it. | |
*/ | |
private val impl: ViewModelImpl? | |
public actual constructor() { | |
impl = ViewModelImpl() | |
} | |
public actual constructor(viewModelScope: CoroutineScope) { | |
impl = ViewModelImpl(viewModelScope) | |
} | |
public actual constructor(vararg closeables: AutoCloseable) { | |
impl = ViewModelImpl(*closeables) | |
} | |
public actual constructor(viewModelScope: CoroutineScope, vararg closeables: AutoCloseable) { | |
impl = ViewModelImpl(viewModelScope, *closeables) | |
} | |
protected actual open fun onCleared() {} | |
@MainThread | |
internal actual fun clear() { | |
impl?.clear() | |
onCleared() | |
} | |
public actual fun addCloseable(key: String, closeable: AutoCloseable) { | |
impl?.addCloseable(key, closeable) | |
} | |
public actual open fun addCloseable(closeable: AutoCloseable) { | |
impl?.addCloseable(closeable) | |
} | |
public actual fun <T : AutoCloseable> getCloseable(key: String): T? = impl?.getCloseable(key) | |
} |
You might have noticed that the ViewModel
class contains a property of type ViewModelImpl
, and most of its functions simply delegate their calls to the corresponding functions in ViewModelImpl
. To fully understand how these functions operate, we should examine the internal workings of the ViewModelImpl
class:
internal class ViewModelImpl { | |
private val lock = SynchronizedObject() | |
/** | |
* Holds a mapping between [String] keys and [AutoCloseable] resources that have been associated | |
* with this [ViewModel]. | |
* | |
* The associated resources will be [AutoCloseable.close] right before the [ViewModel.onCleared] | |
* is called. This provides automatic resource cleanup upon [ViewModel] release. | |
*/ | |
private val keyToCloseables = mutableMapOf<String, AutoCloseable>() | |
private val closeables = mutableSetOf<AutoCloseable>() | |
@Volatile private var isCleared = false | |
constructor() | |
constructor(viewModelScope: CoroutineScope) { | |
addCloseable(VIEW_MODEL_SCOPE_KEY, viewModelScope.asCloseable()) | |
} | |
constructor(vararg closeables: AutoCloseable) { | |
this.closeables += closeables | |
} | |
constructor(viewModelScope: CoroutineScope, vararg closeables: AutoCloseable) { | |
addCloseable(VIEW_MODEL_SCOPE_KEY, viewModelScope.asCloseable()) | |
this.closeables += closeables | |
} | |
/** @see [ViewModel.clear] */ | |
@MainThread | |
fun clear() { | |
if (isCleared) return | |
isCleared = true | |
synchronized(lock) { | |
for (closeable in keyToCloseables.values) { | |
closeWithRuntimeException(closeable) | |
} | |
for (closeable in closeables) { | |
closeWithRuntimeException(closeable) | |
} | |
// Clear only resources without keys to prevent accidental recreation of resources. | |
// For example, `viewModelScope` would be recreated leading to unexpected behaviour. | |
closeables.clear() | |
} | |
} | |
fun addCloseable(key: String, closeable: AutoCloseable) { | |
// Although no logic should be done after user calls onCleared(), we will | |
// ensure that if it has already been called, the closeable attempting to | |
// be added will be closed immediately to ensure there will be no leaks. | |
if (isCleared) { | |
closeWithRuntimeException(closeable) | |
return | |
} | |
val oldCloseable = synchronized(lock) { keyToCloseables.put(key, closeable) } | |
closeWithRuntimeException(oldCloseable) | |
} | |
fun addCloseable(closeable: AutoCloseable) { | |
// Although no logic should be done after user calls onCleared(), we will | |
// ensure that if it has already been called, the closeable attempting to | |
// be added will be closed immediately to ensure there will be no leaks. | |
if (isCleared) { | |
closeWithRuntimeException(closeable) | |
return | |
} | |
synchronized(lock) { closeables += closeable } | |
} | |
fun <T : AutoCloseable> getCloseable(key: String): T? = | |
@Suppress("UNCHECKED_CAST") synchronized(lock) { keyToCloseables[key] as T? } | |
private fun closeWithRuntimeException(closeable: AutoCloseable?) { | |
try { | |
closeable?.close() | |
} catch (e: Exception) { | |
throw RuntimeException(e) | |
} | |
} | |
} |
Let’s break it down one by one. The ViewModelImpl
class contains the following key properties and functions:
isCleared: This property holds the state indicating whether the
ViewModel
has already been cleared. Many function calls depend on this property, as it governs whether certain operations are permissible.closeables and
keyToCloseables: These properties manage
AutoCloseable
instances.closeables
is a set, whilekeyToCloseables
is a map, allowing for efficient storage and retrieval of closeable resources.addCloseable(): There are two variations of this function, but their primary purpose is the same — to add an
AutoCloseable
instance to either the map or set. This is done in a thread-safe manner using aSynchronizedObject, ensuring proper coordination in multi-threaded environments.
clear(): This function is responsible for closing all stored
AutoCloseable
instances from bothcloseables
andkeyToCloseables
. It also resets thecloseables
set, ensuring no lingering resources are left behind.
AutoCloseable
Now you might be wondering about the exact purpose of AutoCloseable
. Originating from Java, AutoCloseable
is a functional interface designed to handle resource management. In Kotlin, AutoCloseable works similarly and allows you to create an instance that executes a specified
closeAction
when its close()
function is called as you’ve seen in the code below:
public actual inline fun AutoCloseable(crossinline closeAction: () -> Unit): AutoCloseable = | |
java.lang.AutoCloseable { closeAction() } | |
public interface AutoCloseable { | |
void close() throws Exception; | |
} |
By now, you should have a clearer understanding of AutoCloseable
and its role in resource management. Essentially, the ViewModel
maintains a map and a set of AutoCloseable
instances, which can be added directly to the ViewModel
using the addCloseable()
function. When the ViewModel
is cleared, all stored AutoCloseable
instances are systematically closed by invoking their close()
function, ensuring proper cleanup of resources.
So what can you achieve with AutoCloseable
? While its use cases may not be extensive, it is particularly useful for delegating tasks that must be executed in the ViewModel
‘s onCleared()
function. For instance, when working with RxJava or RxKotlin, you need to call dispose()
on a CompositeDisposable to clean up all
Disposable objects when the
ViewModel
is cleared.
class MyViewModel : ViewModel() { | |
private val compositeDisposable = CompositeDisposable() | |
fun addDisposable(disposable: Disposable) { | |
compositeDisposable.add(disposable) | |
} | |
override fun onCleared() { | |
super.onCleared() | |
compositeDisposable.dispose() | |
} | |
} |
Job Offers
By utilizing AutoCloseable
, you can create a custom class or function to manage CompositeDisposable
, ensuring that all disposables are automatically disposed of when the ViewModel
is cleared. This approach simplifies resource cleanup and integrates seamlessly with the ViewModel
lifecycle.
Ensure that you are using version 2.8.5 or later of the Jetpack Lifecycle library.
class AutoDisposable : AutoCloseable { | |
private val compositeDisposable = CompositeDisposable() | |
fun add(disposable: Disposable) { | |
compositeDisposable.add(disposable) | |
} | |
override fun close() { | |
compositeDisposable.dispose() | |
} | |
} | |
fun ViewModel.autoDisposable(): AutoDisposable { | |
val autoDisposable = AutoDisposable() | |
addCloseable(autoDisposable) | |
return autoDisposable | |
} | |
class MyViewModel : ViewModel() { | |
private val autoDisposable = autoDisposable() | |
fun addDisposable(disposable: Disposable) { | |
autoDisposable.add(disposable) | |
} | |
} |
At first glance, this approach might seem like over-engineering, as it adds more code compared to manually disposing of the CompositeDisposable
. However, consider a scenario where you have 100+ ViewModel
classes—this method not only simplifies resource management but also eliminates the risk of forgetting to call the dispose()
function in the onCleared()
method, ensuring consistent and reliable cleanup across your codebase.
viewModelScope
One of the most common examples of leveraging AutoCloseable
is the viewModelScope
, which is frequently used in daily development to launch coroutine scopes within a ViewModel
. Let’s take a look at the internal implementation of viewModelScope
:
public val ViewModel.viewModelScope: CoroutineScope | |
get() = | |
synchronized(VIEW_MODEL_SCOPE_LOCK) { | |
getCloseable(VIEW_MODEL_SCOPE_KEY) | |
?: createViewModelScope().also { scope -> | |
addCloseable(VIEW_MODEL_SCOPE_KEY, scope) | |
} | |
} | |
private val VIEW_MODEL_SCOPE_LOCK = SynchronizedObject() |
Examining the internal implementation of viewModelScope
, it first checks if a ViewModel
scope has already been created; if it exists, it retrieves the existing scope. If not, it creates a new ViewModelScope
using the createViewModelScope()
function. To fully understand this process, it’s essential to delve into what the createViewModelScope()
function does.
internal fun createViewModelScope(): CloseableCoroutineScope { | |
val dispatcher = | |
try { | |
Dispatchers.Main.immediate | |
} catch (_: NotImplementedError) { | |
// In Native environments where `Dispatchers.Main` might not exist (e.g., Linux): | |
EmptyCoroutineContext | |
} catch (_: IllegalStateException) { | |
// In JVM Desktop environments where `Dispatchers.Main` might not exist (e.g., Swing): | |
EmptyCoroutineContext | |
} | |
return CloseableCoroutineScope(coroutineContext = dispatcher + SupervisorJob()) | |
} | |
/** | |
* [CoroutineScope] that provides a method to [close] it, causing the rejection of any new tasks and | |
* cleanup of all underlying resources associated with the scope. | |
*/ | |
internal class CloseableCoroutineScope( | |
override val coroutineContext: CoroutineContext, | |
) : AutoCloseable, CoroutineScope { | |
constructor(coroutineScope: CoroutineScope) : this(coroutineScope.coroutineContext) | |
override fun close() = coroutineContext.cancel() | |
} |
The createViewModelScope()
function returns a coroutine scope called CloseableCoroutineScope
, which implements AutoCloseable
. This scope ensures that the coroutine context is canceled when the close()
function is called.
Internally, the mechanism works as follows: viewModelScope
creates a custom coroutine scope that is automatically canceled by leveraging the AutoCloseable
interface. This scope is then registered with the ViewModel
by adding it through the addCloseable()
function, ensuring seamless integration with the ViewModel
lifecycle.
Conclusion
In this article, you’ve explored the internal mechanisms of Jetpack ViewModel
, gaining a deeper understanding of how it operates, particularly the implementation and functionality of viewModelScope
. While the complete ViewModel
mechanism is more intricate—encompassing dedicated providers and lifecycle scoping tailored to Android components—the insights shared here should be sufficient for most project requirements. Hopefully, this article has clarified the workings of ViewModel
and provided valuable insights into the internal behavior of viewModelScope
.
This topic initially has been covered in Dove Letter, a private repository offering daily insights on Android and Kotlin, including topics like Compose, architecture, industry interview questions, and practical code tips. In just 20 weeks since its launch, Dove Letter has surpassed 400 individual subscribers and 12business/lifetime subscribers. If you’re eager to deepen your knowledge of Android, Kotlin, and Compose, be sure to check out ‘Learn Kotlin and Android With Dove Letter’.
This article is previously published on proandroiddev.com.