Working with MVVM – choosing the right Android architecture

Author Luka Leopoldović
Category Development
Date Mar 12, 2020
10 min read

In my previous blog, I’ve told you how and why we switched from a well established MVP architecture to the newly created MVVM. As promised, in this episode I’ll tell you a bit more about it, describe its key points and elaborate how it tackles all the cumbers which its predecessor (MVP) occasionally produced.

Just a reminder: both of our base projects (MVP, MVVM) can be cloned and used as a free starting pack. MVVM skeleton contains a demo project which demonstrates how to use many of our base classes and methods. So feel free to dig in!

STARTING UP

As the MVVM structure suggests, every screen we create (assuming there is a business logic) will contain three layers – the Model, the View and the ViewModel. The hierarchy of the layers is simple – they are stacked above each other, so each one only cares about the one it’s standing on. Hence, in a basic scenario, each layer (with the exception of the Model) depends only on the layer below it, which makes the code much more testable.

The graph below illustrates the hierarchy and the mutual dependencies of the layers.

When we agreed on the structure, we had to define the frameworks/tools/libraries to construct it. And yes, we had the debates as well – RxJava vs. Kotlin coroutines, Dagger vs. Koin, etc…

Long story short – we could just not waive all of the precious operators RxJava gives us, so we decided to stick with it. We picked Dagger over Koin due to much better performance, OkHttp and Retrofit for networking, Room for local caching and we used the rest of the JetPack components as the glue (LiveData, ViewModel) between the layers.

We started by creating the dependency injection graph. You can find the setup under the commons → dagger folder of our base project. I won’t hold onto it for long, since it’s a thing which requires the least interaction with while coding. Its main purpose is to provide us with components which can be injected and injected in. Therefore, make sure to contribute a new activity/fragment in binding modules and to provide yourself with a repo or a ViewModel in repo/ViewModel modules.

In our architecture, each vital component (a View/ViewModel/Repo) extends a base class which sets the foundations up. We decided to start building from the ground floor, so we commenced with the BaseRepo class. It might seem like reverse logic since we have to build for the upper floor(s) in advance, but it really helped us to shape the propagation of data which a ViewModel will eventually expose to its observers.

Let me just quickly remind you about the requirements (more about them in the previous blog) we kept up to while creating the new skeleton:

  • we want to reduce boilerplate to a minimum (e.g. RxJava calls etc.)
  • we want a set of default actions to be present under the hood (e.g. default loader or displaying error messages, default schedulers etc.), but again, those actions must be easily overridable if necessary
  • the code must be generic enough to fit all the cases we might encounter, and easily adaptable if there is a case which is not directly supported by the foundations

BaseRepo’s tasks include fetching data and firing API/database requests. For now, we created generic methods to make the calls and cut the boilerplate off regarding the Observables and the Completables. However, we will expand this in the near future to cover all the Rx return types. For now, we are happy with this since 99% of the requests we make return either an Observable (Single) or Completable.

In general, there are three cases which must be covered in order to fully close the circle:

  • A method which fires a request to either database or API (oneSideCall) and returns data
  • A method which fires a request to both database and API (twoSideCall) and returns data
  • A method which doesn’t return any data

Those methods, most certainly, must also provide us with some extra actions, in case we want to save the data returned by the call, or to perform certain actions in case the call fails.

For instance, take a look at our oneSideCall:

/**
 * Wraps the fetched data with Observable<[ResourceState]> for appropriate ViewModel provision.
 *
 * When fetching the data from an API, provide a @param saveCallData to store the fetched data
 * locally (optionally). Otherwise, use just @param call.
 *
 * Optionally, provide a @param performOnError definition if you would like to perform certain actions
 * in case and when the @param call fails (e.g. provide local data only in case the remote call fails).
 */
fun <T> oneSideCall(
    call: () -> Observable<T>,
    saveCallData: ((data: T) -> Unit)? = null,
    performOnError: () -> Observable<T> = { Observable.empty() }
): Observable<ResourceState<T>> {
    return call()
        .map { ResourceState.success(it) }
        .doAfterNext {
            if (saveCallData != null) {
                it.data?.let { newData -> saveCallData(newData) }
            }
        }
        .onErrorResumeNext { error: Throwable ->
            Observable.mergeDelayError(
                performOnError()
                    .map { ResourceState.success(it) }
                    .onErrorResumeNext { subError: Throwable ->
                        Observable.just(
                            ResourceState.error(
                                handleError(subError),
                                null
                            )
                        )
                    },
                Observable.just(
                    ResourceState.error(
                        handleError(error),
                        null
                    )
                )
            )
        }
} 

