7.2 Implementation Ideas

Note: MVVM has many different approaches to its implementation. I will show some of the ways that this pattern can be implemented, but keep in mind that there is no one right approach.

View

The View is pretty straightforward, you're just creating composables to display the information that you need to. In this section I'll just provide some general tips and best practices for creating your View in MVVM:

  • Always pass the minimum amount of information to your view

    • This tip helps keep our code debuggable. Let's say we need to navigate from Screen A to Screen B. We have two options. We could pass the entire navController down to Screen A, or we could pass a function, navigateToScreenB: () -> Unit to Screen A. If we pass the entire navController, we are also giving Screen A access to many other methods that it could use to influence the screen we are on and our navigation stack. Let's say in the future we have an issue with navigation in our app. If we only passed navigateToScreenB function to Screen A, then we would instantly have the guarantee that Screen A only navigates to screen B, and we would have to do less debugging on that screen, as opposed to passing the entire navController.

    • A corollary to this tip is that ViewModels should never be passed into your Composables. They can be injected, but should never be passed down directly as a parameter.

  • Try to minimize business logic in the view

    • Let's say you're working on Eatery, and you want to determine what the title should show on the home screen. Suppose that if there is more than 1 eatery open, we want to show the text "UAW is not on strike!" is our app title, and if there are no dining halls open, we want to show the text "The strike is still ongoing 😔". It could be tempting to pass the list of Eateries directly to the composable, and have the composable determine which text to show. But it is more appropriate to pass a screenTitle: String down to the view. This keeps business logic out of the view and allows you to easily write tests for your ViewModel to determine what your screen displays in what conditions.

ViewModel

I gave an example of a fairly standard ViewModel in my guide on Flows that shows how to incorporate Flows into a ViewModel. I recommend taking a look at that one first. Here I'll give an example of another common ViewModel use case: loading an asynchronous resource.

HomeViewModel.kt
sealed class HomeViewState {
    data class Loaded(
        val pictures: List<PictureViewState> = emptyList(),
    ) : HomeViewState()

    object Loading : HomeViewState()
    data class Error(val message: String) : HomeViewState()
}

class HomeViewModel(
    private val pictureRepository: PictureRepository = PictureRepositoryProvider.getInstance(),
    private val pictureService: PictureService = PictureServiceProvider.getInstance()
) : ViewModel() {
    private var page = PictureService.START_PAGE
    private val _homeViewState = MutableStateFlow<HomeViewState>(HomeViewState.Loading)
    val homeViewState = _homeViewState.asStateFlow()

    init {
        observePictureRepository()
        loadNextPage()
    }

    private fun observePictureRepository() {
        pictureRepository.observeAll().map { pictures ->
            _homeViewState.update {
                HomeViewState.Loaded(
                    pictures = pictures.map { PictureViewState(it) }
                )
            }
        }.launchIn(viewModelScope)
    }

    fun loadNextPage() = viewModelScope.launch {
        try {
            val pictures = pictureService.pictures(page)
            pictureRepository.put(pictures)
            page++
        } catch (e: Exception) {
            _homeViewState.update {
                HomeViewState.Error(e.localizedMessage ?: "Something went wrong.")
            }
        }
    }
}

There's a lot going on in this example that shows common practices with ViewModels, so let's break this analysis down:

ViewState

For true best practices, each ViewModel should have a corresponding ViewState data class. Sometimes this practice can be a bit overkill, especially if your ViewModel only exposes a couple of fields to the View. But generally this idea helps keep things managable as your code scales. Some reasons that this approach tends to be a good idea:

  1. It encourages a clear separation of concerns between the ViewModel and the View. The purpose of the ViewModel is to expose the UI state to the View, and the ViewModel should provide exactly the information that the View needs to the View.

  2. It simplifies state management by encapsulating all relevant UI data in a single object, making it easier to observe, test, and update the UI.

  3. Compose relies on state-driven UI rendering. A dedicated ViewState ensures UI recomposes automatically when state changes, aligning with Compose’s reactive nature.

Interactions With the Model

We can see that the ViewModel has two main purposes for interacting with the model:

  • Packaging the information from the model into a readable ViewState

    • The function observePictureRepository is responsible for this. We read whatever the latest data is from the model, and then update the UI state accordingly.

  • Allowing UI events to flow from the View to the Model.

    • This is what the loadNextPage function is for. When the UI wants us to load a new page of pictures, it indicates that by calling the loadNextPage function. This function calls upon the pictureService to get more pictures from the API, and then it indicates this change to the pictureRepository , by providing it with the latest picture data through its put method.

Model

Remember there is no single agreed upon implementation of what a Model needs to look like, so I'm going to share a couple of the main variations I've worked with throughout my time as an Android developer.

Model as a Cache

I call this section "Model as a Cache" because the model more just functions as a data structure, and doesn't have much special functionality. Room and DataStore can be thought of as implementations of this approach as well, but you might want to make a Model that wraps these for further abstraction. Let's take a look at an example of building a data cache from scratch:

