Open the starter project in Android Studio. The demo projects for the previous lessons had a single screen. This time, there are multiple independent screens. One for the scopes and another for the dispatchers. You can navigate to one of them from the additional menu screen. There are also tests in the CoroutineTest class. Note it’s in the test directory, instead of in main as is the production code.
Dispatchers
Open the Lesson3DispatchersScreen file. It contains several buttons that start 100 coroutines on various dispatchers. Each coroutine suspends for ten milliseconds and then prints the message to the logcat with the current thread name. In this file, you won’t write any code but observe the logcat messages.
Run the app and click the first button labeled “Dispatchers”. Then click the button labeled
“I/O”. You should see logcat messages with the thread name followed by the name of the coroutine. Now, click the second button (“Default”) and observe the logcat. Have you noticed the difference? There should be a lot more different numbers in the thread names produced by the IO dispatcher than by the Default dispatcher.
For example, if your phone has a CPU with four cores, there should be up to four numbers in the
thread names from the Default dispatcher. The IO dispatcher, however, may have more than four
different numbers. You can see that the IO dispatcher uses more threads than the
Default dispatcher.
Now press the third button (“Main”). Look at the logcat. The thread name is always the same - main. The Main dispatcher serves only the main thread. And there’s always only one main thread in the app. So it doesn’t make sense to add any numbers to its name.
Look at the startCoroutines function to see what the code looks like. Note the nested coroutine inside the CoroutineScope lambda.
Finally, press the fourth button (“I/O with Main context”). You should see the interleaved logcat messages from both dispatchers. See the startCoroutinesWithContext function code to see how it’s implemented. The withContext function switches the dispatcher in the coroutine. So, despite the fact the outer coroutine runs on the IO dispatcher, the inner coroutine changes the context to the one having the Main dispatcher.
Now go to the CoroutineTest class. There are two tests there. They both perform some heavy computation. In these tests, that computation is simulated by the five-second delay. The tests differ by the coroutine builders. The first test uses the runBlocking builder. The second test uses the runTest builder, which is dedicated for tests.
Run the tests by using the Run Test button in the left gutter, near the line numbers. You may
also press Ctrl+Shift+R or Ctrl+Shift+F10 when the cursor is inside the test class. You don’t
need an Android
device to run the tests. They are unit tests and run on the JVM on your laptop or PC. You should see the test results in the bottom panel. The results should look like this.
The order of the tests is random. So, it may be the opposite in your case.
The test with runBlocking should take a little bit more than five seconds to finish. The delay
is exactly five seconds. But there’s some overhead in the test runner internals.
On the other hand, the test with runTest should finish almost immediately. The exact value depends highly on the performance of your machine. In my case, it took only about 500 microseconds, despite the fact the test suspends for five seconds. Note the current time of the test scheduler is 5000 milliseconds. So it’s exactly five seconds.
The runTest builder is very convenient for testing the suspending functions with delays. It doesn’t use real time, but virtual time. You can change that virtual time by using the advanceTimeBy() function. There’s also a advanceUntilIdle() function that advances the time until there are no more remaining tasks.
Using Scopes
Open the Lesson3ScopesScreen file. It contains several buttons that interact with the coroutine scopes. The CoroutineScope is created on top of the screen using the rememberCoroutineScope function. It’s the same function as used in the previous lesson. The created scope is bound to the lifetime of the screen composable. It’s canceled when the screen is removed from the composition. You’ll use that scope to launch all the coroutines in this exercise.
Look at the TODO comment in the onClick lambda of the first (“Navigate back in composable scope”) button. You have to navigate back to the previous screen using the mentioned CoroutineScope. Insert some logcat messages printing to see what’s happening. The function performing the navigation is already there. It’s called onNavigateBack(). Add a few seconds of delay before the navigation action. The code should look like this:
coroutineScope.launch {
Log.i("Lesson3", "starting 2 seconds delay")
delay(2.seconds)
Log.i("Lesson3", "navigating back")
onNavigateBack()
}
Run the app, click the “Scopes” button and click the first button (“Navigate back in composable scope”). You should see the logcat messages in the console. The screen gets popped after two seconds. Now go to the screen and click the button again, but immediately after that (no later than two seconds) press the device back button or, perform the back navigation gesture or press the last button - “Go back to menu”.
You shouldn’t see the last message in the logcat. The coroutine is canceled when the screen leaves the composition. The navigation action from a coroutine doesn’t start. This is the correct behavior.
What if the coroutine isn’t canceled when the screen leaves the composition? Imagine the following backstack of screens:
-
Lesson3ScopesScreen- the current screen. -
MenuScreen -
MainScreen- not in the demo project.
And the following scenario:
-
The user clicks the “Navigate back in composable scope” button on the
Lesson3ScopesScreen. - The user immediately clicks the back button on the device or performs the back navigation gesture.
Action 2 will remove Lesson3ScopesScreen from the backstack but the coroutine will still be running. When it finishes after two seconds, the back navigation action from the coroutine will be performed. So it will pop the MenuScreen from the backstack. The user will land on MainScreen. This isn’t the expected behavior. The user should stay on the MenuScreen. Any coroutine started in Lesson3ScopesScreen should be canceled when the screen leaves the composition to avoid such situations.
Replace the coroutineScope.launch with the MainScope().launch and run the app again. Perform the same steps as before. You should see the “navigating back” message in the logcat. The coroutine isn’t canceled when the screen leaves the composition. The navigation action is performed after two seconds, but due to the fact that there are no more screens in the back stack, the user stays on the menu screen.
You may think that there’s no issue from the user’s perspective. They don’t see anything wrong. But, this situation is even more dangerous than observing the double back navigation action in a row.
Imagine the app that has such a bug gets released to production. Then, after some time, maybe a few months, the app gets updated. Some other developers added a new screen before the menu. Because of the hanging coroutine, the bug is visible as both of the screens get popped. The new developers are confused. They haven’t added any back navigation action. Debugging and figuring out what’s going on may take a lot of time.
So, remember to properly cancel top-level coroutine scopes when they are not needed anymore. Or better yet, use the predefined scopes bound to the lifecycle where possible. Not cancelling scopes may lead to memory leaks or other bugs which are hard to find and may appear after a long time or after making some unrelated changes in the app.
Now look at the second button (“Launch coroutine”). Your task is to launch a coroutine that prints the messages to the logcat with the two seconds delay between them. Use the same provided scope as before. The code should look like this:
coroutineScope.launch {
Log.d("Lesson3", "Coroutine started.")
delay(2.seconds)
Log.d("Lesson3", "Coroutine finished.")
}
Run the app and click the second button. You should see the messages in the logcat. Try again, but this time, try to navigate back using the system back button or the back navigation gesture or the last button. The coroutine should be canceled and you shouldn’t see the last message in the logcat. Nothing new so far.
Look at the third (“Cancel scope”) button. Your task is to cancel the scope. The code should look like this:
Log.d("Lesson3", "Scope about to cancel.")
coroutineScope.cancel()
Log.d("Lesson3", "Scope cancellation requested.")
Run the app, go to the scopes screen and click the second and then the third button. You should not see the last message in the logcat. The scope is canceled along with its child coroutine. Now try clicking the second button again. The coroutine shouldn’t start. The scope is canceled and doesn’t accept new coroutines. There’s only a message that the coroutine is about to start, printed directly from the onClick lambda, not from inside the coroutine.