You are free to pass it any call which returns an Observable<T>, so map it, flatMap it or do whatever you want while passing it as an argument, but leave the boilerplate to the BaseRepo. In case that was an API call whose data must be saved, pass a database method as the saveCallData argument. In case you want to load the database data only when the remote call fails, pass a performOnError definition. Pretty handy, right?

Let’s take a look on how fetching the current weather for a city would look like using the oneSideCall:

override fun getCurrentWeatherForCity(cityName: String): Observable> {
    return oneSideCall(
        call = { api.getWeatherForCity(cityName) },
        saveCallData = { database.weatherDao().saveWeatherData(it) },
        performOnError = { database.weatherDao().getWeatherData(cityName) }
    )
}

That’s pretty neat! Now, you may have noticed that each of these BaseRepo methods wraps the result with a ResourceState. This is our custom wrapper class which classifies the data fetching state as either Success, Loading or Error.

/**
 * Created by lleopoldovic on 23/09/2019.
 *
 *
 * Base class to wrap each database/api provision with.
 *
 * Status values are used by view (activity/fragment) to indicate the fetch status of a request,
 * where ResourceState is commonly wrapped with a MutableLiveData property of a ViewModel.
 */ 

data class ResourceState<out T> (val status: Status, val data: T?, val error: BaseError?) {
    companion object {
        fun <T> loading(data: T? = null): ResourceState<T> {
            return ResourceState(Status.LOADING, data, null)
        }
        fun <T> success(data: T?): ResourceState<T> {
            return ResourceState(Status.SUCCESS, data, null)
        }
        fun <T> error(baseError: BaseError, data: T? = null): ResourceState<T> {
             return ResourceState(Status.ERROR, data, baseError)
        }
}
/**
 * Created by lleopoldovic on 23/09/2019.
 *
 *
 * Status of a resource that is provided to the view (activity/fragment).
 */ 
enum class Status {
    SUCCESS,
    ERROR,
    LOADING
}

That’s because we want to provide our users with proper feedback regarding the request state. Since our in-house backend team often creates custom messages for different types of errors which might occur, we want to present our users with the custom error message as well. This is another benefit of our BaseRepo methods. They will wrap every return data with proper ResourceState definition. Introduction of such a wrapper class can be expensive since it can easily tangle the whole data journey from a repo to a View. Therefore, we made sure to keep it under the hood and expose it to developers in the minimum of cases. Our BaseRepo methods will also handle any errors which might occur during the request. For easier propagation of the error information to other layers of the application, we created a BaseError class which holds all the error info together.

/**
 * Created by lleopoldovic on 25/10/2019.
 */ 

data class BaseError(
    val errorMessage: String = baseErrorMessage,
    val errorCode: Int = ResponseCodes.UNDEFINED.code,
    val validationErrors: ApiInterface.ValidationErrors = ApiInterface.ValidationErrors()
) {
    companion object {
        var baseErrorMessage: String = "Some error occurred.\nPlease try later."
        var localErrorMessage: String = "Local error occurred."
        var networkErrorMessage: String = "Network error, check your internet connection."
        var emptyErrorBodyMessage: String = "{\"message\":\"Empty error body\"}"
    }
}

If a validation error occurred and we want to specially mark an input field which relates to it (e.g. wrong e-mail), we can easily do that. If an authorization error occurs and we have to logout the user, we can easily pass an error code related to the error and, among other things, bring the user to the login screen, etc.

To sum the BaseRepo up:

