Using Android Architecture Components with Firebase Realtime Database (Part 2)
Welcome to part 2 of this blog series on using lifecycle-aware Android
Architecture Components (LiveData
and ViewModel
)
along with Firebase
Realtime Database to implement more robust and testable apps! In
the first part, we saw how you can use LiveData
and
ViewModel
to simplify your Activity code, by refactoring away most
of the implementation details of Realtime Database from an Activity. However,
one detail remained: the Activity was still reaching into the
DataSnapshot
containing the stock price. I'd like to remove all
traces of the Realtime Database SDK from my Activity so that it's easier to read
and test. And, ultimately, if I change the app to use Firestore instead of
Realtime Database, I won't even have to change the Activity code at
all.
Here's a view of the data in the database:
and here's the code that reads it out of DataSnapshot
and copies
into a couple TextViews:
// update the UI with values from the snapshot String ticker = dataSnapshot.child("ticker").getValue(String.class); tvTicker.setText(ticker); Float price = dataSnapshot.child("price").getValue(Float.class); tvPrice.setText(String.format(Locale.getDefault(), "%.2f", price));
The Realtime Database SDK makes it really easy to convert a DataSnapshot into a JavaBean style object. The first thing to do is define a bean class whose getters and setters match the names of the fields in the snapshot:
public class HotStock { private String ticker; private float price; public String getTicker() { return ticker; } public void setTicker(String ticker) { this.ticker = ticker; } public float getPrice() { return price; } public void setPrice(float price) { this.price = price; } public String toString() { return "{HotStock ticker=" + ticker + " price=" + price + "}"; } }
Then I can tell the SDK to automatically perform the mapping like this:
HotStock stock = dataSnapshot.getValue(HotStock.class)
After that line executes, the new instance of HotStock
will contain
the values for ticker
and price
. Using this handy
line of code, I can update my HotStockViewModel
implementation to
perform this conversion by using a transformation.
This allows me to create a LiveData
object that
automatically converts the incoming DataSnapshot
into a
HotStock
. The conversion happens in a Function
object, and I can assemble it like this in my ViewModel
:
// This is a LiveData<DataSnapshot> from part 1 private final FirebaseQueryLiveData liveData = new FirebaseQueryLiveData(HOT_STOCK_REF); private final LiveData<HotStock> hotStockLiveData = Transformations.map(liveData, new Deserializer()); private class Deserializer implements Function<DataSnapshot, HotStock> { @Override public HotStock apply(DataSnapshot dataSnapshot) { return dataSnapshot.getValue(HotStock.class); } } @NonNull public LiveData<HotStock> getHotStockLiveData() { return hotStockLiveData; }
The utility class Transformations
provides a static method map()
that returns a new LiveData
object given a source
LiveData
object and a Function
implementation. This new LiveData
applies the
Function
to every object emitted by the source, then turns around
and emits the output of the Function
. The
Deserializer
function here is parameterized by the input type
DataSnapshot
and the output type HotStock
, and it has
one simple job - deserialize a DataSnapshot
into a
HotStock
. Lastly, we'll add a getter for this new
LiveData
that emits the transformed HotStock
objects.
With these additions, the application code can now choose to receive updates to
either DataSnapshot
or HotStock
objects. As a best
practice, ViewModel
objects should emit objects that are fully
ready to be consumed by UI components, so that those components are only
responsible for displaying data, not processing data. This means that
HotStockViewModel
should be doing all the preprocessing required by
the UI layer. This is definitely the case here, as HotStock
is
fully ready to consume by the Activity that's populating the UI. Here's what
the entire Activity looks like now:
public class MainActivity extends AppCompatActivity { private TextView tvTicker; private TextView tvPrice; @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); tvTicker = findViewById(R.id.ticker); tvPrice = findViewById(R.id.price); HotStockViewModel hotStockViewModel = ViewModelProviders.of(this).get(HotStockViewModel.class);
LiveData<HotStock> hotStockLiveData = hotStockViewModel.getHotStockLiveData();
hotStockLiveData.observe(this, new Observer
() { @Override public void onChanged(@Nullable HotStock hotStock) { if (hotStock != null) { // update the UI here with values in the snapshot tvTicker.setText(hotStock.getTicker()); tvPrice.setText(String.format(Locale.getDefault(), "%.2f", hotStock.getPrice())); } } }); } }
All the references to Realtime Database objects are gone now, abstracted away
behind HotStockViewModel
and LiveData
! But there's
still one potential problem here.
What if a LiveData
transformation is expensive?
All LiveData
callbacks to onChanged()
run on the main
thread, as well as any transformations. The example I've given here is very
small and straightforward, and I wouldn't expect there to be performance
problems. But when the Realtime Database SDK deserializes a
DataSnapshot
to a JavaBean type object, it uses reflection to
dynamically find and invoke the setter methods that populate the bean. This can
become computationally taxing as the quantity and size of the objects increase.
If the total time it takes to perform this conversion is over 16ms (your budget
for a unit of work on the main thread), Android starts dropping frames. When
frames are dropped, it no longer renders at a buttery-smooth 60fps, and the UI
becomes choppy. That's called "jank",
and jank makes your app look poor. Even worse, if your data transformation
performs any kind of I/O, your app could lock up and cause an ANR.
If you have concerns that your transformation can be expensive, you should move
its computation to another thread. That can't be done in a transformation
(since they run synchronously), but we can use something called MediatorLiveData
instead. MediatorLiveData
is built on top of a map transform, and
allows us to observe changes other LiveData
sources, deciding what
to do with each event. So I'll replace the existing transformation with one
that gets initialized in the no-arg constructor for
HotStockViewModel
from part 1 of this
series:
private final FirebaseQueryLiveData liveData = new FirebaseQueryLiveData(HOT_STOCK_REF); private final MediatorLiveData<HotStock> hotStockLiveData = new MediatorLiveData<>(); public HotStockViewModel() { // Set up the MediatorLiveData to convert DataSnapshot objects into HotStock objects hotStockLiveData.addSource(liveData, new Observer<DataSnapshot>() { @Override public void onChanged(@Nullable final DataSnapshot dataSnapshot) { if (dataSnapshot != null) { new Thread(new Runnable() { @Override public void run() { hotStockLiveData.postValue(dataSnapshot.getValue(HotStock.class)); } }).start(); } else { hotStockLiveData.setValue(null); } } }); }
Here, we see that addSource()
is being called on the
MediatorLiveData
instance with a source LiveData
object and an Observer
that gets invoked whenever that source
publishes a change. During onChanged()
, it offloads the work of
deserialization to a new thread. This threaded work is using postValue()
to update
the MediatorLiveData
object, whereas the non-threaded work when
(dataSnapshot is null) is using setValue()
.
This is an important distinction to make, because postValue()
is
the thread-safe way of performing the update, whereas setValue()
may only be called on the main thread.
NOTE: I don't recommend starting up a new thread like this in your
production app. This is not an example of "best practice" threading
behavior. Optimally, you might want to use an Executor
with a pool of reusable threads (for
example) for a job like this.
There's still room for improvement!
Now that we've removed Realtime Database objects from the Activity and accounted
for the performance of the transformation from DataSnapshot
to
HotStock
, there's still another performance improvement to make
here. When the Activity goes through a configuration change (such as a device
reorientation), the FirebaseQueryLiveData
object will remove its
database listener during onInactive()
, then add it back during
onActive()
. While that doesn't seem like a problem, it's important
to realize that this will cause another (unnecessary) round trip of all data
under /hotstock
. I'd rather leave the listener added and save the
user's data plan in case of a reorientation. So, in the next part of this
series, I'll look at a way to make that happen.
I hope to see you next time, and be sure to follow @Firebase on Twitter to get updates on this series! You can click through to part 3 right here.