Let’s Talk about MVVM Implementation

Thach Do
6 min readMay 6, 2024

--

A typical flat-white.

Prerequisites

Before we begin, in this article, I assume that readers already have some experience building Android applications. Ideally, you might have implemented MVVM architecture in any of your projects.

Background

As Android developers, we all know MVVM is Model-View-ViewModel architecture. However, when it comes to the actual implementation, there are a lot more questions yet to answer. For example:

  • How should I model different states of the UI?
  • Should I have put a logic in UseCase or Repository?
  • What should my UseCase and Repository return?
  • What should I name a data class?

Those are smaller decisions but certainly make huge impact on the consistency of the code base. In this article, I will be sharing an implementation that works for me.

A simple news feed screen

States of a simple news feed screen.

I will be elaborating my MVVM implementation based on this simple news feed screen. Let’s get started.

My implementation planning process is fairly simple with 3 major steps:

  • Know all possible UI states.
  • Get the data.
  • Wire things up.

Know all possible UI states

In most cases, there would be 3 states that can appear on a screen:

  • Content; this is known as the happy case which comes straight from the requirement.
  • Loading; this is when the app is fetching and processing data. Depending on the actual situation, apps could display a full-screen loading to block user interactions until the data is ready, or display a small loading and asynchronously update the screen.
  • Error; this is normally missed out as we tend to care more about the happy case. It is also the cause of weird UIs, flaky behaviours, and even crashes.
  • Niches; these are normally edge cases of the content state where the content is empty and we need to display a placeholder. Such cases can be easily inferred from the content state.

Let’s get back to the news feed screen. With the 3 UI states in mind, we can model the screen as follows.

sealed interface NewsFeedUiState {
object Loading : NewsFeedUiState

data class Error(e: Throwable) : NewsFeedUiState

data class Content(posts: List<DisplayPost>) : NewsFeedUiState
}

data class DisplayPost(
val title: String,
val author: String
)

Get the data

The data could be coming from different data sources such as backend API, local database or cache. Repository is where to aggregate data from different sources and produce a neutral model that can be consumed on higher layers.

Let’s assume that our news feed screen only get data from backend API as follows.

interface PostApi {
suspend fun getPosts(): Response<List<NetworkPost>>
}

@Serializable
data class Response<T>(
val code: Int,
val data: T
)

@Serializable
data class NetworkPost(
val title: String,
val author: String
)

Then the repository handle the network call and data mapping.

interface PostRepository {
suspend fun loadPosts(): Result<List<Post>>
}

class PostRepositoryImpl(
private val api: PostApi,
private val ioDispatcher: CoroutineDispatcher
) : PostRepository {
override suspend fun loadPosts(): Result<List<Post>> {
return kotlin.runCatching {
withContext(ioDispatcher) {
api.getPosts().data.map { it.toPost() }
}
}
}
}

fun NetworkPost.toPost() = Post(title, author)

The suspend functions

Function calls on the data and domain layers are normally time-consuming and need to run off the UI thread. Hence, functions on repositories and use cases are suspend. It is to signal the caller that the function might take long to finish and that it needs to be handled properly.

The dispatchers

It’s my preference that repositories and use cases explicitly do their tasks on worker dispatchers. This is to avoid unexpectedly running heavy tasks on main dispatcher afterwards.

The return types

You might have noticed from the code snippet above:

  • PostApi -> Response<*>
  • PostRepository -> Result<*>

Why there are such difference?

  • From my point of view, network calls and database access are terminal operations. They either return some data or fail with an exception. Handling them with a simple try-catch is sufficient.
  • In case of repositories, they could fail due to more reasons like exceptions from data sources, logical errors, etc. Hence, returning Result is a better way to give the caller more insight about the failure. This convention also applies to use cases as they operate on various repositories.

The class naming

Variable naming meme.

So far we have seen many classes containing the same piece of data just to present posts. As we already know, it is to decouple data sources. We don’t want a change in backend response to affect database schema on the device.

Having been through different projects, I’ve found this naming convention is easy to reason about.

  • NetworkPost; prefix a model name with Network to denote that it coming from network APIs.
  • DbPost; this Db prefix tells that this model is used as a table in the database.
  • CachePost; this Cache prefix stands for SharedPreferences , which means the data comes from the key-value cache.
  • Post; this is the universal model used across use cases on the domain layer. It contains only data that is necessary for the business logic.
  • UiPost; as the name suggest, this model is used solely on the UI layer. It might contain additional details about the current state of the screen.

Where are use cases?

When it comes to MVVM architecture, we know that there are use cases. But why haven’t I mentioned them yet?

Sometimes, use cases just simply wrap function calls to underlying repositories. It’s quite impractical and boilerplate to do so just to satisfy an existing stereotype.

So, in the news feed example, the PostRepository is good enough to get post list. An additional use case is unnecessary.

Wire things up

Up to this point, we have covered the Model of MVVM architecture. Let’s articulate the other parts.

NewsFeedViewModel

class NewsFeedViewModel(
private val postRepository: PostRepository
) : ViewModel() {
private val _uiState = MutableStateFlow<NewsFeedUiState>(NewsFeedUiState.Loading)
val uiState: StateFlow<NewsFeedUiState> = _uiState.asStateFlow()

fun loadPosts() {
viewModelScope.launch {
_uiState.value = NewsFeedUiState.Loading

postRepository.loadPosts()
.onSuccess { posts ->
val displayPosts = posts.map { it.toDisplayPost() }
_uiState.value = NewsFeedUiState.Content(displayPosts)
}
.onFailure { e -> _uiState.value = NewsFeedUiState.Error(e) }
}
}
}

fun Post.toDisplayPost() = DisplayPost(title, author)

NewsFeedActivity

class NewsFeedActivity {
private val viewModel by viewModels<NewsFeedViewModel>()

fun onCreate() {
repeatOnLifecycle {
viewModel.uiState.collect { uiState -> handleUiState(uiState) }
}
}

private fun handleUiState(state: NewsFeedUiState) {
when (state) {
is NewsFeedUiState.Loading -> {
// Display loading
}
is NewsFeedUiState.Content -> {
// Display content
}
is NewsFeedUiState.Error -> {
// Display error
retryBtn.singleClick { viewModel.loadPosts() }
}
}
}
}

Uni-directional data flow

Here ViewModel and View are connected according to the uni-directional data flow mechanism where:

  • View observes to data flows that ViewModel exposes.
  • View asks ViewModel to perform some actions; ViewModel then emits new data into the flows that View has been collecting.
  • Every state on the screen can be easily back-tracked to where it was emitted.
Uni-directional data flow.

Closing thoughts

Nothing in life is perfect. The implementation above is no exception. However, I find it just on-point in most cases:

  • It is simple to pick up.
  • It does not require overhead setup, generics or inheritance.
  • It is testable as dependencies are well decoupled.

References

I didn’t come up with all the concepts and code above by myself. It is a process of learning and practicing through many resources, some of which are:

--

--

Thach Do
Thach Do

Written by Thach Do

Senior Android Developer @OKX // I Build Android Web3 Wallet

No responses yet