Kotlin callbackFlow: A lightweight architecture for location-aware Android apps
I’ve been working towards updating the architecture of several Android apps that make heavy use of location services. Specifically, some of them use LocalBroadcastManager
, which has been deprecated, to share locations across app components like Services and Activities. Time for something new.
After reviewing numerous resources on 2021-era Android best practices, primarily focused on Android Jetpack, I prototyped a repository-based architecture that leveraged Room and Kotlin Flow. I wrote about the results in this article:
However, after finishing that project I had a nagging feeling that there was a simpler solution, especially for lightweight apps that don’t already include a database. This article explains my journey to a pure Kotlin Flow-based solution for location-aware apps.
Database buyer’s remorse
As a quick recap from the first article, we started with a solution where the Service passed locations to the Activity using a LocalBroadcastManager
:
By the end of the article we had a new architecture that leverages Flow from Room to observe location updates:
I started implementing this solution in my GPSTest app, which is a utility app for Global Navigation Satellite Systems (GNSS). GPSTest doesn’t currently store location information, which got me thinking — do I really need to add a full-fledged database to the app?
By adding a database I was introducing a lot of overhead:
- Increased APK size due to dependencies for Room
- Increased device storage requirements due to database footprint
- Possible execution time penalty for writing to/reading from persistent storage
- Increased memory requirements for the portions of Room and data loaded into memory
There are some mitigations for some of the above issues, like possibly running Room as an in-memory database instead of being backed by persistent storage, but those have their own tradeoffs (like possibly even more increased memory requirements, or execution time penalties for database bootstrap on each startup).
There had to be a better way…
The solution — callbackFlow
Taking a step back, I realized that I was primarily using Room for the Kotlin Flow implementation.
So what if I implemented my own Flow-based solution (let’s say a SharedLocationManager
) to manage location updates and subscriptions? Something like this:
For apps that don’t need to store a persistent location history, this is a lightweight solution that keeps the repository observer pattern via Flow but doesn’t require the addition of a database.
There are several key differences from the previous architecture:
- The Service is just another observer — Instead of the Service sending the location updates to the Repository, it observes the Repository via Flow just like the other consumers of location information (in this case, to update an ongoing notification). This means that because the Service isn’t the source of the location updates, other components in the app don’t need to wait for it to bind or start to observe locations.
- Lifecycle of
SharedLocationManager
is tied to the lifecycle of the observers — We need to be careful how we configure theSharedLocationManager
so it survives the creation and destruction of various app components when there is at least one remaining subscriber. And, when no more app components are subscribed, we can stop listening for new locations. - The most recent location is returned instead of multiple locations — We change the Flow to return a single
Location
instead ofList<Location>
, primarily because we don’t have a persistent store of locations. However, as we’ll see shortly, if theSharedLocationManager
was already running when a new component subscribes there is still a way to get access to locations going back to when theSharedLocationManager
started for the first subscriber.
So let’s look at the code. Similarly to the previous article, I’ll present this solution in context of what needs to be added to Google’s while-in-use-location code example to make it work with Kotlin Flow, but this time without using Room.
SharedLocationManager and callbackFlow 🌟
The star of the show here is the SharedLocationManager
that manages the Flow for location updates, so let’s look at this class first.
We use callbackFlow
, which is ideal for wrapping callback-based APIs with a Flow:
In the first few lines of code we set up the fusedLocationClient
and locationRequest
to define how frequently we want location updates, etc. — normal boilerplate stuff.
Then, a key line — we define a private _locationUpdates
variable that is assigned a callbackFlow<Location>
, which wraps the setup of the fused location provider. This private variable is exposed to external classes via the fun locationFlow(): Flow<Location>
function that you see at the bottom of the class — we’ll see this being called from the Activity and Service later (via the repository abstraction).
Let’s walk through the rest of the code that sets up the observers for the fused location provide within callbackFlow<Location> {...}
.
First, we define the callback
. In LocationCallback.onLocationResult()
, on every new LocationResult
we trySend()
the new location to the Flow — this enables any observers to receive the new location (and won’t result in exceptions like offer()
).
Next, we add the code that will start location updates. We pass that callback
along with the locationRequest
to fusedLocationClient.requestLocationUpdates()
, and add an OnFailureListener
that closes the Flow in case of error during setup.
Then, we define awaitClose()
that will stop the location updates when the Flow is closed.
We then set several attributes of the callbackFlow
via .shareIn()
:
externalScope
— It’s a best practice to pass in scopes versus defining it within the class. We’ll see where this comes from in the next section.replay
— Here’s that part where observers could potentially be notified ofLocations
from before when they subscribed (as long as theSharedLocationManager
was already started by a different subscriber). We set it to0
, which means that when subscribers subscribe they will only get location updates going forward. But if we set it to3
, subscribers would get the last 3Locations
(if they exist) upon subscription.started
— This is key to defining when the Flow is active. UsingSharingStarted.WhileSubscribed()
, it will start location updates when the first subscriber appears and immediately stop location updates when the last subscriber disappears. This ensures that as long as one app component has a coroutine that’s observing the flow, the location updates will continue. We just need to be careful to scope the observing coroutine to the lifecycle of that app component, which we will see in the following sections.
To stick with the repository-observer design pattern, we also abstract the SharedLocationManager
behind a more general LocationRepository
:
Now that we have our classes defined that manage location updates and makes them observable via callbackFlow
, how do we make it available to our app components?
Hilt implementation for dependency injection 🔪
Similar to last time, we’ll use Hilt for a dependency injection framework, which we can use to access the SharedLocationManager
from the Activity and Service (and ViewModel, Fragments, or wherever else you need it).
You may have notice the @Inject
annotation in the above LocationRepository.kt
class. This tells Hilt that we want to make this component available in other classes.
We also need to annotate the LocationApplication.kt
class to allow Hilt to manage the application (which is also where we define our applicationScope
that will be used in SharedLocationManager
):
…and define a DataModule.kt
class that indicates that we want a single, application-wide SharedLocationManager
to be created (hence the @InstallIn(SingletonComponent::class)
and @Singleton
annotations, and also why we use the LocationApplication
context applicationScope
):
Now, we can configure the Service and Activity to observe updates.
Activity
Let’s start with the MainActivity first. Here’s the UI of the app, which allows the user to start and stop location updates via a button:
Here’s the important part of the Activity code for observing the location Flow:
The initial setup using Hilt is the same as last time —we again inject the repository by annotating the class with @AndroidEntryPoint
and the repository
with @Inject
. We also setup the button OnClickListener to alternately call the subscribeToLocationUpdates()
and unsubscribeToLocationUpdates()
methods to start and stop location updates, respectively.
We setup the Flow in subscribeToLocationUpdates()
. We call repository.getLocations()
with flowWithLifecycle()
and launchIn()
with the Activity’s lifecycle
and lifecycleScope
respectively to ensure that this Flow will be closed when the Activity state is less than the STARTED state (e.g., when it is stopped or destroyed). Within .onEach {}
we simply update a TextView
with each new location latitude and longitude within the logResultsToScreen()
function.
To handle the case when the user taps on the button to stop location updates, we also add the locationFlow: Job?
variable at the stop of the class and assign the Flow to it. This allows us to manually close the flow in unsubscribeToLocationUpdates()
by calling locationFlow?.cancel()
even if the Activity is still STARTED (otherwise, the Flow and location updates would continue until we stopped or destroyed the Activity).
The subscribeToLocationUpdates()
and unsubscribeToLocationUpdates()
method also tells the Service that the user subscribed or unsubscribed to location updates, respectively.
Service
The main difference between the previous solution using Room and this one using the SharedLocationManager
is that the responsibility for managing the location updates is removed entirely from the Service. Instead, the Service is just another observer.
So instead of setting up the LocationCallback
when the Service starts, we instead need to start observing the Flow when the Service is started. This will allow us to update the ongoing notification with the latest location information.
Here are the key parts of the Service for observing the location Flow:
Again, we inject the repository by annotating the class with @AndroidEntryPoint
and the repository with @Inject
.
In subscribeToLocationUpdates()
, we first start the Service. Then, we again call repository.getLocations()
with flowWithLifecycle()
to observe the Flow and tie it to the lifecycle of the Service. Note that to use the lifecycleScope
here, we need our ForegroundOnlyLocationService
to extend a LifecycleService
instead of a normal Service
. In onEach{}
, we update the notification text with the currentLocation
.
The code in onStartCommand()
handles the case when the user taps on the “Stop receiving location updates” button in the Service notification. This triggers an intent to onStartCommand()
where cancelLocationTrackingFromNotification
is true
, which triggers the call to stopSelf()
to stop the service and to unsubscribeToLocationUpdates()
.
But wait…why do we need unsubscribeToLocationUpdates()
? And why do we have the locationFlow: Job?
variable in the Service? If the location updates are tied to the Service lifecycle, and we want the updates to start when the service starts and stop when the service stops, shouldn’t the link to the lifecycle be enough? That’s what I thought too. But when I implemented it that way, the Flow in the Service would continue when I tapped on the stop button in the Activity — even when the Service was stopped 🤔.
I started digging into the LifecycleService
source code to understand why this was happening. It dispatches lifecycle events to ServiceLifecycleDispatcher
, which is where I found my answer.
ServiceLifecycleDispatcher.onServicePreSuperOnBind()
calls postDispatchRunnable(Lifecycle.Event.ON_START)
. So if the Service is bound, the lifecycleScope still considers the lifecycle state to be at least STARTED
, even if the service itself it NOT started!
This is why the Flow was still active in the Service — even though it was stopped, the Service was still bound to the Activity! Thinking through this more, it actually makes sense. A Service can be bound but not started (i.e., the initial app state), and it can be started but not bound (when the Activity is destroyed after tracking has started). So neither state logically comes before the other.
So this is why we need unsubscribeToLocationUpdates()
and the call to locationFlow?.cancel()
— to handle the case when we tap on the button within the activity to stop the Service and it’s Flow.
Gradle configuration 🔧
The absence of Room makes this Gradle configuration simpler than before. You’ll need this in the root build.gradle
to include Hilt:
…and this in the application build.gradle
for the LifecycleService
and Hilt, and to use trySend()
instead of offer()
:
Putting it all together
Add all that together, and we have the same UI as before, but without a Room database. Now we have a SharedLocationManager
that uses callbackFlow
, and the Activity and Service both directly observe that Flow!
Closing thoughts
That’s it! You now have a fully working example of managing location updates via callbackFlow
and observing the Flow via an Activity and Service, with Hilt injecting the needed dependencies!
You can find the above as a working project at https://github.com/barbeau/while-in-use-location in the no-database
branch in the complete
folder. If you want to see the Git diff with the code changes from the original Google example, click here.
A few final observations:
- For simplicity in this example I haven’t changed where the state of the location tracking is stored, which is in the
SharedPreferences
. However, note that you could switch to usingStateFlow
within theSharedLocationManager
instead. - If you remove the button from the UI and have location updates start and stop when the Activity and Service start and stop, the solution is simpler — we could tie Flow entirely to the lifecycle of the Activity and Service and not need to call
locationFlow?.cancel()
. - Unlike the Room example, this solution doesn’t require translation of Android system classes to a separate set of project domain model classes— you can have Activities and Services directly observing system classes like
android.location.Location
from the Flow directly instead of creating your owncom.example.android.whileinuselocation.model.Location
object and filling it with the same data. I’ll leave you to debate whether this is a good architecture decision 😁. If you do decide to translate to your own domain model classes, you can use the.map
operator on the Flow to help with this.
Do you have suggestions on improving this design further? Any problems that you see? Please let me know in the comments below!
As I was thinking of this solution, I stumbled on a few Android code examples that pointed in this direction that deserve credit — this example is based in part on code from:
And here are some additional references I found helpful when learning about Kotlin Flow and callbackFlow:
- Google. “Kotlin: Simplifying APIs with coroutines — MAD Skills”, Dec 16, 2020. https://youtu.be/OmHePYcHbyQ and code lab.
- Google. “Going with the flow — Kotlin Vocabulary”, Nov 24, 2020, https://youtu.be/emk9_tVVLcc.
- Google. “Kotlin flows on Android — Convert callback-based APIs to flows”, https://developer.android.com/kotlin/flow#callback.
- Manuel Vivo (Google). “A safer way to collect flows from Android UIs”, March 24, 2021. https://medium.com/androiddevelopers/a-safer-way-to-collect-flows-from-android-uis-23080b1f8bda.
- Ryan Pierce, “Visualizing Kotlin Flow in Android”, November 10, 2020. https://www.youtube.com/watch?v=RoGAb0iWljg
Finally, thanks also to Chris Arriola for pointing out that trySend()
can replace offer()
!
Was this article helpful? Consider following me on Medium. You can also find me on Twitter and LinkedIn. If you’re a user of the open-source GPSTest app and you’d like to support it, you can check out the GPSTest “Buy me a coffee” page: