This is the fifth part of a series of articles that dive into creating a new Android application from scratch using Jetpack Compose for the user interface and some other tools offered by Google, like Firebase Authentication, Crashlytics, Cloud Firestore, Performance Monitoring, Remote Config, Firebase Extensions, Firebase Cloud Messaging and Hilt.
Part 1 of this series has an overview of what you can do with this app and how it is structured. Part 2 shows how to implement login and sign up flows using Firebase Authentication. Part 3 covers how to add Firebase Crashlytics to handle crashes and display messages to the user, and part 4 covers how to store data in the cloud using Cloud Firestore.
In this fifth part, we will see how to change the services in our codebase to use Kotlin Coroutines, instead of callbacks, to return the result of calls to the ViewModels
. Using Kotlin Coroutines will also enable us to add Kotlin Flow to our application!
Recap
Before we jump into how to change the services and use Coroutines and Flow
, let’s remember how it works using the callback approach to call Firebase API methods and return the response to the ViewModels
.
Firebase Authentication
We can see in the snippet below what the authentication service interface used to look like. Each one of the three methods shown below (for creating an anonymous account, authenticating via email and password, and linking credentials) has a callback called onResult
as a parameter:
interface AccountService {
fun createAnonymousAccount(onResult: (Throwable?) -> Unit)
fun authenticate(email: String, password: String, onResult: (Throwable?) -> Unit)
fun linkAccount(email: String, password: String, onResult: (Throwable?) -> Unit)
}
If we navigate to the authentication service implementation, we can see that these callbacks are called within the addOnCompleteListener
block. This listener is added to each of the calls to the authentication API, and inside it we have access to the response sent by the server. Then we pass the result back to the ViewModels
via the callbacks:
override fun createAnonymousAccount(onResult: (Throwable?) -> Unit) {
Firebase.auth.signInAnonymously()
.addOnCompleteListener { onResult(it.exception) }
}
override fun authenticate(email: String, password: String, onResult: (Throwable?) -> Unit) {
Firebase.auth.signInWithEmailAndPassword(email, password)
.addOnCompleteListener { onResult(it.exception) }
}
override fun linkAccount(email: String, password: String, onResult: (Throwable?) -> Unit) {
val credential = EmailAuthProvider.getCredential(email, password)
Firebase.auth.currentUser!!.linkWithCredential(credential)
.addOnCompleteListener { onResult(it.exception) }
}
The code snippet below was taken from the LoginViewModel
’s onSignInClick
method. We are passing the email and password as parameters, and soon after we open a code block that corresponds to the callback. All the code inside this block will be executed once the Firebase Authentication API sends a response:
viewModelScope.launch(showErrorExceptionHandler) {
accountService.authenticate(email, password) { error ->
if (error == null) {
openAndPopUp(SETTINGS_SCREEN, LOGIN_SCREEN)
} else onError(error)
}
}
Cloud Firestore
Just as we saw earlier in the authentication service, most of our storage service’s methods will also pass a callback as a parameter:
interface StorageService {
fun addListener(
userId: String,
onDocumentEvent: (Boolean, Task) -> Unit,
onError: (Throwable) -> Unit
)
fun removeListener()
fun getTask(taskId: String, onError: (Throwable) -> Unit, onSuccess: (Task) -> Unit)
fun saveTask(task: Task, onResult: (Throwable?) -> Unit)
fun updateTask(task: Task, onResult: (Throwable?) -> Unit)
fun deleteTask(taskId: String, onResult: (Throwable?) -> Unit)
fun deleteAllForUser(userId: String, onResult: (Throwable?) -> Unit)
}
In the implementation of the method that adds a listener to the collection of documents stored in Firestore, we can see that two callbacks are passed in: onError
and onDocumentEvent
. Every time a document is added, changed or deleted from the collection, the code block inside addSnapshotListener
will be executed, and will decide which of the callbacks should be called (in case of success or error):
override fun addListener(
userId: String,
onDocumentEvent: (Boolean, Task) -> Unit,
onError: (Throwable) -> Unit
) {
val query = Firebase.firestore.collection(TASK_COLLECTION).whereEqualTo(USER_ID, userId)
listenerRegistration = query.addSnapshotListener { value, error ->
if (error != null) {
onError(error)
return@addSnapshotListener
}
value?.documentChanges?.forEach {
val wasDocumentDeleted = it.type == REMOVED
val task = it.document.toObject<Task>().copy(id = it.document.id)
onDocumentEvent(wasDocumentDeleted, task)
}
}
}
In TasksViewModel
, when we call the StorageService
’s addListener
method, we pass the onDocumentEvent
and onError
callbacks. Just below the addListener
method, we can see what happens in case we receive an event successfully: if the document was removed from the collection, we also remove it from the tasks
list. Otherwise, we update the task
in the list that the ViewModel
maintains:
val tasks = mutableStateMapOf<String, Task>()
private set
fun addListener() {
viewModelScope.launch(showErrorExceptionHandler) {
storageService.addListener(accountService.getUserId(), ::onDocumentEvent, ::onError)
}
}
private fun onDocumentEvent(wasDocumentDeleted: Boolean, task: Task) {
if (wasDocumentDeleted) tasks.remove(task.id) else tasks[task.id] = task
}
Finally, in the composable function
that represents the TasksScreen
, we access the tasks
list in the TasksViewModel
, extract the last published value
(since it is a mutableState
) and convert it from map
to list
, which is the format that the LazyColumn
needs in order to show the items on the screen:
LazyColumn {
items(viewModel.tasks.values.toList(), key = { it.id }) { taskItem ->
TaskItem(
task = taskItem,
[...]
)
}
}
Adding Coroutines and Flow
Now we want to update the services we just saw to use Kotlin Coroutines, which are lightweight threads that enable you to write asynchronous code. We also want to use Kotlin Flow
, which is a data stream that emits sequential values, and is recommended by the Android team as a way to exchange information between the different layers of your application.
As Firebase aims to always follow the best practices and deliver the best experience to developers, we are adding support for Flow
to our Firebase SDKs for Android as well. So let’s dive into how to use Coroutines with the Authentication API, and how to use both Coroutines and Flow
with the Cloud Firestore API!
Firebase Authentication
Below we can see what the authentication service interface looks like now that we are using Coroutines (this interface also has other methods for signing out the user and deleting the account, but let’s leave them out now and focus on the methods for logging in the user, creating and linking accounts). The first thing we need to do is add the suspend
keyword to the beginning of each function. A suspending function is simply a function that can be paused and resumed at a later time. The second thing we need to do is remove the callbacks from the parameters. If it is necessary to return some data, we can return it directly in the implementation of the function, as it was a synchronous call.
interface AccountService {
suspend fun createAnonymousAccount()
suspend fun authenticate(email: String, password: String)
suspend fun linkAccount(email: String, password: String)
}
Now in the service implementation, we don’t have to use the addOnCompleteListener
in the API calls anymore, because we no longer need to pass the result to a callback. What we do instead, is use the await()
method. Once you call await()
, you suspend the function call, thus preventing the thread from being blocked. Note that you can only call await()
in a suspend
function:
override suspend fun createAnonymousAccount() {
auth.signInAnonymously().await()
}
override suspend fun authenticate(email: String, password: String) {
auth.signInWithEmailAndPassword(email, password).await()
}
override suspend fun linkAccount(email: String, password: String) {
val credential = EmailAuthProvider.getCredential(email, password)
auth.currentUser!!.linkWithCredential(credential).await()
}
And finally, now the service call in the onSignInClick
method is as seen in the code snippet below. First we try to authenticate, and if the call succeeds, we proceed to the next screen. As we are executing these calls inside a launchCatching
block (this is a method of MakeItSoViewModel
- the base ViewModel
that all other ViewModels
extend that uses lifecycleScope
to seamlessly integrate with the apps lifecycle), if an error happens on the first line, the exception will be caught and handled, and the second line will not be reached at all. In this way, we can write asynchronous code that looks synchronous which improves code readability:
launchCatching {
accountService.authenticate(email, password)
openAndPopUp(SETTINGS_SCREEN, LOGIN_SCREEN)
}
Cloud Firestore
Before we jump into how to update Cloud Firestore calls to use Coroutines and Flow
, it is important to make clear that the minimum version of the Firebase Bill of Materials that you can use is 31.0.0. All versions before this one won’t have support for Flow
in the Cloud Firestore SDK, so check your build.gradle
file to make sure you are using at least this version.
Once the BoM version is up to date, it is possible to update the StorageService
interface to look like the code snippet below. As with the authentication service, all callbacks were removed from the parameters, and we added the keyword suspend
to the beginning of each function. Also, we made the tasks
list a Flow
:
interface StorageService {
val tasks: Flow<List<Task>>
suspend fun getTask(taskId: String): Task?
suspend fun save(task: Task): String
suspend fun update(task: Task)
suspend fun delete(taskId: String)
suspend fun deleteAllForUser(userId: String)
}
Below we can see the code that is executed when someone accesses the tasks Flow
: we take the current user available in the authentication service, and we use this user’s id
to search for documents in the task collection that belong to this user. Then we map each document to an object that our code understands (a data class Task
object) through the dataObjects()
method provided by the Firestore API:
override val tasks: Flow<List<Task>>
get() =
auth.currentUser.flatMapLatest { user ->
firestore.collection(TASK_COLLECTION).whereEqualTo(USER_ID_FIELD, user.id).dataObjects()
}
Now it’s even easier for TasksViewModel
. Instead of listening to events and performing operations on a list maintained locally, now we only need to declare that the tasks Flow
in the TasksViewModel
is the same as the service’s tasks Flow
. Now every time a new version of the Tasks
is available in the service, it will be available in the TasksViewModel
as well:
@HiltViewModel
class TasksViewModel @Inject constructor(
private val storageService: StorageService,
private val logService: LogService
) : MakeItSoViewModel(logService) {
val tasks = storageService.tasks
[...]
}
Finally, the TasksScreen
can easily access and collect the most recent values
from the Flow
of tasks
available in the TasksViewModel
. The only thing the TasksScreen
has to do is transform it to a list, which is the format that the LazyColumn
needs to show the items on the screen. We do this by using collectAsStateWithLifecycle
. This is an extension function from the lifecycle-runtime-compose
API that aims to help developers to safely collect new states, while being aware of the lifecycle of the app:
val tasks = viewModel.tasks.collectAsStateWithLifecycle(emptyList())
LazyColumn {
items(tasks.value, key = { it.id }) { taskItem ->
TaskItem(
task = taskItem,
[...]
)
}
}
And that’s it! You are all set to use Kotlin Coroutines and Flow
in your app.
What’s next
In part 6 of this series we will see how to monitor the quality of your application by adding Firebase Performance Monitoring to it. We will cover what is automatically measured by this product, and how you can add custom traces to monitor specific parts of your codebase.
You can clone the source code on the Github repository. If you’re also interested in iOS/Mac development, the same application is available for these platforms as well. The iOS/Mac version uses SwiftUI for building the user interface, which also follows the declarative UI model, similarly to how we build UIs in Compose. You can find the source code in this Github repository.
If you have any questions or suggestions, feel free to reach out to us on the discussions page of the repository.