  • Boilerplate reduced – CHECK
  • A set of actions present under the hood – CHECK
  • Generic and easily adaptable code – CHECK

LET’S GET THAT DATA

The middle man of this data delivery service is a ViewModel. Its responsibility is to request a data set from the Model (repo), parse it if necessary and expose it to whomever needs to observe it. The BaseViewModel class is much simpler than the BaseRepo class since it extends the ViewModel class which deals with most of the tricky stuff for us. Hence, we used it to auto-clear the CompositeDisposable and to truncate the boilerplate. Again, we have a couple of generic methods present under the hood.

For instance, let’s take a look at the observableCall method:

/**
 * Used for creating observable calls to remove RxJava boilerplate.
 *
 * On the other hand, if a custom action is required while doOnSubscribe and subscribe are
 * triggered, your are free to override the action. In that case, be sure to update your
 * liveData values manually.
 */
fun <T> observableCall(
    liveData: MutableLiveData<ResourceState<T>>,
    call: () -> Observable<ResourceState<T>>,
    doOnSubscribe: ((disposable: Disposable) -> Unit)? = null,
    subscribe: ((data: ResourceState<T>) -> Unit)? = null,
    scheduler: Scheduler = schedulers.io()
) {
    @Suppress("unused")
    call()
        .subscribeOn(scheduler)
        .observeOn(schedulers.ui())
        .doOnSubscribe {
            compositeDisposable.add(it)
            if (doOnSubscribe != null) {
                doOnSubscribe(it)
            } else {
                liveData.value = ResourceState.loading()
            }
        }
        .subscribe {
            if (subscribe != null) {
                subscribe(it)
            } else {
                liveData.postValue(it)
            }
        }
} 

The method updates the liveData values automatically, but you are free to more than easily override those actions if a custom work is required.

A call for getting the current weather now looks like this:

fun getCurrentWeatherData(city: String) {
    observableCall(
        liveData = _weatherData,
        call = { repo.getCurrentWeatherForCity(city) }
    )
}

Pretty clean and readable, right? We used RxJava and Observables to pass the information from a repo to a ViewModel, and now we’ll use LiveData to expose it to observers. Notice that if you open an Rx stream to an Observable Room query, it will never complete, so we are able to constantly listen for the Model updates, and that’s the reason behind the merge operator implementation of the BaseRepo methods!

In case the data has to be parsed, we created a map extension which enables you to map the data wrapped with the ResourceState without worrying about the wrapper class itself. Do what you have to do with the data, and we’ll make sure to unwrap it and wrap it back up to ResourceState for you.

fun <T, R> Observable<ResourceState<T>>.mapData(customMap: (item: T) -> R): Observable<ResourceState<R>> {
    return this.map { rs ->
        rs.data?.let { data ->
            ResourceState(
                rs.status,
                customMap(data),
                rs.error
            )
        } ?: ResourceState(rs.status, null, rs.error)
    }
}

Let’s recap:

  • Boilerplate reduced – CHECK
  • A set of easily overridable default actions present under the hood – CHECK
  • Generic and easily adaptable code – CHECK

CAN I FINALLY SEE IT

Yep. We’ve come to the top. There’s the View. Get it? 🙂

Before getting into observing the LiveData values from a ViewModel, I’ll quickly mention the BaseView interface. It provides us with a set of base methods each Activity/Fragment will implement. That gives us a bit of flexibility for the actions that follow. Each Activity/Fragment should extend the BaseActivity/BaseFragment class which deals with the mandatory dependency injection for you and makes sure you have access to the BaseView methods. And now comes the best part. It would be a hassle if we’d have to manually take care of displaying loading/error states of requests, even if they should be displayed with their default UI actions. So as the cherry on top, we created a custom observer for the ResourceState values wrapped with LiveData.

/**
 * Created by lleopoldovic on 02/10/2019.
 */ 
class ResourceStateObserver<T>(
    private val view: BaseView,
    private val onSuccess: (data: T?) -> Unit,
    private val onLoading: (isLoading: Boolean) -> Unit = DEFAULT_LOADING_ACTION,
    private val onError: (baseError: BaseError) -> Unit = DEFAULT_ERROR_ACTION
) : Observer<ResourceState<T>> {

    companion object {
        private val DEFAULT_ERROR_ACTION: (baseError: BaseError) -> Unit = {}
        private val DEFAULT_LOADING_ACTION: (isLoading: Boolean) -> Unit = {}
    }
    override fun onChanged(t: ResourceState<T>?) {
        // Loading state handler:
        onLoadingActionCheck(onLoading, isLoading = t?.status == Status.LOADING)

        // Error/success state handler:
        if (t?.status == Status.ERROR) {
            onErrorActionCheck(onError, baseError = t.error ?: BaseError(errorMessage = BaseError.baseErrorMessage))
            // If a response code needs to be handled specifically, add the new case here
            when (t.error?.errorCode) {
                ResponseCodes.UNAUTHORIZED.code -> view.onLogout()
            }
        } else if (t?.status == Status.SUCCESS) {
            onSuccess(t.data)
        }
    }
    private fun onLoadingActionCheck(
        onLoadingAction: (isLoading: Boolean) -> Unit = DEFAULT_LOADING_ACTION,
        isLoading: Boolean
    ) {
        if (onLoadingAction === DEFAULT_LOADING_ACTION) {
            // A lambda is NOT defined - use the default value
            view.showProgressCircle(isLoading)
        } else {
            // A lambda is defined - no default value used
            onLoading(isLoading)
        }
    }
    private fun onErrorActionCheck(
        onErrorAction: (baseError: BaseError) -> Unit = DEFAULT_ERROR_ACTION,
        baseError: BaseError
    ) {
        if (onErrorAction === DEFAULT_ERROR_ACTION) {
            // A lambda is NOT defined - use the default value
            view.showError(baseError.errorMessage)
        } else {
            // A lambda is defined - no default value used
            onError(baseError)
        }
    }
}

All it requires is the BaseView and a success action. In case you haven’t provided a custom loading/error action, the default ones will be executed. On the other hand, if you do want to provide them, it’s super-easy to do it!

So, binding UI with the fetched values and simultaneously taking care of displaying error/loading states has never been quicker:

viewModel.weatherData.observe(viewLifecycleOwner, ResourceStateObserver(this, 
    onSuccess = { /* Populate UI */ }
))

In case an extra action is required to display an error or a loading state, help yourselves to one of our many lambdas. 🙂

viewModel.weatherData.observe(viewLifecycleOwner, ResourceStateObserver(this,
    onSuccess = { /* Populate UI / },     
    onLoading = { /Custom loading action/ },     
    onError   = { /Custom error action*/ }
))

Again, let’s check our list:

