Android - Best Practices for ViewModel State in MVVM?
Asked Answered
S

2

32

I am working on an Android App using the MVVM pattern along LiveData (possibly Transformations) and DataBinding between View and ViewModel. Since the app is "growing", now ViewModels contain lots of data, and most of the latter are kept as LiveData to have Views subscribe to them (of course, this data is needed for the UI, be it a Two-Way Binding as per EditTexts or a One-Way Binding). I heard (and googled) about keeping data that represents the UI state in the ViewModel. However, the results I found were just simple and generic. I would like to know if anyone has hints or could share some knowledge on best practices for this case. In simple words, What could be the best way to store the state of an UI (View) in a ViewModel considering LiveData and DataBinding available? Thanks in advance for any answer!

Staple answered 9/6, 2018 at 17:5 Comment(0)
L
58

I struggled with the same problem at work and can share what is working for us. We're developing 100% in Kotlin so the following code samples will be as well.

UI state

To prevent the ViewModel from getting bloated with lots of LiveData properties, expose a single ViewState for views (Activity or Fragment) to observe. It may contain the data previously exposed by the multiple LiveData and any other info the view might need to display correctly:

data class LoginViewState (
    val user: String = "",
    val password: String = "",
    val checking: Boolean = false
)

Note, that I'm using a Data class with immutable properties for the state and deliberately don't use any Android resources. This is not something specific to MVVM, but an immutable view state prevents UI inconsistencies and threading problems.

Inside the ViewModel create a LiveData property to expose the state and initialize it:

class LoginViewModel : ViewModel() {
    private val _state = MutableLiveData<LoginViewState>()
    val state : LiveData<LoginViewState> get() = _state

    init {
        _state.value = LoginViewState()
    }
}

To then emit a new state, use the copy function provided by Kotlin's Data class from anywhere inside the ViewModel:

_state.value = _state.value!!.copy(checking = true)

In the view, observe the state as you would any other LiveData and update the layout accordingly. In the View layer you can translate the state's properties to actual view visibilities and use resources with full access to the Context:

viewModel.state.observe(this, Observer {
    it?.let {
        userTextView.text = it.user
        passwordTextView.text = it.password
        checkingImageView.setImageResource(
            if (it.checking) R.drawable.checking else R.drawable.waiting
        )
    }
})

Conflating multiple data sources

Since you probably previously exposed results and data from database or network calls in the ViewModel, you may use a MediatorLiveData to conflate these into the single state:

private val _state = MediatorLiveData<LoginViewState>()
val state : LiveData<LoginViewState> get() = _state

_state.addSource(databaseUserLiveData, { name ->
    _state.value = _state.value!!.copy(user = name)
})
...

Data binding

Since a unified, immutable ViewState essentially breaks the notification mechanism of the Data binding library, we're using a mutable BindingState that extends BaseObservable to selectively notify the layout of changes. It provides a refresh function that receives the corresponding ViewState:

Update: Removed the if statements checking for changed values since the Data binding library already takes care of only rendering actually changed values. Thanks to @CarsonHolzheimer

class LoginBindingState : BaseObservable() {
    @get:Bindable
    var user = ""
        private set(value) {
            field = value
            notifyPropertyChanged(BR.user)
        }

    @get:Bindable
    var password = ""
        private set(value) {
            field = value
            notifyPropertyChanged(BR.password)
        }

    @get:Bindable
    var checkingResId = R.drawable.waiting
        private set(value) {
            field = value
            notifyPropertyChanged(BR.checking)
        }

    fun refresh(state: AngryCatViewState) {
        user = state.user
        password = state.password
        checking = if (it.checking) R.drawable.checking else R.drawable.waiting
    }
}

Create a property in the observing view for the BindingState and call refresh from the Observer:

private val state = LoginBindingState()

...

viewModel.state.observe(this, Observer { it?.let { state.refresh(it) } })
binding.state = state

Then, use the state as any other variable in your layout:

<layout ...>

    <data>
        <variable name="state" type=".LoginBindingState"/>
    </data>

    ...

        <TextView
            ...
            android:text="@{state.user}"/>

        <TextView
            ...
            android:text="@{state.password}"/>

        <ImageView
            ...
            app:imageResource="@{state.checkingResId}"/>
    ...

</layout>

Advanced info

Some of the boilerplate would definitely benefit from extension functions and Delegated properties like updating the ViewState and notifying changes in the BindingState.

If you want more info on state and status handling with Architecture Components using a "clean" architecture you may checkout Eiffel on GitHub.

It's a library I created specifically for handling immutable view states and data binding with ViewModel and LiveData as well as glueing it together with Android system operations and business use cases. The documentation goes more in depth than what I'm able to provide here.

