1. Introduction to kotlin coroutines
In the past few years, the concept of collaborative process has developed rapidly. Up to now, it has been adopted by many mainstream programming languages, such as Go and Python. Collaborative process can be realized at the language level, and even Java can indirectly support collaborative process by using extension libraries. Today's protagonist Kotlin also kept up with the pace and added support for collaborative process in version 1.3.
Kotlin Coroutines is a thread processing framework provided by kotlin. Developers can use Kotlin Coroutines to simplify asynchronous code, so that the code of different threads can be executed in the same code block, making the code more linear and easier to read.
However, Coroutines is not a new concept put forward by Kotlin. It comes from Simula and Modula-2 languages. This term was invented by Melvin Edward Conway and used to build assembler as early as 1958, which shows that Coroutines is a programming idea and is not limited to specific languages.
Kotlin Coroutines has solved the following pain points for Android developers
- Main thread safety issues
- Callback Hell
Let's introduce a simple example to see how concise the collaborative process can be.
fun test() { thread { // Sub thread makes network request request1() runOnUiThread { // Switch to the main thread to update the UI Log.d("tag", "request1") thread { // Sub thread makes network request request2() runOnUiThread { // Switch to the main thread to update the UI Log.d("tag", "request2") } } } } } private fun request1() = Unit private fun request2() = Unit
It can be seen that when multiple requests are to be processed, the problem of multi-layer nesting (multi-layer callback) will occur, and the readability of the code will be very poor. In contrast, the following code using collaboration will be much clearer.
fun test2() { val coroutineScope = CoroutineScope(Dispatchers.Main) coroutineScope.launch { val request1 = withContext(Dispatchers.IO) { // Sub thread makes network request request1() } // Switch to the main thread to update the UI Log.d("tag", request1) val request2 = withContext(Dispatchers.IO) { // Sub thread makes network request request2() } // Switch to the main thread to update the UI Log.d("tag", request2) } } suspend fun request1(): String { // Simulate a network request with a delay of 2s delay(2000) return "request1" } suspend fun request2(): String { // Simulate a network request with a delay of 1s delay(1000) return "request2" }
2. Test ox knife
Let's start using the collaboration. First create a Kotlin project using Android Studio, and then add the following configuration in build.gradle
buildscript { repositories { google() mavenCentral() } dependencies { classpath "com.android.tools.build:gradle:7.0.3" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:1.5.20" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } }
Then add the following dependencies in the build.gradle of the app module
dependencies { implementation 'androidx.core:core-ktx:1.3.2' implementation 'androidx.appcompat:appcompat:1.2.0' // ... // Collaborative process core library implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.5.0" // Synergetic Android support library implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0" }
In this way, we can use a collaborative process. Next, let's start with how to create a collaborative process.
2.1 create a collaborative process
We can start a collaborative process in the following three ways
- runBlocking will block the current thread until the statement in the closure is executed. Therefore, it will not be used in business development scenarios. It is generally used in main functions and unit tests.
- CoroutineScope.launch is suitable for performing tasks that do not need to return results. CoroutineScope.async is suitable for performing tasks that need to return results. We will talk about using the await suspend function to get the returned results in the next section.
- It is recommended to use coroutinescontext to build CoroutineScope to create a collaboration, because it can better control and manage the lifecycle of the collaboration. These two parts will be specifically discussed in the later principle part.
/** * Three ways to start a collaborative process */ fun startCoroutine() { // Start a coroutine through runBlocking // It interrupts the current thread until the execution of the statement in the runBlocking closure is completed runBlocking { fetchDoc() } // Start a collaboration through CoroutineScope.launch val coroutineScope = CoroutineScope(Dispatchers.IO) coroutineScope.launch { fetchDoc() } // Start a collaboration through CoroutineScope.async val coroutineScope2 = CoroutineScope(Dispatchers.Default) coroutineScope2.async { fetchDoc() } }
2.2 thread switching operation
In Kotlin Coroutines, the scheduler is mainly used to control thread switching. When creating a collaboration, you can pass in the specified scheduling mode to determine which thread the collaboration body block is executed in.
// Perform operations in a background thread someScope.launch(Dispatchers.Default) { // ... }
The code block of the above collaboration will be distributed to the thread pool managed by the collaboration for execution.
The thread pool in the above example belongs to Dispatchers.Default. At some time in the future, the code block will be executed by a thread in the thread pool. The specific execution time depends on the thread pool policy.
In addition to the Dispatchers.Default scheduler in the above example, there are two other schedulers
- Dispatchers.IO the code block under the scheduler will be executed in the IO thread, mainly processing network requests and IO operations.
- Dispatchers.Main the code block under the scheduler will be executed in the main thread, mainly to update the UI.
class MainActivity : AppCompatActivity() { private val mainScope = MainScope() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) startLaunch() } private fun startLaunch() { // Create a collaboration with default parameters, and its default scheduling mode is Main, that is, the thread environment of the collaboration is the Main thread val job1 = mainScope.launch { // delay is a pending function Log.d("startLaunch", "Before Delay") delay(1000) Log.d("startLaunch", "After Delay") } val job2 = mainScope.launch(Dispatchers.IO) { // The current thread environment is an IO thread Log.d("startLaunch", "CurrentThread + ${Thread.currentThread()}") withContext(Dispatchers.Main) { // The current thread environment is the main thread Log.d("startLaunch", "CurrentThread + ${Thread.currentThread()}") } } mainScope.launch { // After execution, there can be a return value val userInfo = getUserInfo() Log.d("startLaunch", "CurrentThread + ${Thread.currentThread()}") } } // withContext is a suspend function that can suspend the current coroutine (a new Context can be passed in) private suspend fun getUserInfo() = withContext(Dispatchers.IO) { delay(2000) "Hello World" } }
2.3 handling concurrent operations
Kotlin Coroutines performs concurrent operations through async keyword, which is usually used in conjunction with await method or awaitAll extension method.
class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) MainScope().launch { startAsync() } } private suspend fun startAsync() { val coroutineScope = CoroutineScope(Dispatchers.IO) // async will return the deferred object, which can be returned by await() val avatarJob = coroutineScope.async { fetchAvatar() } val nameJob = coroutineScope.async { fetchName() } // You can also return a collection of return values using the awaitAll() extension method of Collections val listOf = listOf(avatarJob, nameJob) val startTime = System.currentTimeMillis() val awaitAll = listOf.awaitAll() val endTime = System.currentTimeMillis() Log.d("startAsync", "Time spent ${endTime - startTime}ms") Log.d("startAsync", awaitAll.toString()) } private suspend fun fetchAvatar(): String { delay(2000) return "head portrait" } private suspend fun fetchName(): String { delay(2000) return "nickname" } }
The above code is a simple concurrent example. fetchAvatar is delayed by 2000ms and fetchName is delayed by 1000ms. Here, the awaitAll() method is used to return the result set. It will return the result after fetchAvatar is completed, that is, after 2000ms.
3. Understand Kotlin Coroutines hang function
In the above example, the suspend keyword appears many times. It is a keyword of Kotlin Coroutines. We call the function modified with the suspend keyword as the suspend function. For example, withContext and delay belong to pending functions.
3.1 what is suspend
So what exactly does hang mean? Will it block the current thread? Let's look at a daily example. We request user information from the server. This process is time-consuming. Therefore, we need to obtain user information in the IO thread. 1 suspend through withContext and switch to the IO thread to request user information. 2 return to the UI thread to update the UI. Will the main thread be blocked at this time? The answer is of course not, otherwise the page would have been stuck.
class UserViewModel: ViewModel() { // Refresh UI after requesting user information fun fetchUserInfo() { // Starting a context is the of the UI thread viewModelScope.launch(Dispatchers.Main) { // 1 suspend request user information val userInfo = withContext(Dispatchers.IO) { UserResp.fetchUserInfo() } // 2 update UI Log.d("fetchUserInfo", userInfo) } }
We understand suspension from the two roles of the current thread and the suspended coroutine.
thread
In fact, when the thread executes the suspend function of the coroutine, it will not continue to execute the coroutine code for the time being.
What will the thread do next?
If it is a background thread:
- Or have nothing to do and be recycled by the system
- Or continue to perform other background tasks
In the above example, the main thread is in the context of the collaboration, so the main thread will continue to work, that is, refresh the interface.
Synergetic process
At this time, the collaboration is suspended from the current thread. If its context is other threads, the collaboration will run on other threads without restrictions. Wait until the task is completed before switching back to the main thread
The above example is in the IO thread, so we will switch to the IO thread to request the user's information.
3.2 what is the function of suspend keyword
The above suspend modified functions have the function of suspending / switching threads.
Does any keyword that uses suspend have such a feature?
The answer is No. let's look at the following methods. We only print a paragraph of text without cutting it somewhere and then cutting it back. Therefore, the append keyword does not enable the coroutine to suspend / switch threads
suspend fun test() { println("I'm a pending function") }
So what's the use of the suspend keyword?
The answer is to remind the "function caller". What is modified by the suspend keyword is a time-consuming function that can only be used in the coroutine.
4. Understand several core concepts of Kotlin Coroutines
4.1 CoroutineContext - Context
CoroutineContext is the context of the collaboration process. It mainly carries the work of resource acquisition, configuration management, etc. it is a unified provider of general data resources related to the execution environment. It is extremely important to use the context of running the collaboration process in the collaboration process, so as to realize the correct thread behavior and life cycle.
CoroutineContext contains some user-defined data sets, which are closely related to the collaboration. It is a collection of indexed Element instances. This indexed collection is similar to a data structure between Set and Map. Each Element has a unique Key corresponding to it in this collection. Elements with the same Key cannot exist repeatedly of
Elements can be combined by the + sign. Element has several subclasses, and CoroutineContext is mainly composed of these subclasses:
- Job: the unique identifier of the collaboration process, which controls the lifecycle of the collaboration process.
- Coroutinedispatch: Specifies the thread on which the coroutine runs.
- CoroutineName: the name of the collaboration process. The default is coroutine. It is generally used during debugging.
- CoroutineExceptionHandler: refers to the exception handler of the coroutine, which is used to handle uncapped exceptions.
The CoroutineContext interface is defined as follows:
public interface CoroutineContext { // The operator [] is overloaded, and the Element associated with the Key can be obtained in the form of CoroutineContext[Key] public operator fun <E : Element> get(key: Key<E>): E? // It is an aggregation function that provides the ability to traverse each Element in the CoroutineContext from left to right, and perform an operation on each Element public fun <R> fold(initial: R, operation: (R, Element) -> R): R // Operator + overload, you can combine two CoroutineContext into one in the form of CoroutineContext + CoroutineContext public operator fun plus(context: CoroutineContext): CoroutineContext // Return a new CoroutineContext, which deletes the Element corresponding to the Key public fun minusKey(key: Key<*>): CoroutineContext // Key definition, empty implementation, only an identification public interface Key<E : Element> // Element definition. Each element is a CoroutineContext public interface Element : CoroutineContext { // Each Element has a Key instance public val key: Key<*> //... } }
Several characteristics of CoroutineContext can be found through interface definition
- The get operator is rewritten, so it can be accessed in the form of brackets like CoroutineContext[key] to access the elements in the map.
- The plus operator is overridden, so you can use the + sign to connect different CoroutineContext.
By viewing the source code, we can find that CoroutineContext is mainly implemented by CombinedContext, Element and EmptyCoroutineContext.
Element may wonder "why the element itself is also a collection". The main reason is that it is convenient to design the API, which means that only element is stored inside the element.
EmptyCoroutineContext is an empty implementation of CoroutineContext and does not hold any elements.
public operator fun plus(context: CoroutineContext): CoroutineContext = // If the CoroutineContext to be added is empty, no processing will be done and it will be returned directly if (context === EmptyCoroutineContext) this else // fast path -- avoid lambda creation // If the CoroutineContext to be added is not empty, fold it context.fold(this) { acc, element -> // We can understand acc as the CoroutineContext on the left of the + sign, and element as an element of the CoroutineContext on the right of the + sign //First, delete the element on the right from the CoroutineContext on the left val removed = acc.minusKey(element.key) // If removed is empty, it means that the CoroutineContext on the left is empty after deleting the same element as element, and then the element on the right can be returned if (removed === EmptyCoroutineContext) element else { // Ensure that the interceptor is always at the end of the collection val interceptor = removed[ContinuationInterceptor] if (interceptor == null) CombinedContext(removed, element) else { val left = removed.minusKey(ContinuationInterceptor) if (left === EmptyCoroutineContext) CombinedContext(element, interceptor) else CombinedContext(CombinedContext(left, element), interceptor) } } }
Since CoroutineContext is composed of a group of elements, the elements on the right side of the plus sign will overwrite the elements on the left side of the plus sign to form a newly created CoroutineContext. For example, (Dispatchers.Main, "name") + (Dispatchers.IO) = (Dispatchers.IO, "name").
4.2 job & deferred - tasks
4.2.1 Job
Job is used to process the collaboration. For each created collaboration (through launch or async), it will return a job instance, which is the unique ID of the collaboration and is responsible for managing the lifecycle of the collaboration.
In addition to using launch or async to create a Job, you can also create a Job through the following Job construction method.
public fun Job(parent: Job? = null): Job = JobImpl(parent)
This is well understood. When a parent is passed in, the Job at this time will be the child Job of the parent.
Since the Job is to manage the collaboration process, it provides six states to represent the running state of the collaboration process. See the official table
State | [isActive] | [isComplete] | isCancelled |
---|---|---|---|
New (optional initial state) | false | false | false |
Active (default initial state) | true | false | false |
Completing (transient state) | true | false | false |
Cancelling (transient state) | false | false | true |
Cancelled (final state) | false | true | true |
Completed (final state) | false | true | false |
Although we can't get the specific running state of the collaboration, we can get whether the current collaboration is in three states through isActive, isCompleted and isCancelled.
We can roughly understand the next collaboration job from creation to completion or cancellation through the following figure.
wait children +-----+ start +--------+ complete +-------------+ finish +-----------+ | New | -----> | Active | ---------> | Completing | -------> | Completed | +-----+ +--------+ +-------------+ +-----------+ | cancel / fail | | +----------------+ | | V V +------------+ finish +-----------+ | Cancelling | --------------------------------> | Cancelled | +------------+ +-----------+
4.2.2 Deferred
public interface Deferred<out T> : Job { public val onAwait: SelectClause1<T> public suspend fun await(): T @ExperimentalCoroutinesApi public fun getCompleted(): T @ExperimentalCoroutinesApi public fun getCompletionExceptionOrNull(): Throwable? }
By using async to create a Coroutine, you can get a Deferred with a return value. The Deferred interface inherits from the Job interface and provides an await method to obtain the return result of Coroutine. Since Deferred inherits from the Job interface, the contents related to Job are also applicable to Deferred.
4.3 CoroutineDispatcher - Scheduler
What is a scheduler? Kotlin official explains this
The scheduler determines which thread or threads the associated collaboration is executed on. The collaboration scheduler can limit the collaboration to a specific thread, assign it to a thread pool, or let it run unrestricted.
Dispatchers is a standard library that encapsulates the help class for switching threads, which can be simply understood as a thread pool.
public actual object Dispatchers { @JvmStatic public actual val Default: CoroutineDispatcher = createDefaultDispatcher() @JvmStatic public actual val Main: MainCoroutineDispatcher get() = MainDispatcherLoader.dispatcher @JvmStatic public actual val Unconfined: CoroutineDispatcher = kotlinx.coroutines.Unconfined @JvmStatic public val IO: CoroutineDispatcher = DefaultScheduler.IO }
Dispatchers.Default
The default scheduler is suitable for background computing. It is a CPU intensive task scheduler. If the dispatcher is not specified when creating Coroutine, it is generally used as the default value. The Default dispatcher uses a shared background thread pool to run internal tasks. Note that it shares a thread pool with IO, but limits the maximum concurrency .
Dispatchers.IO
As the name suggests, this is used to perform blocking IO operations. It shares a shared thread pool with Default to perform tasks inside. According to the number of tasks running at the same time, additional threads will be created when necessary, and unnecessary threads will be released after the task is completed.
Dispatchers.Unconfined
Since dispatcher.unconfined does not define a thread pool, the thread is started by default during execution. When the first hang point is encountered, the thread calling resume determines the thread to resume the collaboration.
Dispatchers.Main:
The specified execution thread is the main thread, which is the UI thread on Android.
4.4 CoroutineStart - starter
CoroutineStart process startup mode is the second parameter that needs to be passed in when starting the process. There are 4 startup modes:
CoroutineStart.DEFAULT
The default startup mode can be called hungry man startup mode, because scheduling starts immediately after the collaboration is created. Although it is scheduled immediately, it is not executed immediately, or it may be cancelled before execution.
CoroutineStart.LAZY
In lazy startup mode, there will be no scheduling behavior after startup, and scheduling will not occur until we need it to execute. In other words, scheduling will only start when we actively call the start, join or await functions of the Job.
CoroutineStart.ATOMIC
Like ATOMIC, it starts scheduling immediately after the collaboration is created, but it is a little different from the DEFAULT mode. The collaboration started in ATOMIC mode does not respond to the cancel operation until the first hang point. ATOMIC must involve the cancel operation after the collaboration is suspended.
CoroutineStart.UNDISPATCHED:
In this mode, the coroutine will directly start to execute in the current thread until it runs to the first hanging point.
4.5 CoroutineScope - scope of collaboration
To start a collaboration, its CoroutineScope must be specified. CoroutineScope can track the process, even if the process is suspended. Unlike the scheduler Dispatcher, CoroutineScope does not run the process, it just ensures that you do not lose track of the process.
Tasks in the collaboration process can be cancelled through CoroutineScope. In Android, we usually do time-consuming operations when the page is started. These time-consuming tasks are meaningless when the page is closed. At this time, the Activity or Fragment can start the collaboration process through lifecycleScope.
In order to clarify the relationship between parent-child processes and the propagation of abnormal processes, the scope of processes is officially divided into the following three categories
Top level scope
The scope of a collaboration without a parent collaboration is the top-level scope.
Synergy scope
Start a new collaboration process in the collaboration process. The new collaboration process is the child collaboration process of the collaboration process. In this case, the scope of the child collaboration process is the collaboration scope by default. At this time, the uncapped exceptions thrown by the child orchestration will be passed to the parent orchestration for processing, and the parent orchestration will also be cancelled.
Master-slave scope
It is consistent with the collaboration scope in terms of the parent-child relationship of the collaboration process. The difference is that when an uncapped exception occurs in the collaboration process under the scope, the exception will not be passed up to the parent collaboration process.
In addition to the behaviors mentioned in the three scopes, the following rules exist between parent-child processes:
If the parent process is cancelled, all child processes are cancelled. Since there is a parent-child collaboration relationship in both the collaboration scope and the master-slave scope, this rule applies. The parent process needs to wait for the execution of the child process before it finally enters the completion state, regardless of whether the process body of the parent process has been executed. The child collaboration inherits the elements in the context of the parent collaboration. If it has members with the same key, the corresponding key will be overwritten. The effect of overwriting is only valid within its own range.
5. Several problems of using synergy in Android 🌰
Note: the following example code is in ViewModel, so you can use viewModelScope
5.1 network request
At present, Retrofit officials have supported Kotlin Coroutines.
interface UserApi { @GET("url") suspend fun getUsers(): List<UserEntity> }
After the interface is defined, the most basic request is implemented with a coroutine.
private fun fetchUsers() { viewModelScope.launch { users.postValue(Resource.loading(null)) try { val usersFromApi = apiHelper.getUsers() users.postValue(Resource.success(usersFromApi)) } catch (e: Exception) { users.postValue(Resource.error(e.toString(), null)) } } }
You can also request multiple interfaces at the same time through Kotlin Coroutines, and refresh the UI after getting the data of the two interfaces.
private fun fetchUsers() { viewModelScope.launch { users.postValue(Resource.loading(null)) try { // coroutineScope is needed, else in case of any network error, it will crash coroutineScope { val usersFromApiDeferred = async { apiHelper.getUsers() } val moreUsersFromApiDeferred = async { apiHelper.getMoreUsers() } val usersFromApi = usersFromApiDeferred.await() val moreUsersFromApi = moreUsersFromApiDeferred.await() val allUsersFromApi = mutableListOf<ApiUser>() allUsersFromApi.addAll(usersFromApi) allUsersFromApi.addAll(moreUsersFromApi) users.postValue(Resource.success(allUsersFromApi)) } } catch (e: Exception) { users.postValue(Resource.error("Something Went Wrong", null)) } } }
5.2 operating Room database
As a member of JetPack, Room supports Kotlin Coroutines. The code is as follows
@Dao interface UserDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertUsers(vararg users: User) @Update suspend fun updateUsers(vararg users: User) @Delete suspend fun deleteUsers(vararg users: User) @Query("SELECT * FROM user WHERE id = :id") suspend fun loadUserById(id: Int): User @Query("SELECT * from user WHERE region IN (:regions)") suspend fun loadUsersByRegion(regions: List<String>): List<User> }
5.3 do some time-consuming operations
Kotlin Coroutines can also do some time-consuming operations, such as IO reading and writing, list sorting, etc. delay is used to simulate time-consuming tasks.
fun startLongRunningTask() { viewModelScope.launch { status.postValue(Resource.loading(null)) try { // do a long running task doLongRunningTask() status.postValue(Resource.success("Task Completed")) } catch (e: Exception) { status.postValue(Resource.error("Something Went Wrong", null)) } } } private suspend fun doLongRunningTask() { withContext(Dispatchers.Default) { // your code for doing a long running task // Added delay to simulate delay(5000) } }
5.4 set a timeout for time-consuming tasks
You can increase the timeout time of a collaboration process through withTimeout. When the task exceeds this time, a TimeoutCancellationException will be thrown.
private fun fetchUsers() { viewModelScope.launch { users.postValue(Resource.loading(null)) try { withTimeout(100) { val usersFromApi = apiHelper.getUsers() users.postValue(Resource.success(usersFromApi)) } } catch (e: TimeoutCancellationException) { users.postValue(Resource.error("TimeoutCancellationException", null)) } catch (e: Exception) { users.postValue(Resource.error("Something Went Wrong", null)) } } }
5.5 handling of global exceptions
You can customize CoroutineExceptionHandler to handle some non intercepted exceptions.
private val exceptionHandler = CoroutineExceptionHandler { _, exception -> users.postValue(Resource.error("Something Went Wrong", null)) } private fun fetchUsers() { viewModelScope.launch(exceptionHandler) { users.postValue(Resource.loading(null)) val usersFromApi = apiHelper.getUsers() users.postValue(Resource.success(usersFromApi)) } }
5.6 count down with Kotlin Flow
Start a countdown through Flow in the Activity or Fragment, and update the UI status every 1 s
lifecycleScope.launch { (59 downTo 0).asFlow() .onEach { delay(1000) } .flowOn(Dispatchers.Default) .onStart { Logger.d("Timer start") }.collect { remain -> Logger.d("Timer remaining $remain second") } }
6. Summary
Through this article, we reviewed "what is a collaborative process", "how to use a collaborative process", "understanding of hanging" and "Application of collaborative process in Android". In fact, just like Kotlin, a young language, coprocessor constantly optimizes and adds new functions, such as Flow, Channel and so on. If you have better comments, please leave a comment.
This article is composed of blog one article multi posting platform OpenWrite release!