1. Introduction
Android KTX is a set of extensions for commonly used Android framework APIs, Android Jetpack libraries, and more. We built these extensions to make calling into Java programming language-based APIs from Kotlin code more concise and idiomatic by leveraging language features such as extension functions and properties, lambdas, named and default parameters, and coroutines.
What is a KTX library?
KTX stands for Kotlin extensions, and it's not a special technology or language feature of the Kotlin language in itself. It's just a name that we adopted for Google's Kotlin libraries that extend the functionality of APIs made originally in the Java programming language.
The nice thing about Kotlin extensions is that anyone can build their own library for their own APIs, or even for third-party libraries that you use in your projects.
This codelab will walk you through some examples of adding simple extensions that take advantage of Kotlin language features. We'll also take a look at how we can convert an asynchronous call in a callback-based API into a suspending function and a Flow - a coroutines-based asynchronous stream.
What you will build
In this codelab, you're going to work on a simple application that obtains and displays the user's current location. Your app will:
|
What you'll learn
- How to add Kotlin extensions on top of existing classes
- How to convert an async call returning a single result to a coroutine suspend function
- How to use Flow to obtain data from a source that can emit a value many times
What you'll need
- A recent version of Android Studio (3.6+ recommended)
- The Android Emulator or a device connected via USB
- Basic level knowledge of Android development and the Kotlin language
- Basic understanding of coroutines and suspending functions
2. Getting set up
Download the Code
Click the following link to download all the code for this codelab:
... or clone the GitHub repository from the command line by using the following command:
$ git clone https://github.com/googlecodelabs/kotlin-coroutines.git
The code for this codelab is in the ktx-library-codelab
directory.
In the project directory, you will find several step-NN
folders which contain the desired end state of each step of this codelab for reference.
We'll be doing all our coding work in the work
directory.
Running the app for the first time
Open the root folder (ktx-library-codelab
) in Android Studio, then select the work-app
run configuration in the dropdown as shown below:
Press the Run button to test your app:
This app is not doing anything interesting yet. It's missing a few parts to be able to display data. We'll add the missing functionality in the subsequent steps.
3. Introducing extension functions
An easier way to check for permissions
Even though the app runs, it simply shows an error - it's unable to get the current location.
That's because it's missing the code for requesting the runtime location permission from the user.
Open MainActivity.kt
, and find the following commented-out code:
// val permissionApproved = ActivityCompat.checkSelfPermission(
// this,
// Manifest.permission.ACCESS_FINE_LOCATION
// ) == PackageManager.PERMISSION_GRANTED
// if (!permissionApproved) {
// requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
// }
If you uncomment the code and run the app, it will request the permission and proceed to showing the location. However, this code is difficult to read for several reasons:
- It uses a static method
checkSelfPermission
from theActivityCompat
utility class, that exists only to hold methods for backwards-compatibility. - The method always takes an
Activity
instance as the first parameter, because it's impossible to add a method to a framework class in the Java programming language. - We're always checking if the permission was
PERMISSION_GRANTED
, so it would be nicer to directly get a booleantrue
if the permission is granted and false otherwise.
We want to convert the verbose code shown above to something shorter like this:
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
// request permission
}
We're going to shorten the code with the help of an extension function on Activity. In the project, you'll find another module called myktxlibrary
. Open ActivityUtils.kt
from that module, and add the following function:
fun Activity.hasPermission(permission: String): Boolean {
return ActivityCompat.checkSelfPermission(
this,
permission
) == PackageManager.PERMISSION_GRANTED
}
Let's unpack what is happening here:
fun
in the outermost scope (not inside aclass
) means we're defining a top-level function in the file.Activity.hasPermission
defines an extension function with the namehasPermission
on a receiver of typeActivity
.- It takes the permission as a
String
argument and returns aBoolean
that indicates whether the permission was granted.
So what exactly is a "receiver of type X"?
You will see this very often when reading documentation for Kotlin extension functions. It means that this function will always be called on an instance of an Activity
(in our case) or its subclasses, and inside the function body we can refer to that instance using the keyword this
(which can also be implicit, meaning we can omit it entirely).
This is really the whole point of extension functions: adding new functionality on top of a class that we can't or don't want to otherwise change.
Let's look at how we would call it in our MainActivity.kt
. Open it up and change the permissions code to:
if (!hasPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), 0)
}
If you run the app now, you can see the location displayed on the screen.
A helper for formatting Location text
The location text isn't looking too great though! It's using the default Location.toString
method, which wasn't made to be displayed in a UI.
Open the LocationUtils.kt
class in myktxlibrary
. This file contains extensions for the Location
class. Complete the Location.format
extension function to return a formatted String
, and then modify Activity.showLocation
in ActivityUtils.kt
to make use of the extension.
You can take at the code in the step-03
folder if you're having trouble. This is what the end result should look like:
4. The location API and async calls
Fused Location Provider from Google Play Services
The app project we're working on uses the Fused Location Provider from Google Play Services to get location data. The API itself is fairly simple, but due to the fact that obtaining a user location is not an instantaneous operation, all calls into the library need to be asynchronous, which complicates our code with callbacks.
There are two parts to getting a user's location. In this step, we're going to focus on getting the last known location, if it's available. In the next step, we're going to look at periodic location updates when the app is running.
Obtaining the last known location
In Activity.onCreate
, we're initializing the FusedLocationProviderClient
which will be our entry point to the library.
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
}
In Activity.onStart
, we then invoke getLastKnownLocation()
which currently looks like this:
private fun getLastKnownLocation() {
fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
showLocation(R.id.textView, lastLocation)
}.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to get location.")
e.printStackTrace()
}
}
As you can see, lastLocation
is an async call that can complete with a success or failure. For each of these outcomes, we have to register a callback function that will either set the location to the UI or show an error message.
This code doesn't necessarily look very complicated because of callbacks now, but in a real project you might want to process the location, save it to a database, or upload to a server. Many of these operations are also asynchronous, and adding callbacks upon callbacks would quickly make our code unreadable and could look something like this:
private fun getLastKnownLocation() {
fusedLocationClient.lastLocation.addOnSuccessListener { lastLocation ->
getLastLocationFromDB().addOnSuccessListener {
if (it != location) {
saveLocationToDb(location).addOnSuccessListener {
showLocation(R.id.textView, lastLocation)
}
}
}.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to read location from DB.")
e.printStackTrace()
}
}.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to get location.")
e.printStackTrace()
}
}
Even worse, the above code has problems with leaking memory and operations, because the listeners are never removed when the containing Activity
finishes.
We're going to look for a better way to solve this with coroutines, which let you write asynchronous code that looks just like a regular, top-down, imperative block of code without doing any blocking calls on the calling thread. On top of that, coroutines are also cancellable, letting us clean up whenever they go out of scope.
In the next step, we'll be adding an extension function that converts the existing callback API into a suspend function that is callable from a coroutine scope tied to your UI. We want the end result to look something like this:
private fun getLastKnownLocation() {
try {
val lastLocation = fusedLocationClient.awaitLastLocation();
// process lastLocation here if needed
showLocation(R.id.textView, lastLocation)
} (e: Exception) {
// we can do regular exception handling here or let it throw outside the function
}
}
5. Convert one-shot async requests to a coroutine
Creating a suspend function using suspendCancellableCoroutine
Open up LocationUtils.kt
, and define a new extension function on the FusedLocationProviderClient
:
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
suspendCancellableCoroutine { continuation ->
TODO("Return results from the lastLocation call here")
}
Before we go into the implementation part, let's unpack this function signature:
- You already know about the extension function and receiver type from the previous parts of this codelab:
fun FusedLocationProviderClient.awaitLastLocation
suspend
means this will be a suspending function, a special type of function that can only be called within a coroutine or from anothersuspend
function- The result type of calling it will be
Location
, as if it were a synchronous way of getting a location result from the API.
To build the result, we are going to use suspendCancellableCoroutine
, a low-level building block for creating suspending functions from the coroutines library.
suspendCancellableCoroutine
executes the block of code passed to it as a parameter, then suspends the coroutine execution while waiting for a result.
Let's try adding the success and failure callbacks to our function body, like we've seen in the previous lastLocation
call. Unfortunately, as you can see in the comments below, the obvious thing that we'd like to do - returning a result - is not possible in the callback body:
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
suspendCancellableCoroutine { continuation ->
lastLocation.addOnSuccessListener { location ->
// this is not allowed here:
// return location
}.addOnFailureListener { e ->
// this will not work as intended:
// throw e
}
}
That's because the callback happens long after the surrounding function has finished, and there's nowhere to return the result. That's where suspendCancellableCoroutine
comes in with the continuation
that is provided to our block of code. We can use it to provide a result back to the suspended function some time in the future, using continuation.resume
. Handle the error case using continuation.resumeWithException(e)
to properly propagate the exception to the call site.
In general you should always make sure that at some point in the future, you will either return a result or an exception to not keep the coroutine suspended forever while waiting for a result.
suspend fun FusedLocationProviderClient.awaitLastLocation(): Location =
suspendCancellableCoroutine<Location> { continuation ->
lastLocation.addOnSuccessListener { location ->
continuation.resume(location)
}.addOnFailureListener { e ->
continuation.resumeWithException(e)
}
}
That's it! We've just exposed a suspend version of the last known location API that can be consumed from coroutines in our app.
Calling a suspending function
Let's modify our getLastKnownLocation
function in MainActivity
to call the new coroutine version of the last known location call:
private suspend fun getLastKnownLocation() {
try {
val lastLocation = fusedLocationClient.awaitLastLocation()
showLocation(R.id.textView, lastLocation)
} catch (e: Exception) {
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
}
As mentioned before, suspending functions always need to be called from other suspending functions to ensure they're running within a coroutine, which means we have to add a suspend modifier to the getLastKnownLocation
function itself, or we'll get an error from the IDE.
Notice that we're able to use a regular try-catch block for exception handling. We can move this code from inside the failure callback because exceptions coming from the Location
API are now propagated correctly, just like in a regular, imperative program.
To start a coroutine, we would normally use CoroutineScope.launch
, for which we need a coroutine scope. Fortunately, Android KTX libraries come with several predefined scopes for common lifecycle objects such as Activity
, Fragment
, and ViewModel
.
Add the following code to Activity.onStart
:
override fun onStart() {
super.onStart()
if (!hasPermission(ACCESS_FINE_LOCATION)) {
requestPermissions(arrayOf(ACCESS_FINE_LOCATION), 0)
}
lifecycleScope.launch {
try {
getLastKnownLocation()
} catch (e: Exception) {
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
}
startUpdatingLocation()
}
You should be able to run your app and verify that it works before moving on to the next step, where we'll introduce Flow
for a function that emits location results multiple times.
6. Building a Flow for streaming data
Now we're going to focus on the startUpdatingLocation()
function. In the current code, we register a listener with the Fused Location Provider to get periodic location updates whenever the user's device moves in the real world.
To show what we want to achieve with a Flow
based API, let's first look at the parts of MainActivity
that we'll be removing in this section, moving them instead to implementation details of our new extension function.
In our current code, there's a variable for tracking if we started listening to updates:
var listeningToUpdates = false
There's also a subclass of the base callback class and our implementation for the location updated callback function:
private val locationCallback: LocationCallback = object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult?) {
if (locationResult != null) {
showLocation(R.id.textView, locationResult.lastLocation)
}
}
}
We also have the initial registration of the listener (which can fail if the user didn't grant necessary permissions), together with callbacks since it's an async call:
private fun startUpdatingLocation() {
fusedLocationClient.requestLocationUpdates(
createLocationRequest(),
locationCallback,
Looper.getMainLooper()
).addOnSuccessListener { listeningToUpdates = true }
.addOnFailureListener { e ->
findAndSetText(R.id.textView, "Unable to get location.")
e.printStackTrace()
}
}
Finally, when the screen is no longer active, we clean up:
override fun onStop() {
super.onStop()
if (listeningToUpdates) {
stopUpdatingLocation()
}
}
private fun stopUpdatingLocation() {
fusedLocationClient.removeLocationUpdates(locationCallback)
}
You can go ahead and delete all of those code snippets from MainActivity
, leaving just an empty startUpdatingLocation()
function that we will use later to start collecting our Flow
.
callbackFlow: a Flow builder for callback based APIs
Open LocationUtils.kt
again, and define another extension function on the FusedLocationProviderClient
:
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
TODO("Register a location listener")
TODO("Emit updates on location changes")
TODO("Clean up listener when finished")
}
There a few things that we need to do here to replicate the functionality that we just deleted from the MainActivity
code. We will be using callbackFlow()
, a builder function that returns a Flow
, which is suitable for emitting data from a callback based API.
The block passed to callbackFlow()
is defined with a ProducerScope
as its receiver.
noinline block: suspend ProducerScope<T>.() -> Unit
ProducerScope
encapsulates the implementation details of a callbackFlow
, such as the fact that there is a Channel
backing the created Flow
. Without going into details, Channels
are used internally by some Flow
builders and operators, and unless you're writing your own builder/operator, you don't need to concern yourself with these low-level details.
We're simply going to use a few functions that ProducerScope
exposes to emit data and manage the state of the Flow
.
Let's start by creating a listener for the location API:
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
for (location in result.locations) {
offer(location) // emit location into the Flow using ProducerScope.offer
}
}
}
TODO("Register a location listener")
TODO("Clean up listener when finished")
}
We'll use ProducerScope.offer
to send location data through to the Flow
as it comes in.
Next, register the callback with the FusedLocationProviderClient
, taking care to handle any errors:
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
for (location in result.locations) {
offer(location) // emit location into the Flow using ProducerScope.offer
}
}
}
requestLocationUpdates(
createLocationRequest(),
callback,
Looper.getMainLooper()
).addOnFailureListener { e ->
close(e) // in case of error, close the Flow
}
TODO("Clean up listener when finished")
}
FusedLocationProviderClient.requestLocationUpdates
is an asynchronous function (just like lastLocation
) that uses callbacks for signalling when it completes successfully and when it fails.
Here, we can ignore the success state, as it simply means that at some point in the future, onLocationResult
will be called, and we will start emitting results into the Flow
.
In case of failure, we immediately close the Flow
with an Exception
.
The last thing that you always need to call inside a block passed to callbackFlow
is awaitClose
. It provides a convenient place to put any cleanup code to release resources in case of completion or cancellation of the Flow
(regardless if it happened with an Exception
or not):
fun FusedLocationProviderClient.locationFlow() = callbackFlow<Location> {
val callback = object : LocationCallback() {
override fun onLocationResult(result: LocationResult?) {
result ?: return
for (location in result.locations) {
offer(location) // emit location into the Flow using ProducerScope.offer
}
}
}
requestLocationUpdates(
createLocationRequest(),
callback,
Looper.getMainLooper()
).addOnFailureListener { e ->
close(e) // in case of exception, close the Flow
}
awaitClose {
removeLocationUpdates(callback) // clean up when Flow collection ends
}
}
Now that we have all the parts (registering a listener, listening for updates and cleaning up), let's go back to MainActivity
to actually make use of the Flow
for displaying the location!
Collecting the Flow
Let's modify our startUpdatingLocation
function in MainActivity
to invoke the Flow
builder and start collecting it. A naive implementation might look something like this:
private fun startUpdatingLocation() {
lifecycleScope.launch {
fusedLocationClient.locationFlow()
.conflate()
.catch { e ->
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
.collect { location ->
showLocation(R.id.textView, location)
Log.d(TAG, location.toString())
}
}
}
Flow.collect()
is a terminal operator that starts the actual operation of the Flow
. In it, we will receive all location updates emitted from our callbackFlow
builder. Because collect
is a suspending function, it must run inside a coroutine, which we launch using the lifecycleScope.
You can also notice the conflate
()
and catch
()
intermediate operators that are called on the Flow
. There are many operators that come with the coroutines library that let you filter and transform flows in a declarative manner.
Conflating a flow means that we only ever want to receive the latest update, whenever the updates are emitted faster than the collector can process them. It fits our example well, because we only ever want to show the latest location in the UI.
catch
, as the name suggests, will allow you to handle any exceptions that were thrown upstream, in this case in the locationFlow
builder. You can think of upstream as the operations that are applied before the current one.
So what is the problem in the snippet above? While it won't crash the app, and it will properly clean up after the activity is DESTROYED (thanks to lifecycleScope
), it doesn't take into account when the activity is stopped (e.g. when it is not visible).
It means that not only we'll be updating the UI when it is not necessary, the Flow will keep the subscription to location data active and waste battery and CPU cycles!
One way to fix this, is to convert the Flow to a LiveData using the Flow.asLiveData
extension from the LiveData KTX library. LiveData
knows when to observe and when to pause the subscription, and will restart the underlying Flow as needed.
private fun startUpdatingLocation() {
fusedLocationClient.locationFlow()
.conflate()
.catch { e ->
findAndSetText(R.id.textView, "Unable to get location.")
Log.d(TAG, "Unable to get location", e)
}
.asLiveData()
.observe(this, Observer { location ->
showLocation(R.id.textView, location)
Log.d(TAG, location.toString())
})
}
The explicit lifecycleScope.launch
is no longer needed because asLiveData
will supply the necessary scope to run the Flow in. The observe
call actually comes from LiveData and has nothing to do with coroutines or Flow, it's just the standard way of observing a LiveData with a LifecycleOwner. The LiveData will collect the underlying Flow and emit the locations to its observer.
Because flow re-creation and collection will be handled automatically now, we should move our startUpdatingLocation()
method from Activity.onStart
(which can execute many times) to Activity.onCreate
:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
startUpdatingLocation()
}
You can now run your app and check how it reacts to rotation, pressing the Home and Back button. Check the logcat to see if new locations are being printed when the app is in the background. If the implementation was correct, the app should properly pause and restart the Flow collection when you press Home and then return to the app.
7. Wrap up
You've just built your first KTX library!
Congratulations! What you've achieved in this codelab is very similar to what would normally happen when building a Kotlin extensions library for an existing Java-based API.
To recap what we've done:
- You added a convenience function for checking permissions from an
Activity
. - You provided a text formatting extension on the
Location
object. - You exposed a coroutine version of the
Location
APIs for getting the last known location and for periodic location updates usingFlow
. - If you want, you could further clean up the code, add some tests, and distribute your
location-ktx
library to other developers on your team so they can benefit from it.
To build an AAR file for distribution, run the :myktxlibrary:bundleReleaseAar
task.
You can follow similar steps for any other API that could benefit from Kotlin extensions.
Refining the application architecture with Flows
We've mentioned before that launching operations from the Activity
as we did in this codelab is not always the best idea. You can follow this codelab to learn how to observe flows from ViewModels
in your UI, how flows can interoperate with LiveData
, and how you can design your app around using data streams.