Here is a series of articles on using coroutines on Android. This article will focus on using coroutines to solve real-world problems.
Other articles in the series:
Using coroutines on Android (part 1) : Getting The Background
Using coroutines on Android (2) : Getting Started
Use coroutines to solve real world problems
The first two articles in this series focused on how coroutines simplify code, provide mainline security on Android, and avoid leaking tasks. With that in mind, this is a great solution for handling background tasks in Android and simplifying callback code.
So far, we’ve learned what coroutines are and how to manage them. In this article, we’ll look at how to use coroutines to accomplish real tasks. Coroutines are a universal programming language feature at the same level as functions, so you can use them to do anything an object or function can do. However, coroutines are a good solution for two tasks that occur frequently in real code.
- One-time requests: Calls are executed once at a time, and they always end when the result is ready.
- Streaming requests: Observing changes and feeding back to the caller, they do not finish executing until the first result is returned.
Coroutines solve these tasks well. In this article, we’ll dive into one-time requests and explore how to implement them on Android.
One-time request
One-time requests are executed each time they are invoked, and the results end as soon as they are ready. This is the same pattern as a normal function call — call, do some work, return. Because of its similarity to function calls, it is easier to understand than streaming requests.
One-time requests are executed each time they are invoked, and the result is stopped as soon as it is ready.
As an example of a one-time request, imagine how your browser loads web pages. When you click on the link, a web request is sent to the server to load the web page. Once the data is transferred to your browser, it stops interacting with the back end and has all the data it needs. If the server modifies the data, the new changes are not displayed in the browser and you must refresh the page.
So even though one-time requests lack the real-time push capabilities of streaming requests, they are still powerful. On Android, you can do a lot of things with one-time requests, such as querying, storing, or updating data. It is also a good solution for list sorting.
Problem: Show ordered list
Let’s explore one-time requests by showing an ordered list. To make the example more concrete, let’s write a product inventory application for store employees to use. It is used to query goods based on when they were last restocked. Goods can be arranged either in ascending or descending order. There are so many goods here that sorting takes almost a second, let’s use coroutines to avoid blocking the main thread.
All products in the App are stored in the database Room. This is a good example because we don’t need to make network requests so we can focus on design patterns. The lack of network requests makes this example simple, but it nonetheless shows the pattern used to implement one-time requests.
To implement this request using coroutines, you need to introduce coroutines into ViewModel, Repository, and Dao. Let’s take a look at how they are combined with coroutines one by one.
class ProductsViewModel(val productsRepository: ProductsRepository) : ViewModel() {
private val _sortedProducts = MultableLiveData<List<ProductListing>>()
val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
/** * Called by the UI when the user clicks the appropriate sort button */
fun onSortAscending(a) = sortPricesBy(ascending = true)
fun onSortDescending(a) = sortPricesBy(ascending = false)
private fun sortPricesBy(ascending: Boolean) {
viewModelScope.launch {
// suspend and resume make this database request main-safe
// so our ViewModel doesn't need to worry about threading
_sortedProducts.value =
productsRepository.loadSortedProducts(ascending)
}
}
}
Copy the code
ProductsViewModel is responsible for receiving user-level events and requesting Repository to update data. It uses LiveData to store the current ordered list to be displayed in the UI. When a new event is received, the sortPricesBy method opens a new coroutine to sort the collection and update the LiveData when the results are available. Since the ViewModel can fetch the coroutine in the onCleared callback, it is a good place to start the coroutine in this architecture. When the user leaves the interface, there is no need to continue the unfinished task.
If you don’t know much about LiveData, here’s a great article on how LiveData stores data for the UI layer by CeruleanOtter.
ViewModels: A Simple Example
This is a common pattern for using coroutines on Android. Since the Android Framework cannot call the suspend function, you need a coroutine to respond to UI events. The easiest way to do this is to start a new coroutine when an event occurs, and the best place to do this is in the ViewModel.
Starting coroutines in the ViewModel is a common design pattern.
The ViewModel actually fetches data through ProductsRepository. Let’s look at the code:
class ProductsRepository(val productsDao: ProductsDao) {
/** * This is a "regular" suspending function, which means the caller must * be in a coroutine. The repository is not responsible for starting or * stoppong coroutines since it doesn't have a natural lifecycle to cancel * unnecssary work. * * This *may* be called from Dispatchers.Main abd is main-safe because * Room will take care of main-safety for us. */
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
return if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
Copy the code
ProductsRepository provides a reasonable interface for the interaction of commodity data. In this App, because all the data is stored in the Room database, it provides daOs with two methods for different sorts.
Repository is an optional part of the Android Architecture Components Architecture. If you use a repository or similar level of functionality in your app, it will prefer to use suspended functions. Because Repository has no life cycle, it is just an object, so it has no way of doing resource cleaning. Coroutines launched in Repository may leak.
In addition to avoiding leaks, you can reuse Repository in different contexts using hang functions. Anyone who knows how to create a coroutine can call loadSortedProducts, such as the WorkManager library that starts a background task.
Repository should use hang functions to secure the main thread.
Note: Some save operations performed in the background may want to continue when the user leaves the interface, in which case it makes sense to run out of the life cycle. In most cases, viewModelScope is a good choice.
Let’s look at ProductsDao again:
@Dao
interface ProductsDao {
// Because this is marked suspend, Room will use it's own dispatcher
// to run this query in a main-safe way,
@Query("select * from ProductListing ORDER BY dataStocked ASC")
suspend fun loadProductsByDateStockedAsceding(a): List<ProductListing>
// Because this is marked suspend, Room will use it's own dispatcher
// to run this query in a main-safe way,
@Query("select * from ProductListing ORDER BY dataStocked DESC")
suspend fun loadProductsByDateStockedDesceding(a): List<ProductListing>
}
Copy the code
ProductsDao is a Room Dao that provides two suspend functions externally. Because functions are decorated by suspend, Room keeps them mainline safe. This means you can call them directly from dispatchers.main.
If you haven’t used coroutines in Room, read this article by FMuntenescu:
Room && Coroutines
Note, however, that the coroutine calling it will run on the main thread. So if you’re going to do something expensive with the result, like convert it to a collection, you want to make sure you don’t block the main thread.
Note: Room uses its own scheduler to perform query operations in the background thread. You should no longer use withContext(dispatchers.io) to call Room’s suspend query. This will only make your code run slower.
The suspension function in Room is mainline-safe and runs in a custom scheduler.
One-time request mode
This is the complete pattern for one-time requests using coroutines in Android Architecture Components. We added coroutines to ViewModel, Repository, and Room, each with different responsibilities.
- The ViewModel starts the coroutine on the main thread and terminates once it has a result.
- Repository provides suspended functions and keeps them mainline secure.
- The database and network layers provide suspend functions and secure their main threads.
The ViewModel is responsible for starting the coroutine and ensuring that the coroutine is retrieved when the user leaves the interface. It does not do expensive operations on its own, but relies on other layers to do so. Once the results are available, they are sent to the UI using LiveData. And because the ViewModel doesn’t do expensive operations, it starts the coroutine on the main thread. By starting on the main thread, it can respond more quickly to user events (such as memory caches) when results are available.
Repository provides suspended functions to access data. It generally does not start long-life coroutines because it has no way to cancel them. Whenever Repository needs to do expensive operations (collection conversions, etc.), it needs to use withContext to provide mainline-safe interfaces.
The data layer (network or database) always provides suspend functions. When using Kotlin coroutines, you need to ensure that these hang functions are mainline-safe, and Room and Retrofit follow this principle.
In a one-time request, the data layer only provides suspend functions. If you want to get a new value, you must call it again. It’s like a refresh button in a browser.
It’s worth taking the time to get your head around the one-time request model. This is a common pattern in Android coroutines, and you’ll use it all the time.
First Bug Report
After testing the solution, you put it into production and it works fine for a few weeks until you get a very strange error report:
Subject: 🐞 – Sorting error!
Report: When I clicked the sort button very, very, very, very fast, the sort occasionally got wrong. This doesn’t happen every time.
You look and scratch your head. What could have gone wrong? The logic seems fairly simple:
- Start sorting user requests
- Start sorting in the Room scheduler
- Show sorting results
You’re about to close the bug by saying “pass — don’t click the button quickly”, but you’re worried that something is really wrong. After adding logs and writing test cases to test making many sort requests at once, you finally figure out why.
The final result is not actually the “result of sorting”, but the result “when the last sorting is done”. When the user frantically presses the button, multiple sorts are initiated simultaneously, possibly ending in any order. (Think of it as multi-threaded concurrency in Java)
When starting a new coroutine in response to user events, consider what happens when the user starts another coroutine before the coroutine terminates.
This is a concurrency bug that actually has nothing to do with coroutines. This bug can occur when we use callbacks, Rx, and even ExecutorService in the same way. Let’s explore how the following scenarios ensure that one-time requests are executed in the order expected by the user.
Best option: Disable button
The core question is how do we sort twice. We can make it sort only once! The easiest way to do this is to disable the sort button and stop sending new events.
This seems like a simple solution, but it’s a good idea. The code implementation is also simple and easy to test.
To disable the button, you can notify UI sortPricesBy that a sort request is in progress, as shown below:
// Solution 0: Disable the sort buttons when any sort is running
class ProductsViewModel(val productsRepository: ProductsRepository): ViewModel() {
private val _sortedProducts = MutableLiveData<List<ProductListing>>()
val sortedProducts: LiveData<List<ProductListing>> = _sortedProducts
private val _sortButtonsEnabled = MutableLiveData<Boolean> ()val sortButtonsEnabled: LiveData<Boolean> = _sortButtonsEnabled
init {
_sortButtonsEnabled.value = true
}
/** * Called by the UI when the user clicks the appropriate sort button */
fun onSortAscending(a) = sortPricesBy(ascending = true)
fun onSortDescending(a) = sortPricesBy(ascending = false)
private fun sortPricesBy(ascending: Boolean) {
viewModelScope.launch {
// disable the sort buttons whenever a sort is running
_sortButtonsEnabled.value = false
try {
_sortedProducts.value =
productsRepository.loadSortedProducts(ascending)
} finally {
// re-enable the sort buttons after the sort is complete
_sortButtonsEnabled.value = true}}}}Copy the code
That doesn’t look bad. Simply disable the button inside sortPricesBy when calling Repository.
In most cases, this is a good solution to a problem. But do we want to fix this bug when the button is available? This is a bit difficult, and we’ll look at several approaches in the rest of this article.
Important: This code shows a major advantage of starting on main — The buttons disable INSTANTLY in response to a click. If you switched dispatchers, a fast-fingered user on a slow phone could send more than one click!
Concurrent mode
The following sections explore some advanced topics. If you’re just starting to use coroutines, you don’t have to fully understand. A simple disable button is a good solution to most of your problems.
In the remainder of this article, we will discuss how to ensure that a one-time request works without disabling the button. We can avoid unexpected concurrency situations by controlling when coroutines run (or don’t run).
Here are three patterns that you can use in a one-time request to ensure that only one request is made at a time.
- Cancel the previous one before starting any more coroutines.
- The next task is placed in a wait queue until the previous request completes before starting another.
- If a request is already running, return it instead of starting another request.
If you think about these solutions, they are all relatively complex to implement. To focus on design patterns rather than implementation details, I created GIST to provide implementations of these three patterns as usable abstractions. (Here’s a quick look at the code for GIST.)
Plan 1: Cancel the previous task
In the sorting case, getting a new event from the user means you can cancel the previous request. After all, the user doesn’t want to know the outcome of the last task, so what’s the point of continuing?
To cancel the last request, we first have to trace it in some way. This is what the cancelPreviousThenRun function in GIST does.
Let’s see how it can be used to fix bugs:
// Solution #1: Cancel previous work
// This is a great solution for tasks like sorting and filtering that
// can be cancelled if a new request comes in.
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
var controlledRunner = ControlledRunner<List<ProductListing>>()
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
// cancel the previous sorts before starting a new one
return controlledRunner.cancelPreviousThenRun {
if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
}
Copy the code
Look at the implementation of cancelPreviousThenRun in GIST, and you can see how it tracks the task at work.
// see the complete implementation at
// https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7
suspend fun cancelPreviousThenRun(block: suspend(a) -> T): T {
// If there is an activeTask, cancel it because it's result is no longer neededactiveTask? .cancelAndJoin()// ...
Copy the code
In short, it always keeps track of the current ordering in the member variable activeTask. Whenever a new sort is started, everything in the activeTask is cancelled immediately. The effect of this is to cancel all sorts in progress before starting a new sort.
Using an abstract implementation like ControlledRunner
is a good way to encapsulate logic, rather than mixing concurrency with program logic.
Important: This pattern is not suitable for use in global singletons because unrelated callers should not cancel each other out.
Plan two: Join the team for the next task
Here is a solution that always works for concurrency bugs.
You simply queue the requests so that only one request is made at a time. Just like queues in a store, requests are executed in the order in which they are queued.
For this particular queuing problem, cancellation may be better than queuing. But it’s worth noting that it always works.
// Solution #2: Add a Mutex
// Note: This is not optimal for the specific use case of sorting
// or filtering but is a good pattern for network saves.
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
val singleRunner = SingleRunner()
suspend fun loadSortedProducts(ascending: Boolean): List<ProductListing> {
// wait for the previous sort to complete before starting a new one
return singleRunner.afterPrevious {
if (ascending) {
productsDao.loadProductsByDateStockedAscending()
} else {
productsDao.loadProductsByDateStockedDescending()
}
}
}
}
Copy the code
Whenever a new sort is performed, it uses a SingleRunner instance to ensure that only one sort task is performed at the same time.
It uses a Mutex, a Mutex is a one-way ticket, or lock, which a coroutine must acquire to get into a block of code. If one coroutine tries to enter while it is running, it will suspend itself until all waiting coroutines are complete.
Mutex guarantees that only one coroutine will run at a time, and that they will end in the order they were started.
Plan 3: Add the previous task
The third solution is to add the previous task. It would be a good idea if the new request could reuse the same half-done task that already exists.
This pattern does not make much sense for sorting functions, but is useful for network requests.
For our product inventory application, users needed a way to get the latest product inventory data from the server. We provide a refresh button that the user can click to initiate a new web request.
Like the sort button, the disable button resolves the problem while the request is in progress. But if we don’t want to, or can’t, we can opt in to existing requests.
Check out the gist code using joinPreviousOrRun to see how it works:
class ProductsRepository(val productsDao: ProductsDao, val productsApi: ProductsService) {
var controlledRunner = ControlledRunner<List<ProductListing>>()
suspend fun fetchProductsFromBackend(a): List<ProductListing> {
// if there's already a request running, return the result from the
// existing request. If not, start a new request by running the block.
return controlledRunner.joinPreviousOrRun {
val result = productsApi.getProducts()
productsDao.insertAll(result)
result
}
}
}
Copy the code
This is the opposite of cancelPreviousAndRun. CancelPreviousAndRun cancelPreviousAndRun cancels the previous request directly, and joinPreviousOrRun will abandon the new request. If there is already a running request, it will wait for the result of execution and return, rather than making a new request. Blocks of code are executed only if there are no running requests.
You can see how the tasks in joinPreviousOrRun work in the code below. It simply returns the result of the previous request when a task exists in activeTask.
// see the complete implementation at
// https://gist.github.com/objcode/7ab4e7b1df8acd88696cb0ccecad16f7#file-concurrencyhelpers-kt-L124
suspend fun joinPreviousOrRun(block: suspend(a) -> T): T {
// if there is an activeTask, return it's result and don't run the blockactiveTask? .let {return it.await()
}
// ...
Copy the code
This pattern works well for requests to query products by ID. You can use a map to hold the ID to Deferred mapping, and then use the same join logic to track previous requests for the same product.
Adding the previous tasks can effectively avoid repeated network requests.
# # What ‘s next?
In this article, we explored how to use Kotlin coroutines to implement one-time requests. First, we implemented a complete design pattern by starting coroutines in the ViewModel and providing exposed suspend functions through the Repository and Room Dao.
For most tasks, this is all you need to do in order to use the Kotlin coroutine on Android. This pattern can be applied to many scenarios, such as sorting mentioned above. You can also use it to query, save, and update web data.
Then we looked at a possible bug and its solution. The simplest (and often best) solution is to modify it from the UI to disable the sort button directly while a sort is running.
Finally, we looked at some advanced concurrency patterns and how to implement them in Kotlin coroutines. The code is a bit complex, but provides a good introduction to some high-level coroutine topics.
In the next article, let’s get into streaming requests and how to use the liveData builder!
Article first published wechat public account: Bingxin said, focus on Java, Android original knowledge sharing, LeetCode problem solving.
More original articles, scan code to pay attention to me!