PictureRepository.kt
typealias PictureId = String

class PictureRepository {
    private val idsToPictures = MutableStateFlow(mapOf<PictureId, Picture>())

    fun observe(pictureIds: Set<PictureId>): Flow<List<Picture>> =
        idsToPictures.map { idsToPictures ->
            pictureIds.mapNotNull { idsToPictures[it] }
        }.distinctUntilChanged()

    fun observeAll(): Flow<List<Picture>> =
        idsToPictures.map { it.values.toList() }.distinctUntilChanged()

    fun put(pictures: List<Picture>) {
        idsToPictures.update {
            it + pictures.associateBy { pic -> pic.id }
        }
    }
}

We can see that this PictureRepository simply acts as a data structure, almost like a regular Map. Except we use Flow so users of the repository can observe updates to the data live as they occur. We expose a put that updates the idsToPicture Flow, so then any observers using either observe or observeAll will be updated properly.

A more complicated repository might expose multiple Flows, with multiple respective put and observe methods, and then it could be the responsibility of the ViewModel to aggregate the data into what the UI wants to display.

Model as a Data Source

Let's consider a data source as a more abstract notion of a cache. So the ViewModel should be able to call upon this data source and get the data that it needs. It doesn't need to worry about any of the implementation details or where the data source gets its data (as opposed to the previous example, where the ViewModel places data in the model). Let's use a trimmed version of Eatery Blue's EateryRepository as an example:

@Singleton
class EateryRepository @Inject constructor(private val networkApi: NetworkApi) { 
    private val _eateryFlow: MutableStateFlow<EateryApiResponse<List<Eatery>>> =
        MutableStateFlow(EateryApiResponse.Pending)

    /**
     * A [StateFlow] emitting [EateryApiResponse]s for lists of ALL eateries, if loaded successfully.
     */
    val eateryFlow = _eateryFlow.asStateFlow()
    
    /**
     * A map from eatery ids to the states representing their API loading calls.
     */
    private val eateryApiCache: MutableMap<Int, MutableStateFlow<EateryApiResponse<Eatery>>> =
        mutableMapOf()
    
    
    /**
     * Makes a new call to backend for the specified eatery. After calling,
     * `eateryApiCache[eateryId]` is guaranteed to contain a state actively loading that eatery's
     * data.
     */
    private fun pingEatery(eateryId: Int) {
        // If first time calling, make new state.
        if (eateryApiCache[eateryId] == null) {
            eateryApiCache[eateryId] = MutableStateFlow(EateryApiResponse.Pending)
        }

        eateryApiCache[eateryId]!!.value = EateryApiResponse.Pending

        CoroutineScope(Dispatchers.IO).launch {
            try {
                val eatery = getEatery(eateryId = eateryId)
                eateryApiCache[eateryId]!!.value = EateryApiResponse.Success(eatery)
            } catch (_: Exception) {
                eateryApiCache[eateryId]!!.value = EateryApiResponse.Error
            }
        }
    }
    
    /**
     * Returns the [StateFlow] representing the API call for the specified eatery.
     * If ALL eateries are already loaded, then this simply instantly returns that.
     */
    fun getEateryFlow(eateryId: Int): StateFlow<EateryApiResponse<Eatery>> {
        if (eateryFlow.value is EateryApiResponse.Success) {
            return MutableStateFlow(
                EateryApiResponse.Success(
                    (eateryFlow.value as EateryApiResponse.Success<List<Eatery>>)
                        .data.find { it.id == eateryId }!!
                )
            )
        }

        // If not called yet or is in an error, re-ping.
        if (!eateryApiCache.contains(eateryId)
            || eateryApiCache[eateryId]!!.value is EateryApiResponse.Error
        ) {
            pingEatery(eateryId = eateryId)
        }

        return eateryApiCache[eateryId]!!
    }
}

We can see that this model has a cache nested as a part of the model. The model is now responsible for fetching all of its data, and this part has been abstracted away from the ViewModel. The model could also expose methods for modifying its data. Although this works great for a simple example such as read-only Eatery details, sometimes it can become complicated to have one file that calls the API and manages the ViewState in one.

We also have to try and find a delicate balance between the responsibilities of the Model and the ViewModel. For example, this EateryRepository already packages the data in a MutableStateFlow<EateryApiResponse<List<Eatery>>>. If I wanted a ViewModel for the screen that shows all eateries, then I would basically just be exposing this state flow, and not much else. This makes the Model feel more like a ViewModel, since it's already doing the work of aggregating the data into this more readable form of EateryApiResponse

In the real Eatery codebase, the ViewModel combines the userPreferencesRepository and the eateryRepository to aggregate the data and provide the View with information about what eateries are favorited. This gives a nice purpose for the ViewModel. But the point is that we never want to have a single Model have too many responsibilities, to the point where its corresponding ViewModel just becomes boilerplate.

Last updated