How to use Kotlin coroutines to complete complex tasks effectively

When creating an Android app, it is important to consider how complex tasks will be handled because devices have a limited amount of processing power. If resources are not managed effectively then the performance of the app will suffer. In this tutorial, we will explore how Kotlin coroutines enable your app to run tasks "behind the scenes" without compromising performance.

Prepare your app to use coroutines

To use coroutines, you first must appropriately configure your app using a toolkit called Gradle. Navigate through Project > Gradle Scripts and open build.gradle (Module: app).

android-gradle-module.png

Scroll down to the dependencies section. You will likely find a list of 'implementation' statements (amongst other things). Add the following code to the list to implement coroutines:

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.0'

Remember to resync the project (if prompted to do so).

resync-gradle.png

Once your project has synced successfully, you can begin to write your first coroutines.

Jobs, coroutine dispatchers and coroutine scopes

Coroutines are customisable and can be tailored towards certain workflows. For example, you can assign a coroutine a Job. A Job is a cancellable process that allows you to coordinate how and when tasks are completed. For instance, a Job called mediaJob (which we'll use to coordinate the playback of audio files) can be declared like this:

private val mediaJob = Job()

Remember to add the following import statement to the top of your Kotlin file so you can use coroutines and jobs in your code:

import kotlinx.coroutines.*

Another way of customising your coroutines is through your choice of dispatcher. A dispatcher determines which thread your coroutine will run in. Threads are like departments in a company: each one has its own speciality. For example, Dispatchers.Default is best suited for processing complex data and running computationally-demanding tasks, Dispatchers.IO is most suitable for tasks involving reading and writing files and communicating over networks, while Dispatchers.Main is geared towards the user interface and handling views, widgets and objects.

Jobs and dispatchers can be combined in a coroutine scope. A coroutine scope oversees a coroutine from start to finish and helps shape how the coroutine operates. Continuing with our media player manager coroutine example, you could define a coroutine scope that incorporates the mediaJob like this:

private val mediaScope = CoroutineScope(Dispatchers.Main + mediaJob)

Applying coroutines to functions

In this section, we will discuss how coroutines can be applied to methods and functions. When implementing a coroutine, you can choose between several different builders. Launch builders run the coroutine without blocking the main thread, async builders return a result once the task is complete, produce and broadcast builders return a stream of values (via their respective channels), and runBlocking builders block the current thread until the work is complete (you will rarely use runBlocking for anything other than debugging). Going back to the music player app example, the app will likely need a function that handles commands to play songs. The method will need to find the selected track, initiate playback and load metadata about the song such as its title, artist and artwork. We'll build this function in stages. First, input the following code which loads the audio file and begins playback:

fun playSong(song: Song) = mediaScope.launch(Dispatchers.IO) {
    // load the song
    val mMediaPlayer = MediaPlayer().apply {
        setAudioAttributes(AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_MEDIA)
            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
            .build()
        )
        setDataSource(application, song.uri))
        prepare()
    }
    // play the song
    mMediaPlayer.start()
}

The above function is called playSong and it accepts a Song object as an argument. This Song object was defined in our tutorial on Room databases. It contains information about a given song (e.g. the song's title, a link to the song's artwork and a link to the song's audio file). The playSong function is launched using the mediaScope coroutine scope we defined earlier and incorporates the IO dispatcher. The IO dispatcher is used because this function will handle audio files. The remainder of the method loads the song into a media player based on its Uri (a piece of data that identifies a file) then initiates playback. We won't discuss the media player too much here but if you are interested in learning more then see our media player tutorial.

The code we have written so far uses a coroutine to load an audio file based on its Uri and initiate playback. Applying the coroutine should allow the function to run without impairing app performance or disrupting the user. It is likely though that when a new song is played you will want to run additional tasks such as loading information about the currently playing song into the user interface so the user can see which song is currently playing. To achieve this, let's write a little more code and explore some further things we can do with coroutines. Amend the playSong function so it reads like the following and write a new function called loadArtworkAsync:

fun playSong(song: Song) = mediaScope.launch(Dispatchers.IO) {
    // load the song
    val mMediaPlayer = MediaPlayer().apply {
        setAudioAttributes(AudioAttributes.Builder()
            .setUsage(AudioAttributes.USAGE_MEDIA)
            .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
            .build()
        )
        setDataSource(application, song.uri)
        prepare()
    }
    // play the song
    mMediaPlayer.start()
      
    // load the song's artwork
    val artwork = loadArtworkAsync(song.artworkFilename).await()
    artworkImageView.setImageBitmap(artwork)
}

private fun loadArtworkAsync(filename: String): Deferred<Bitmap> = mediaScope.async(Dispatchers.IO) {
    val cw = ContextWrapper(applicationContext)
    val directory = cw.getDir("albumArt", Context.MODE_PRIVATE)
    val f = File(directory, "$filename.jpg")
    return@async BitmapFactory.decodeStream(FileInputStream(f))
}

The code we added to the playSong function runs a method called loadArtworkAsync, which creates a bitmap image of the song's album artwork. The bitmap is stored in a variable called artwork and loaded into an image view with the ID artworkImageView. Generating the artwork bitmap could be a time-consuming task and so we use a coroutine to complete the job without impairing app performance. This coroutine needs to be handled differently though because we must wait for the bitmap to be generated before continuing with the playSong method and inserting the artwork into the ImageView widget. To achieve this, the bitmap returned by loadArtworkAsync method is marked as "Deferred", which signals that the bitmap may take some time to be prepared, and the method runs using the async builder and IO dispatcher. The async builder signals that the method will return a result (the bitmap) and the IO dispatcher directs the method to run on a behind-the-scenes worker thread. While not necessary for your understanding of coroutines, the bitmap is generated by finding the artwork image on the device based on its filename then decoding the file into a bitmap using BitmapFactory. What this example hopefully shows is how you can use coroutines to complete complex or resource-intensive tasks in a logical and performance-optimised manner.

Cancelling coroutines

coroutines should be cancelled when the activity or fragment using them is destroyed to prevent unwanted effects such as memory leakage which could be detrimental to the performance of the app and device. For example, you could cancel all coroutines running on a particular scope like this:

override fun onDestroy() {
    super.onDestroy()
    mediaScope.cancel()
}

The above code cancels all coroutines using the mediaScope coroutine scope; however, you can also cancel individual coroutines. The best way of doing this would be to run the coroutine as a distinct Job then cancel that Job only. For example, you could design a function that scans the user's device for songs and run that function using a unique Job like this:

val libraryScanJob: Job = mediaScope.launch(Dispatchers.Default) { 
    // write code here which scans the user's device for music
}

The Job could be cancelled using the following:

libraryScanJob.cancelAndJoin()

The cancelAndJoin() command is an alternative to cancel() but must be called from another coroutine. It cancels the Job and suspends (pauses) the coroutine which called cancelAndJoin() until the Job is successfully cancelled. If you want to cancel a Job from outside a coroutine, then you should simply use cancel().

<<< Previous

Next >>>