Ledford answered 23/6, 2018 at 14:16 Comment(25)
Awesome! Thank you very much! I will still have some hard time understanding some concepts of Kotlin, since I am currently coding in Java, but I will figure it out in some way. The greatest struggle will be to make something like the copy function, but overall your answer will surely be useful! Upvoted, of course.Staple
@Giordano I don't have any good recommendation except for giving Kotlin a try? ;) It looks like it will be the future for Android development. You may be able to cook up something similar in Java using Copy constructors, Cloneable or the Builder pattern but nothing as straightforward.Ledford
I personally think this MVI style approach isn't necessary in mobile development. It makes sense in the web world where you can have a lot more happening on the screen and keeping track of the view state is an issue. However on android generally you'll only have something like a loading spinner, a list, or an error message and individual LiveData properties for these features are enoughChryselephantine
@Etienne Lenhart Does this mean for a given Activity or Fragment, it is much more recommended to expose only one LiveData which holds all the UI State?Imparadise
@CarsonHolzheimer Well, I guess it depends on the scale and complexity of the app. Maybe you're observing multiple data sources, some UI elements need to update according to a combination of events or you need to take another source's state into account when something changes. Keeping everything in an immutable ViewState allows for much more predictable behavior.Ledford
@ArchieG.Quiñones It depends on what you consider "recommended". Google's samples often expose multiple LiveData and if that fits your needs, go for it. Once you get to more complex apps with advanced business logic I personally would recommend a single LiveData. That's also what Florina Muntenescu showcases in her talk on app architecture. But again, it's always good to consult recommendations and then use whatever works for your specific use case.Ledford
@EtienneLenhart I don't disagree, but there is a cost of extra boilerplate to follow that pattern. In the two largish enterprise apps I've worked on, keeping track of UI state was never an issue.Chryselephantine
@CarsonHolzheimer Yeah, there definitely is a bit of boilerplate. That's why I created the library. It keeps setup code pretty minimal.Ledford
@CarsonHolzheimer Sorry, hit Enter to soon... I'm curious, what pattern you find more useful though. Especially how you'd handle "rendering" changes coming from multiple LiveData. Having a "single stream of truth" seems easier to handle to me. And MediatorLiveData still allows to separate all components and just bundles all information into a single stream.Ledford
@EtienneLenhart The approach I'm taking with the current app is bundling things together that might logically change together, like data class SearchState(val status: Status, val data: List<String>, val errorMsg: String) but then just query: LiveData<String> and searchBarExpanded: LiveData<Boolean> for example. That way I automatically only re-render (mostly) what needs to change and I don't need to add everything as a source to the single MediatorLiveData. I think it keeps it easier to read, but for a complicated app I can see your strategy would keep things more controlledChryselephantine
@EtienneLenhart Also I read that the Databinding library handles by itself only updating a view if the value actually changes. Is that true? Do you really need to handle it like you do above?Chryselephantine
@CarsonHolzheimer Ah, so you kinda expose more focused states. I already thought about leveraging sealed classes either as ViewState or parts of a ViewState to bundle related information. I also like your approach and think it almost comes down to personal preference then. About Data binding: Do you happen to remember where you've read this? I tried to find out if calling notifyPropertyChanged causes a rerender regardless of an actually changed value but didn't have the time to investigate this more. I'd still use a BindingState though, since there'd be too much logic in the XML otherwise.Ledford
@EtienneLenhart I read it here. An interesting read, he takes the redux approach even further with Actions and Reducers.Chryselephantine
@CarsonHolzheimer Thank you for the link. I actually created the library as a simplified version of the Redux architecture, since I found it too difficult to grasp and use. I guess I could remove the if (field != value) {...} then. I'll update the answer. Thanks for the discussion and your insights.Ledford
Wow did you update your answer which included the example above. I've visited this before but i dont remember the example above. anyway great job! :DImparadise
@ArchieG.Quiñones Not exactly sure what you mean? I only updated the "Data binding" sample to remove the if (field != value) {...} statements. Thanks anyway, though. :)Ledford
I liked the idea of a single state to observe on a viewmodel, but I was really uneasy with the !! double bang used in the example. as i found out it works perfect with state.value?.copy(checking = true) was !! intentional ?Ingram
arent we refreshing a lot of views in this case with observer working on complete state change ?Ingram
@DivyanshuNegi The double bang is simply used as a "fail-fast" approach. Since state.value = LoginViewState() has to be called before any sensible updateState() can be performed, !! makes sure that you get a crash should you forget to set an initial state.Ledford
@DivyanshuNegi If you're using Data binding with the BaseObservable the included BindingAdapters already take care of only updating the view if the respective value changed. When using the manual approach which simply updates views in the Observer you'd have to track changes yourself. Although I'm currently working on a complete rewrite of the linked library on GitHub that'll allow observing specific state properties for changes. It's already available on the master branch if you'd like to check it out, just not released yet.Ledford
@EtienneLenhart Thanks for the reply, would surely take a look at your libraryIngram
@EtienneLenhart, It is best practice to only expose immutable LiveData objects as opposed to MutableLiveData publicly because the values may be unintentionally altered outside of the ViewModel. The Android team recommends this best practice as described in the 2018 Android Developer Summit talk Fun with LiveData.Simonson
@EtienneLenhart, Is there a negative memory impact by creating a shallow copy? Since the LiveData value is being updated it seems to be a non issue as the old value should be dropped from memory.Simonson
@Simonson Thanks for pointing out the exposed mutable LiveData, I updated the answer. Considering the copy impact: To ensure a truly immutable state you have to make a copy at some point. Since Kotlin's copy mechanism makes a shallow copy, as you already pointed out, copying should be quite fast and easily collectable for the ART GC. I haven't thoroughly tested this though.Ledford
This approach is very convenient when it comes to tracking simple ui, believe it or not when it comes to animating stuff then it starts to become troublesome. Thanks for sharing anyway, pick your best weapon for your specific case, for some it will work for me it didn't and separate live data's did much better job - in keeping app clean and open for new changes as well. Additionally in my case it led to many updates of states of different views when single property changed, maybe there would be some simple solution for that, but still that's some boilerplate to be added there.Willodeanwilloughby
S
8

