Effective Use of ViewModel with LiveData Builder & Transformations on Android
When we think about MVVM, the first thing that comes to our mind about the job of ViewModel
is to fetch data from a model layer. I did some research on how to make the best use of ViewModel
and encountered Architecture
related classes such as Transformations
and LiveData builder
. Today, let me introduce some use cases of them with the app that calls TwitterAPI.
MVVM data flow and ViewModel
How to implement ViewModel
which mediates View
(presentation) and Model
(data) is very important in terms of MVVM.
ViewModel
works just fine by making it catch some actions on View
, or retrieve some data from Model
. However, optimizing the management of internal LiveData properties could be a struggle.
In some other cases, ViewModel
could work as a data holder like savedInstanceState
, by taking advantage of VM’s nature which is to keep living until its owner dies. Also, this allows multiple Fragments
to refer to the same ViewModel
, which is very convenient.
ViewModel’s Data
By the way, you might be wondering why VM’s data is not cleared even when a structural change like a screen orientation shift caused onDestroy
on Activity
. This is because onDestroy
has two main causes and VM’s data is cleared only when isFinishing
is true
on onDestroy
, which happens when the os kills the Activity
.
Now, moving on to the example app.
The Tweets Search App
Basic flow
View
↑↓ ( LiveData<UIData>
)
ViewModel
(Translate or Filter)
↑↓ ( ModelData
)
Model
As shown above, ViewModel
receives the data from Model
( repository ), translates it into a View-friendly format, and passes it to View
as LiveData
.
Structure
InitializeFragment
which obtainsBearerToken
with OAuth2MainActivityFragment
on which we searchtweets
Fetching the Token
First, let’s get the bearer token we need to call the search API. In real life, we would probably store it on a config XML or something and load it depending on the building environment, or just the backend will manage everything. However, I thought this would be a good example, so I made it interactive.
Once it’s initialized, the token will be stored in Preference
.
In this screen, InitializeFragment is asking the API key pair if there is no saved token in Preference. We will save the token if the pair works fine on calling OAuth API, close the Fragment
, and open the search screen.
Flow
InitializeFragment
- fetchToken()
↑↓ observe: LiveData<NetworkState>
MainViewModel
- getBearerToken()
↑↓ Coroutine: TwitterBearerTokenResult
TwitterBearerTokenRepository
- getToken()
The key point here is that the type of data being observed
is not Token
but NetworkState
.
Classes
TwitterBearerTokenResult
is a class that wraps Token
and NetworkState
. The View
that observes NetworkState
will show progress, results, and errors based on the state.
The view does not need the token
The repository makes HTTP requests to fetch the token. It also manages the NetworkState
since it has the request state. However, if it returned this result directly to the view
, the token will be included about which the view
does not care. Furthermore, it would be a wrong task for a view to save the token to Preference
. The view
only cares about if the token is successfully obtained or not.
LiveData Builder
The LiveData
builder is here to save the day. With this builder, you can call repositories’ suspend functions with a designated CoroutineScope
, and return the state/value as a LiveData
. The callback passed in the argument would be called when the LiveData has active observers.
A Function Exposed to the UI - MainViewModel.kt
In this example, LOADING
would be fired first. The repository’s Coroutine
function would make a request to fetch a token while the UI is showing a loading screen. Then, the result will be reflected on the NetworkState
. The token would be saved on Success
and Error
logs would be printed. But the point here is that only the NetworkState
would be passed to the views.
The Caller Side - InitializeFragment.kt
Now, the view
can change the UI based on the state, which is the only thing it observes.
Fetching Tweets
Next, we will fetch tweets through the search API and render them with RecyclerView
.
We want the ViewModel to hold on to the tweet list even when the Fragment
gets recreated on orientation shifts. So, the Fragment
will observe the list in onCreate
to always be aware of the VM’s LiveData
.
Flow
MainActivityFragment
- fetchTweets()
↑↓ observe: LiveData<TweetDataResult>
MainViewModel
- search()
↑↓ Coroutine: TweetDataResult
TwitterBearerTokenRepository
- getToken()
This time, the same generic type is used. What we need to figure out is how to update the list and keep the list on recreation of the Fragment
.
Data Classes
MutableLiveData & MediatorLiveData
A quick solution for this situation could be: MutableLiveData(_tweets)
for updating tweets internally, LiveData(tweets)
for exposing to View
to observe
, and MediatorLiveData
for repository calling on search events.
The Search Function - MainViewModel.kt
This would work ok but is not quite a beautiful solution. It feels redundant that MediatorLiveData
is used inside search()
just to make the exposed LiveData
immutable, even though it’s not translating the data or mediating LiveData
sources. Would there be a better way to update the exposed LiveData
on search()
?
Transformations
Transformations
(ref link) is here for the better solution. This class has functions like map()
and switchMap()
. Though they are very similar, let’s look into the details.
Transformations.java
As you can see, they are both a function that returns LiveData
with a specific generic type. The first argument is a LiveData
which serves as a trigger, and the second one is a receiver function that takes the returned value of the trigger. On map()
, this function needs to return the generic value, and for switchMap()
the return value will be LiveData
.
You might think this is similar to the previous implementation of MediatorLiveData
, and it sure is returning MediatorLiveData
. So, it is technically the same except for the fact that takes two steps. The explanation for switchMap()
is below. (validation
is ignored)
switchMap Flow
- Create
MediatorLiveData(result)
and returns it addSource()
the first argumentLiveData(trigger)
toresult
- On
trigger
value updates, invoke the second argument callback with the value andaddSource()
the returnedLiveData(mSource)
toresult
- Update the
MediatorLiveData(result)
value onLiveData(mSource)
updates to notify its observers
Now, let’s refactor the MainViewModel
with this.
The Search Logic - MainViewModel.kt
Now, this looks cleaner. The MutableLiveData
searchWords
is the trigger, and we only expose tweetDataResults
to the view
. This list is the MediatorLiveData
returned from Transformations.switchMap(searchWords)
, which the view
can observe.
The trigger value will be updated on search actions, and this notifies the switchMap
and the lambda function repository.loadTweets()
runs. Finally, the view will update the UI when the repository returns a result.
The Caller Side - MainActivityFragment.kt
The search action would only update the search words. At this point, this feels clean enough for me.
TL;DR
The use of ViewModel
is optimized by using LiveData
builder and Transformations
. The beauty of MVVM is that a view receives optimized data by ViewModels even when the Model returns more complex and various data.
The git repository of this app is here.