Converting Firebase API Callbacks to Kotlin Flows
Using Kotlin’s callbackFlow
builder on Android
A short guide on when and why to use the callbackFlow
builder function in Android.
Click on the handsome man below for the full code.
The key to understanding when callbackFlow
is appropriate is to be able to recognise a “callback” in the first place.
Here are some “callbacks” that you use all the time:
setOnClickListener
addOnCompleteListener
addOnScrollListener
addUpdateListener
If you see “listener” in the code, chances are you are looking at a “callback”. callbackFlow
is a nifty little way to convert these listeners into Kotlin Flows.
But why would you want to do this?
Well, sometimes you don’t! A good rule of thumb is to ask yourself: am I going to receive a bunch of things (multi-shot), or am I just after one thing (single-shot)?
Let's look at our use cases from above:
setOnClickListener
— multi (can be clicked more than once)addOnCompleteListener
— single (things should complete once)addOnScrollListener
— multi (we get a bunch of scroll events)addUpdateListener
— multi (whenever something updates, we get a ‘callback’)
We can use callbackFlow
on all of these, but should we?
Button.clickFlow()
Let’s do something crazy. Let’s turn our everyday Button’s onClickListener
callback into a Flow. Just for fun.
Note that we use the function offer
instead of emit
inside callbackFlow
. In English, offer
is a softer verb than emit
— almost a question. An offer can be accepted… or not. This becomes important when dealing with backpressure, but for the purposes of this article, you can think of offer
and emit
as synonyms.
Moving on, let’s create a little extension function to collect our clicks:
Now we can hook this up in the collector like so:
buttonDownload.onClick { viewModel.download() }
… which is not much different from:
buttonDownload.setOnClickListener { viewModel.download() }
🤔 Hmmm, these look pretty similar!
So turning our button clicks into a Flow doesn’t make a whole lot of sense…unless, of course, we want to debounce!
😎
I’m being a bit facetious, of course. This kind of code is, in my experience, probably over-engineering things. However, the exercise itself is fun and provides a digestible way of exploring and understanding how callbackFlow
works and what it is designed for. If you want to see some useful examples where coroutines are used to simplify View related code check out https://chris.banes.dev/suspending-views/ and https://github.com/JakeWharton/RxBinding for some inspiration.
Let’s take a look at a few “callbacks” from the Firebase SDKs and create something more useful.
Querying a Record from Firestore
Let’s say we just want to get a single record from your remote database:
CollectionReference.get()
.addOnCompleteListener { snapshot ->
// Do something with snapshot
}
Since we only want a single snapshot, a suspend
function makes more sense in this case. Lucky for us, Google created a neat extension function (fun suspend Task.await()
) that wraps another awesome function from the kotlin library (suspendCancellableCoroutine
), which does exactly this.
You’ll need:
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:0"
so you can do this:
import kotlinx.coroutines.tasks.awaitval snapshot = CollectionReference.get().await()
But you can’t just call a suspend function from anywhere, you’ll need some coroutine scope:
implementation “androidx.lifecycle:lifecycle-runtime-ktx:0”
This gives us lifecycleScope
and viewModelScope
extension properties which we can use to create coroutines from our Views or ViewModels respectively, and automatically connects the coroutine lifecycle to the View or Viewmodel lifecycle.
💡 In Android, the View or ViewModel is typically where our scopes should be launched and where our Flows should be collected. If you find yourself writing this all the time:
GlobalScope.launch { } // Naughty, naughty!
…you’re probably doing it wrong.
Back to the example:
Neat right? Simple, easy to read code and all without the use of a Flow.
📎 Don’t use Flow if a suspend
function will do the job.
Watching for changes to a Firestore Database
When registering for changes to a certain part of a Firestore Database, we are expecting multiple “callbacks”, and so, we should find ourselves reaching for Flow, not a suspend function.
Not much different to our Button click Flow from above. Notice, however, how we just panic and bail if we get an error, and if we get a null or empty snapshot, we offer an emptyList
? This is poor programming and will almost certainly confuse the collector — even if you write the collector yourself! It’s better to propagate these signals to the collector. This makes your code more complex, yes, but also smarter. The client code can decide to do something different when an error or empty response is received: call another API instead, log an error, display an error, crash with an error, give the user a different option… whatever.
So, how can we encapsulate and propagate these signals to the caller/client code?
Let’s look at a different scenario and explore this idea.
Downloading a File from Firebase Storage
Downloading a file is a good mix of the above situations. It can take some time to download the file, but one way or another, it will complete. There are several ways that it can complete and there are several things we would like to know about during the download too. Firebase Storage has a nice set of “callbacks” to keep you informed about your download.
How do we convert all this to a Flow?
The first, second and third step is to think about how to model the information we are going to offer in the Flow, since a Flow must encapsulate a single type, let’s call it DownloadResult
and model all the relevant data we need in the client code.
Here’s what I came up with:
📎 Agonise over the structures that you put into your Flows. Modelling your data efficiently is the key to writing clean functional code.
InProgress
andPaused
contain information that enables the collector to pause, resume and cancel the task, as well as determine the progress.Success
contains theFile
which the collector can then do with as they please.Failed
contains the reason for the failure and allows the collector to react accordingly.
Now let’s have a look at how we would use it:
Note, this is a slimmed-down version of the real code to make it easier to digest the first time around, be sure to download the full repository for all the finer details.
We can now use a Redux-like pattern — whereby our UI state flows from the state
property:
Bonus Material
Two observations about offer()
- It is not a suspend function.
- It returns a Boolean.
When and how the underlying system (in this case Firebase) “calls back” is out of our control. This is why offer()
is not a suspend function, it is designed to operate outside the callbackFlow
coroutine scope (as is close()
).
offer
’s return value indicates whether it has succeeded in “offering” the value to the Flow or not. This is important in the Download File scenario because the final values offered just before the Flow is closed (DownloadResult.Complete.XXX
), are very important. They tell the collector how the download completed — was it a success or a failure? However, the flood of DownloadResult.InProgress
offerings could potentially clog the Flow and prevent the collector from receiving the last offering! Not good!
How do we get around this?
The good news is that you normally don’t have to worry about it. Flow covers the main use cases out-of-the-box and it’s only when you are doing “something fancy” that you’ll come across such an issue.
So, in the next article I’m going to do “something fancy” and dive deeper into this problem…