Goodbye LiveData, Hello SharedFlow
Replacing LiveData with SharedFlow
Recently, I was working on refactoring an old module in our app, and had the chance to see if using
SharedFlow as a replacement for
LiveData would be practical. The goal of this article is showcasing a few scenarios you can stumble upon, and how coroutines handle them (hopefully without writing extra code for functionality).
Some of the cases were not as straightforward as just collecting stuff, so I thought it’d be interesting to share my findings.
P.S. I’ll not focus on collecting / lifecycle awareness in this article.
Overview of ViewModel
Before going into details, I want to give an overview of what kind of requirements we had in ViewModel.
We have two data fields, Ratings and Reviews. Each is requested separetly, and returns a
Result which can be
Failure or a
State . All these have their own data holders that are being observed (eg,
ratingsState). They have different handling requirements than others.
We also have a
userEvent data container. An
event is when user interacts with app that should change ui as a result, eg. when user wants to see sorting options, an
Event.SortingOptions(options) is fired with available sorting options.
Setting up SharedFlows for data fields
Let’s start with defining our data containers for a successfull
First of all, I don’t have a default or initial value for
Ratings . Thus I use
SharedFlow instead of
SharedFlow has three constructor parameters:
onBufferOverflow . By default, it has 0
extraBufferCapacity , and
BufferOverflow.SUSPEND as overflow strategy.
Ratings is using
replay = 1 because whenever I start observing ratings, I want to access current data. This would cover cases where I have multiple subscribers or resubscribe after a configuration change.
On a successfull fetch, I just set data to it via
tryEmit is non-suspending version of
emit, and returns a
Boolean indicating if it succesfully emmited the value or not. As explained per docs, if you create a
SharedFlow without any
tryEmit will always return
false. You can think of it as
offermethod of a deque that has capacity of
replay + extraBufferCapacity.
Although I request
Ratings once and there’s no other case where it’s requested again, checking the result and printing an error on failure guarantees that if any other team member tries to fetch
Ratings more then once later on, and emit fails, they’ll see it in console.
Reviews can be emitted multiple times based on events — like sorting or category. On top of this, there’s a different message to be shown if
Reviews has gone empty after selecting a category or not. If you have no reviews for a product from the start, it would say
There're no reviews for this product whilst if you chose a category and there are no reviews there, it’d say
There're no reviews in category you chose.
First and foremost, I’m interested in last two results, so
replay = 2. In case user is spamming categories, I won’t be caring about old results anymore, thus using
BufferOverflow.DROP_OLDEST as overflow strategy.
tryEmit to emit
Reviews, but not checking result this time. Since I’m using
tryEmit will never fail, and keep dropping oldest values that are emitted.
Now, whenever I start collecting
reviews I’ll be getting latest two values. On top of this, to fullfill requirement for setting text, I can make use of
reviews.replyCache, eg. in fragment / activity
Handling state is not really different than
State, in this case, is just a simple
They both have one replay, and drops oldest as new states are coming in. If you think of how I described data logic above,
_ratingsState will actually just receive
Loaded once, then return
Loaded every time it’s collected.
_reviewState however, may be updated as long as user is interacting.
On UI side, I don’t need two separate states for displaying loading indicator, so I combine
state as follows:
If both states are
state will emit
combine block will be called each time
_reviewsState is changed, and I don’t need to handle multiple
Loading states as loading will be visible until
Loaded is emited. Thus adding
distinctUntilChanged() flow operator to eliminate in-between states. This will keep posting correct states as user interacts with app and
_reviewState changes, and won’t trigger collection unless state actually changes!
Lastly, both states are set in a suspending manner, so non of the states are missed.
Events are handled differently than other types: they are one shot. If there’s an event, it should be suspended until it is consumed. There shouldn’t be multiple subscribers to events.
Currently, there’s no out-of-the-box support in
SharedFlow for suspending sending values until there’s a subscriber, nor consuming sent value (to prevent multiple subscribers from processing same value).
For this, we can use a
Channel and expose it as flow by using
This guarantees that
_events is suspended until there’s a receiver for the events. Since
consumeAsFlow is used, it’ll throw an
IllegalStateException if multiple subscriptions happen. If
receiveAsFlow is used, resulting
flow can be collected by multiple collectors, but collectors won’t receive same values, which can be handy when required.
In the end,
Channel was able to cover up all cases I need for refactoring, and without having to code single extension / subclass for functionalities I required, which was what I aimed for :)
Few small tips I found out along the way for ease of use:
- To behave like
replay = 1and
onBufferOverflow = BufferOverflow.DROP_OLDEST
- Be conscious when using
- Remember collection is not lifecycle aware like
collectis suspending. If you have some code in
launchblock but after where you collect the flow, it may not get executed as you expect. Remember
SharedFlowis a hot flow !