  • Boilerplate reduced – CHECK
  • A set of easily overridable default actions present under the hood – CHECK
  • Generic and easily adaptable code – CHECK

PAGINATION

Since 95% of pagination we create (and we do it quite often) is page-keyed pagination, we decided to create a generic page-keyed paginator. You can find the code under common → mvvm → pagination. It is the JetPack PagingLibrary in a generic form to minimise the effort of establishing one, and maximize the results we can gain from it.

The setup consist out of three generic classes:

  • GenericPaginationDataSource
  • GenericPaginationDataSourceFactory
  • GenericPaginationParams

If you do plan to use those, make sure to extend the BasePaginationViewModel instead of the BaseViewModel, since it will provide you with additional methods for setting the paginator up, and it will wrap the return type of the paginator with the ResourceState. Since there is no paginated data available with the weather API, we integrated a news API to test it out. 🙂

This is all the code necessary to create a paginated news fetch:

class NewsViewModel @Inject constructor(
    schedulers: SchedulerProvider,
    private val newsRepo: NewsRepo
) : BasePaginationViewModel(schedulers) {

    // Lateinits
    private lateinit var newsDataSourceFactory: GenericPaginationDataSourceFactory<News, NewsPaginationParams>

    // LiveDatas
    lateinit var newsList: LiveData<ResourceState<PagedList<News>>>

    // Other
    private var paginationParams: NewsPaginationParams = NewsPaginationParams("").apply {
        pageNumber = 1
        loadBefore = false
    }
    init {
        newsDataSourceFactory = generatePaginationDataSourceFactory(
            paginationParams = paginationParams,
            call = { newsRepo.getNewss(it as NewsPaginationParams) }
        )
        val newsPagedListConfig = generateDefaultPaginationConfig(
            paginationParams = paginationParams
        )
        newsList = buildLivePagedListWithState(newsDataSourceFactory, newsPagedListConfig)
    }
    fun search(query: String) {
        newsDataSourceFactory.reInit(paginationParams.apply {
            this.query = query; this.pageNumber = 1
        })
    }
}
data class NewsPaginationParams(
    var query: String
): GenericPaginationParams()
viewModel.newsList.observe(viewLifecycleOwner, ResourceStateObserver(this,
    onSuccess = { it?.let { data -> newsListAdapter.submitList(data) } }
))

CONCLUSION

There are plenty of other things involved with this base project, like the MyFirebaseMessagingService, NotificationChannelsHelper etc. which I haven’t mentioned, and which do play an important role in everyday coding. We might cover those in another blog. Until then, feel free to explore the rest of the setup and the rest of the helper classes and methods as well. We also have a bonus dependency implemented – the Bornfight Android Utils which are giving us an even better boost:

  • GenericAdapter (recycler view)
  • GenericArrayAdapter
  • TextViewUtils

Combined with our new architecture, they create a real powerhouse. 🙂

Now that we’ve introduced you to our new setup, feel free to give it a shot and tell us what you think about it! In time, it will certainly experience some makeovers, extra polishing and fine-tuning, but even now it amps us up by giving us additional boost right from the start.

_____
We’re available for partnerships and open for new projects.
If you have an idea you’d like to discuss, share it with our team!

Subscribe to our newsletter

Subscribe now and get our top news once a month.

100% development, design & strategy.

100% spam-free.

You're now a part of our club!

We got your e-mail address and you'll get our next newsletter!