Kotlin Coroutines – Simplifying asynchronous code on Android

Expertises

Concurrent and non-blocking code is an industry-standard. Whether it’s server-side applications or mobile apps, it’s desirable most of the time to have a fluid user experience and a solution that scales well. Concurrency and handling asynchronous operations play a big role in that regard. In the area of Android development, there are numerous ways to deal with asynchronous tasks, each with benefits and downsides of their own. Last year (2019) Google announced that they are providing first-class support for Kotlin’s coroutines through the Android jetpack library. This means it can be easily integrated with all the popular tools and libraries known to Android developers, making it a viable option to consider when choosing a solution for handling long-running operations while providing ‘main-safety’. Let’s have a look at how these coroutines work in Android.

Today we’ll take a look on how the use of coroutines changes the way you call asynchronous functions, how to setup your code to make use of coroutines and finally we’ll see how coroutines affect unit-testing.

Definition

The definition of coroutines from Wikipedia is as follows:

“Coroutines are computer program components that generalize subroutines for non-preemptive multitasking, by allowing execution to be suspended and resumed.”

This definition is pretty abstract, so let’s make it a bit more practical:

Coroutines offer a way to write asynchronous non-blocking code in a sequential matter. It eliminates the need for listeners and callbacks, where a lot of nesting and decentralization might make your code unreadable and hard to understand. Next to improved readability, coroutines have more to offer. But before I get into that, let’s first look at an example.

Diving in

An example of retrieving a user from a backend service and assigning the firstName property to a LiveData value using callbacks:

fun getUser() { 
    userService.getUserFromRemote { user -> 
        firstNameData.postValue(user.firstName) 
    } 
} 

Defining the same function when using coroutines would look something like this:

suspend fun getUser() { 
    val user = userService.getUserFromRemote() 
    firstNameData.postValue(user.firstName) 
} 

You might notice the suspend keyword at the start of the function. When marking a function with suspend, you tell the compiler that this function can either be called from a CoroutineContext or from another suspend function. It means the function can be paused or ‘suspended’ when executing asynchronous code and continue or ‘resume’ the function when it retrieved the result from the asynchronous operation. In the example, the user would still be retrieved on a background thread, while it reads as if it would return a user object immediately. In the coroutine example the userService.getUserFromRemote() function would look something like this:

suspend fun getUserFromRemote() = withContext(Dispatchers.IO) { 
    // logic to execute request and get user from remote url using OKHTTP or any other tool 
} 

withContext(Dispatchers.IO) makes sure that the lines inside the block are executed on the IO thread. When a suspend function is written in this way, the caller of the function doesn’t have to worry about handling threading. Even when getUserFromRemote is called from the main thread, it will still execute on the IO thread. We call this making a function `main-safe`. It’s considered a best practice to write main-safe code when it’s known that a function will perform IO code or CPU-heavy operations. There are three different kinds of dispatchers, all with their own purpose. They’re listed below.

Dispatchers.Main

Main thread on Android, interact with the UI and perform light work

  • Calling suspend functions

  • Call UI functions

  • Updating LiveData

Dispatchers.IO

Optimized for disk and network IO off the main thread

  • Database*

  • Reading/writing files

  • Networking**

Dispatchers.Default

Optimized for CPU intensive work off the main thread

  • Sorting a list

  • Parsing JSON

  • DiffUtils

Job cancellation by using scopes

Coroutines are run inside of a CoroutineScope. When a suspending function gets called from a non-suspending function, the caller should specify a scope for the coroutine to be run in. This forces the caller to think about the ‘lifecycle’ or ‘scope’ of the work to be executed. So, a Dispatcher (e.g. Dispatchers.IO) is responsible for the execution of the coroutine and a CoroutineScope is responsible for keeping track of the coroutine. When a scope ‘expires’ it automatically will cancel all coroutines that were started within this scope. A commonly used scope that Android provides out of the box is ViewModelScope. Coroutines started from a ViewModel are typically run in this scope. As soon as the ViewModel will be cleared, all coroutines started within this ViewModelScope will be canceled. This eliminates the need of keeping track of active coroutines and canceling them manually when needed.