Android Unidirectional Data Flow (UDF) 2.0

Update 12/18/2019: Android Unidirectional Data Flow with LiveData — 2.0

I've designed a pattern based on the Unidirectional Data Flow using Kotlin with LiveData.

UDF 1.0

Check out the full Medium post or YouTube talk for an in-depth explanation.

Medium - Android Unidirectional Data Flow with LiveData

YouTube - Unidirectional Data Flow - Adam Hurwitz - Medellín Android Meetup

Code Overview

Step 1 of 6 — Define Models

ViewState.kt

// Immutable ViewState attributes.
data class ViewState(val contentList:LiveData<PagedList<Content>>, ...)

// View sends to business logic.
sealed class ViewEvent {
  data class ScreenLoad(...) : ViewEvent()
  ...
}

// Business logic sends to UI.
sealed class ViewEffect {
  class UpdateAds : ViewEffect() 
  ...
}

Step 2 of 6 — Pass events to ViewModel

Fragment.kt

private val viewEvent: LiveData<Event<ViewEvent>> get() = _viewEvent
private val _viewEvent = MutableLiveData<Event<ViewEvent>>()

override fun onCreate(savedInstanceState: Bundle?) {
    ...
    if (savedInstanceState == null)
      _viewEvent.value = Event(ScreenLoad(...))
}

override fun onResume() {
  super.onResume()
  viewEvent.observe(viewLifecycleOwner, EventObserver { event ->
    contentViewModel.processEvent(event)
  })
}

Step 3 of 6 — Process events

ViewModel.kt

val viewState: LiveData<ViewState> get() = _viewState
val viewEffect: LiveData<Event<ViewEffect>> get() = _viewEffect

private val _viewState = MutableLiveData<ViewState>()
private val _viewEffect = MutableLiveData<Event<ViewEffect>>()

fun processEvent(event: ViewEvent) {
    when (event) {
        is ViewEvent.ScreenLoad -> {
          // Populate view state based on network request response.
          _viewState.value = ContentViewState(getMainFeed(...),...)
          _viewEffect.value = Event(UpdateAds())
        }
        ...
}

Step 4 of 6 — Manage Network Requests with LCE Pattern

LCE.kt

sealed class Lce<T> {
  class Loading<T> : Lce<T>()
  data class Content<T>(val packet: T) : Lce<T>()
  data class Error<T>(val packet: T) : Lce<T>()
}

Result.kt

sealed class Result {
  data class PagedListResult(
    val pagedList: LiveData<PagedList<Content>>?, 
    val errorMessage: String): ContentResult()
  ...
}

Repository.kt

fun getMainFeed(...)= MutableLiveData<Lce<Result.PagedListResult>>().also { lce ->
  lce.value = Lce.Loading()
  /* Firestore request here. */.addOnCompleteListener {
    // Save data.
    lce.value = Lce.Content(ContentResult.PagedListResult(...))
  }.addOnFailureListener {
    lce.value = Lce.Error(ContentResult.PagedListResult(...))
  }
}

Step 5 of 6 — Handle LCE States

ViewModel.kt

private fun getMainFeed(...) = Transformations.switchMap(repository.getFeed(...)) { 
  lce -> when (lce) {
    // SwitchMap must be observed for data to be emitted in ViewModel.
    is Lce.Loading -> Transformations.switchMap(/*Get data from Room Db.*/) { 
      pagedList -> MutableLiveData<PagedList<Content>>().apply {
        this.value = pagedList
      }
    }
    is Lce.Content -> Transformations.switchMap(lce.packet.pagedList!!) { 
      pagedList -> MutableLiveData<PagedList<Content>>().apply {
        this.value = pagedList
      }
    }    
    is Lce.Error -> { 
      _viewEffect.value = Event(SnackBar(...))
      Transformations.switchMap(/*Get data from Room Db.*/) { 
        pagedList -> MutableLiveData<PagedList<Content>>().apply {
          this.value = pagedList 
        }
    }
}

Step 6 of 6 — Observe State Change!

Fragment.kt

contentViewModel.viewState.observe(viewLifecycleOwner, Observer { viewState ->
  viewState.contentList.observe(viewLifecycleOwner, Observer { contentList ->
    adapter.submitList(contentList)
  })
  ...
}
Simonson answered 18/6, 2019 at 0:8 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.