Testing coroutines

Asynchronous code and the involvement of threading makes unit tests a bit tricky. The tests typically finish before all the code is executed causing the tests to fail. Coroutines need some tricks of their own to make them work smoothly in unit tests. Let’s take our previous example and try to test the getUserFromRemote function.

@Test 
fun testGetUserFromRemote() { 
    val user = userService.getUserFromRemote() 
    assertEquals("test", user.firstName) 
} 

This code won’t compile because getUserFromRemote() is a suspend function and it’s not being called from a CoroutineContext. We can use runBlocking or runBlockingTest for this. runBlocking runs a new coroutine and blocks the current thread until it’s completed. This allows us to make the code compile and run our test in the test thread. runBlockingTest works exactly the same as runBlocking but will skip any delays or sleeps in the code. Therefore, runBlockingTest is the way to go in unit tests. The code will now look like this:

@Test 
fun testGetUserFromRemote() = runBlockingTest { 
    val user = userService.getUserFromRemote() 
    assertEquals("test", user.firstName) 
} 

Now the test runs successfully! But what about testing the code that runs this code by starting a new coroutine?

Say we have a ViewModel that exposes the first name of the user through LiveData and sets it when the user is retrieved from the service. The ViewModel would look something like this:

class MyViewModel(private val userService: UserService): ViewModel() {

    val firstNameData = MutableLiveData() 
 
    fun getUser() { 
        viewModelScope.launch(Dispatchers.IO) { 
            val user = userService.getUserFromRemote() 
            firstNameData.postValue(user.firstname) 
        } 
    } 
} 

We can use runBlockingTest again, and our test would look something like:

@Test 
fun testGetUser() = runBlockingTest { 
    viewModel.getUser() 
    assertEquals("test", viewModel.firstNameData.value) 
} 

Our test will run without problems, but it will fail. Because the coroutine is started on a different thread, the test will reach the assertEquals code before it executes the userService.getUserFromRemote, causing the user.firstName not to be assigned to the LiveData value yet. This is why it’s a good practice to always inject your dispatchers. This will change the ViewModel to look like this:

class MyViewModel(
    private val userService: UserService,  
    private val dispatcher: CoroutineDispatcher = Dispatchers.IO
): ViewModel() { 
 
    val firstNameData = MutableLiveData() 
 
    fun getUser() { 
        viewModelScope.launch(dispatcher) { 
            val user = userService.getUserFromRemote() 
            firstNameData.postValue(user.firstname) 
        } 
    } 
} 

This allows us to inject a dispatcher of choice when using the MyViewModel in unit tests. Because we want the coroutine to be executed in the same thread as the unit tests, we can use a dispatcher called Dispatcher.Unconfined. This dispatcher isn’t tied to a specific thread and will, therefore, run the coroutine on the same thread as where it was started from. Our final test code will look as follows:

@Test 
fun testGetUser() = runBlockingTest { 
    val viewModel = MyViewModel(Dispatchers.Unconfined) 
    viewModel.getUser() 
    assertEquals("test", viewModel.firstNameData.value) 
}

That’s it! Our test runs successfully, and the assertion passes.

To summarize

A coroutine runs suspending functions that are dispatched by a Dispatcher. The kind of the dispatcher determines which thread a coroutine will run. Suspending functions are declared by using the suspend keyword and are suspended when they perform asynchronous operations and resume when the result is available. A coroutine is started in a CouroutineScope which keeps track of coroutines start within it and cancels them when the scope invalidates. Using a CoroutineScope eliminates the need to manage the lifecycle of long-running operations manually and reduces the chance of causing memory leaks. To make testing easier it’s a good practice to inject your Dispatcher either into your class or into your suspend function directly.

I hope you learned a thing or two about coroutines today and this post might help you with implementing coroutines in your own Android/Kotlin projects!

Yoeri van Hoek

Gerelateerde berichten