61

Kotlin Coroutines Tutorial for Android : Advanced [FREE]

 4 years ago
source link: https://www.tuicool.com/articles/jInI3uU
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

In this Advanced Kotlin Coroutines Tutorial for Android, you’ll gain a deeper understanding of Kotlin Coroutines by replacing common asynchronous programming methods, such as creating new Thread s and using callbacks, in an Android app.

You’ll work on a modified version of the starter project RWDC2018 from the Android Background Processing video course developed by Joe Howard. For more in depth coverage of Kotlin Coroutines see Kotlin Coroutines by Tutorials by Filip Babić and Nishant Srivastava.

Note : This tutorial assumes you have experience with Android and Kotlin. But if that’s not the case, check out the Beginning Android Development with Kotlin series and other Kotlin and Android tutorials RayWenderlich.com has to offer.

What Are Coroutines?

By now, you’ve read a few articles and blog posts on Kotlin Coroutines. You’re thinking, “not another definition of coroutines!” Well, even though this isn’t a Getting Started post, it’s still best to understand the history of a topic before deciding on a definition.

Besides, you might learn something new. :]

Note : If you’re already familiar with coroutines in general and have read the official documentation, you might find this explanation redundant. If that’s the case, skip this section and jump over to code immediately.

The Origins

Coroutines are not a new concept. In fact, Melvin Conway, a mathematician, physicist, and computer scientist coined the term coroutines in his paper, “Design of a Separable Transition-Diagram Compiler” in 1958. His paper proposed to “organize a compiler as a set of coroutines, which gives the possibility of using separate passes in debugging and then running a single pass compiler in production.”

Coroutines were first implemented as methods in assembly language. They were then implemented in high-level languages like C, C++, C#, Clojure, Java, JavaScript, Python, Ruby, Perl, Scala and, of course, Kotlin.

So, what are coroutines?

Nowadays

The Kotlin Evolution and Enhancement Process, or KEEP, GitHub repository provides a more complete definition. It states that a coroutine is an “instance of suspendable computation.” This is conceptually similar to a thread because it uses a block of code to run and has a similar lifecycle.

The KEEP further states that a coroutine is “created and started, but it is not bound to any particular thread. It may suspend its execution in one thread and resume in another one. Moreover, like a future or a promise , it may complete with some result (which is either a value or an exception).”

In other words, coroutines mitigate the complications of working with asynchronous programming. The code you write is sequential. That makes it easier to understand than callbacks and various observable constructs.

Threads are expensive to create and require resources to maintain. That means there are only so many threads you can create in a system. Opposite of that, coroutines manage their own thread pools. Some dispatchers even share pools. A suspended coroutine doesn’t block any thread. It waits for the next available thread to resume it.

By decoupling work and threads, it’s possible to create and execute thousands of coroutines. This is within a finite thread pool and without any overhead.

In short, a coroutine is a code component with a lifecycle that is not bound to a single thread. Any thread in the pool can execute, suspend and resume the coroutine.

Getting Started

This tutorial is a bit unconventional when it comes to the code you’ll be working on. First, you’ll experiment with a few concepts and key components of coroutines in Kotlin Playground . Then you’ll switch to an Android app project where you’ll add a lot of advanced coroutine usage.

Key Components

These are the most commonly used Kotlin Coroutine components when implementing coroutines in an Andriod app.

Suspendable Functions

Coroutines work on the principle of suspendable functions . As you already learned, coroutines can pause and resume at any time between any number of threads. This process is called code suspension .

It allows coroutines to be lightweight and fast because they don’t really allocate any overhead, such as threads. Instead, they use predefined resources and smart resource management.

The system uses continuations to know when and where to resume a function.

Continuations

When a function suspends there is information, or state, of the suspended coroutine. Every time a coroutine suspends, it stores its state in a continuation. When the coroutine resumes, the continuation contains enough information to continue the rest of the coroutine’s execution seamlessly.

The Continuation interface consists of a CoroutineContext and a completion callback used to report the success or failure of the coroutine. In the snippet below, an existing asynchronous API service that uses callbacks is wrapped into a suspendable function, and it propagates the result or error using a Continuation . It’s just an example function, but the idea is there.

suspend fun <Data, Result> suspendAsyncApi(data: Data): Result =
  suspendCancellableCoroutine { continuation ->
    apiService.doAsyncStuff<Data, Result>(data,
        { result -> continuation.resume(result) }, // resume with a result
        { error -> continuation.resumeWithException(error) } // resume with an error
    )
  }

You can see how, by abstracting the function return value, with a coroutine and Continuation , you can return a value, without actually returning it immediately. You wrap the asynchronous API, which works with callbacks, into a suspendable function which, when called, will seem like sequential code. If you called this function from within another coroutine, it would look similar to this:

val username = suspendAsyncApi<String, String>("userId") // get the username for a given user id

This is not a real API, but you could essentially write your own API, which works similar to this. The important part is how coroutines and continuations bridge asynchronous and synchronous worlds while keeping the syntax clear.

Coroutine Context

Coroutine context is a persistent set of data about the coroutine. It’s contained within the Continuation , making it an immutable collection of thread-local variables and program state associated with the coroutine.

Since coroutines are lightweight, it’s not a limitation that the coroutine context is immutable. If the coroutine context needs to change, you can simply launch a new coroutine, with a mutated context.

Coroutine Builders

To start and run new coroutines, you have to use a Coroutine Builder . They take some code and wrap it in a coroutine, passing it to the system for execution. This makes them the bread and butter of coroutines.

The main builder for coroutines is launch() . It creates a new coroutine and launches it instantly by default. It builds and launches a coroutine in the context of some CoroutineScope :

GlobalScope.launch { // CoroutineScope
  // coroutine body
}

Once you get ahold of a CoroutineScope , you can use launch() on it, to start a coroutine. You can use coroutine builders in a normal non-suspending function, or other suspendable functions, starting nested coroutines.

Concurrent Execution

Another coroutine builder is async() . It’s special because you can use it to return a value from a coroutine, allowing for that allows concurrent execution. You would use async() from any coroutine, like so:

GlobalScope.launch { // CoroutineScope
  val someValue = async { getValue() } // value computed in a coroutine
}

However, you can’t use the value just yet. async() returns a Deferred which is a non-blocking cancellable future. To obtain the result you have to call await() . Once you start awaiting, you suspend the wrapping coroutine, until you get the computed value.

Blocking Builder

There’s another builder you can use for coroutines, which is a bit unconventional. runBlocking() forces coroutines to be blocking calls.

Note : runBlocking is a builder that blocks the thread until the execution completes to avoid JVM shutdown in special situations like main functions or tests. You should avoid using it in regular Kotlin coroutine code.

To explain how to start and execute Kotlin coroutines, it’s best to take a look at some live snippets of code:

import kotlinx.coroutines.*
import java.lang.Thread

fun main() {
    GlobalScope.launch {  // launch new coroutine in background and continue
        delay(1000L) // non-blocking delay for 1 second (default time unit is ms)
        println("World!") // print after delay
        val sum1 = async { // non blocking sum1
       	    delay(100L)
            2 + 2
        }
        val sum2 = async { // non blocking sum2
            delay(500L)
            3 + 3
        }
        println("waiting concurrent sums")
        val total = sum1.await() + sum2.await() // execution stops until both sums are calculated
        println("Total is: $total")
    }
    println("Hello,")     // main thread continues while coroutine executes
    Thread.sleep(2000L)   // block main thread for 2 seconds to keep JVM alive
}

Run the Coroutine Builder Example in a Kotlin Playground.

The snippet above launches a Kotlin coroutine which uses delay() to suspend the function for one second. Since Kotlin coroutines don’t block any threads, the code proceeds to the second println() statement and prints Hello, .

Next, the code sleeps the main thread so the program doesn’t finish before the coroutine completes its execution. The coroutine runs its second line and prints World! .

It then concurrently builds and starts two async coroutines. Finally, when both concurrent operations are complete, it prints the total.

This is a simple but effective way to learn about Kotlin coroutines and the idea behind them.

Take a look at the return type of launch() . It returns a Job which represents the piece of computation that you wrapped in a coroutine. You can nest jobs and create a child-parent hierarchy.

You’ll see how to use this to cancel coroutines in a later snippet.

One of the things you used above is the GlobalScope instance for the coroutine scope. Let’s see what scopes are and how you should approach them.

CoroutineScope

CoroutineScope s confine new coroutines by providing a lifecycle-bound component that binds to a coroutine. Every coroutine builder is an extension function defined on the CoroutineScope type. launch() is an example of a coroutine builder.

You already used GlobalScope . It’s useful for top-level coroutines that operate on the entire app lifetime and aren’t bound to any lifecycle. Typically, you’d use CoroutineScope over GlobalScope in an Android app to control when lifecycle events occur.

In an Android app, you implement CoroutineScope s on components with well-defined lifecycles. These components include Activity , Fragment and ViewModel .

Calling launch() on CoroutineScope s provides a Job that encapsulates a block of code. Once the scope cancels, all the Kotlin coroutines within clear up their resources and cancel, as well.

Take the following snippet of code:

import kotlinx.coroutines.*

fun main() = runBlocking { // this: CoroutineScope
    launch { 
        delay(200L)
        println("Task from runBlocking")
    }
    
    coroutineScope { // Creates a new coroutine scope
        val job = launch {
            println("Task from nested launch, this is printed")
            delay(500L) 
            println("Task from nested launch, this won't be printed")
        }
    
        delay(100L)
        println("Task from first coroutine scope") // Printed before initial launch
        job.cancel() // This cancels nested launch's execution
    }
    
    println("Coroutine scope is over") // This is not printed until nested launch completes/is cancelled
}

Run CoroutineScope Example in a Kotlin Playground. . Examining the snippet above, you’ll see a few things.

First, you force the coroutines to be blocking, so you don’t have to sleep the program as you did before. Then you launch a new coroutine which has an initial delay. After that, you use coroutineScope() to create a new scope. You then launch a coroutine within it saving the returned Job .

Because you delay the initial launch() , it doesn’t run, until the coroutineScope() executes fully. However, within the coroutineScope() , you store and delay the Job and the nested coroutine. Since you cancel it after it delays, it will only print the first statement, ultimately cancelling before the second print statement. And as the coroutineScope() finishes, the initial launch() finishes its delay, and it can proceed with execution.

Finally, once the scope finishes, the runBlocking() can finish as well. This ends the program. It’s important to understand this flow of execution, to build stable coroutines, without race conditions, or hanging resources.

Job

In the previous section you saw how to cancel the execution of a coroutine. You should understand that a Job is a cancellable component with a lifecycle.

Jobs are typically created by calling launch() . You can also create them using a constructor – Job() . They can live within the hierarchy of other jobs either as the parent or a child. If you cancel a parent Job then you also cancel all its children.

If a child Job fails, or cancels, then its parent and parent hierarchy will cancel, as well. The exception the hierarchy receives is, of course, a CancellationException .

Note : There is a special type of a Job which doesn’t cancel if one of its children fail – the SupervisorJob . You can check it out at the official documentation .

So, the failure of a child will, by default, cancel its parent and any other children in the hierarchy. Sometimes you need to wait until a coroutine execution is effectively canceled. In that case, you can call job.cancelAndJoin() instead of job.cancel() .

import kotlinx.coroutines.*

fun main() = runBlocking {
    val startTime = System.currentTimeMillis()
    val job = launch(Dispatchers.Default) {
        var nextPrintTime = startTime
        var i = 0
        while (isActive) { // cancelable computation loop
            // print a message twice a second
            if (System.currentTimeMillis() >= nextPrintTime) {
                println("I'm sleeping ${i++} ...")
                nextPrintTime += 500L
            }
        }
    }
    delay(1300L) // delay a bit
    println("main: I'm tired of waiting!")
    job.cancelAndJoin() // cancels the job and waits for its completion
    println("main: Now I can quit.")    
}

Run the Cancelable CoroutineScope Example in a Kotlin Playground.

The output for the program will be a few prints from the while loop, following with the cancel and finally the main() finishing.

There are benefits to being able to cancel a coroutine in an Android app. For example, say an app goes into the background and an Activity stops. In that case, you should cancel any long-running API calls, to clean up resources. This will help you avoid possible memory leaks or unwanted behavior.

You can cancel a Job, along with any children, from an Activity event like onStop() . And it’s even easier if you do it through the use of CoroutineScope , but you’ll do that later.

CoroutineDispatchers

Dispatchers determine what thread or thread pool the coroutine uses for execution. The dispatcher can confine a coroutine to a specific thread. It can also dispatch it to a thread pool. Less commonly, it can allow a coroutine to run unconfined, without a specific threading rule, which can be unpredictable.

Here are some common dispatchers:

Dispatchers.Main : This dispatcher confines coroutines to the main thread for UI-driven programs, like Swing, JavaFX, or Android apps. It’s important to note that this dispatcher doesn’t work without adding an environment-specific Main dispatcher dependency in Gradle or Maven.

Use Dispatchers.Main.immediate for optimum UI performance on updates.

Dispatchers.Default : This is the default dispatcher used by standard builders. It’s backed by a shared pool of JVM threads. Use this dispatcher for CPU intensive computations.

Dispatchers.IO : Use this dispatcher for I/O-intensive blocking tasks that uses a shared pool of threads.

Dispatchers.Unconfined : This dispatcher doesn’t confine coroutines to any specific thread. The couroutine starts the execution in the inherited CoroutineDispatcher that called it. But after a suspension ends, it may resume in any other thread.

This lack of confinement may lead to a coroutine destined for background execution to run on the main thread, so use it sparingly.

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    launch { //context of the parent, main runBlocking coroutine
        println("main runBlocking: I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Unconfined) { //not confined -- will inmediatly run in main thread but not after suspension
        println("Unconfined: I'm working in thread ${Thread.currentThread().name}")
        delay(100L) // delays (suspends) execution 100 ms
        println("Unconfined: I'm working in thread ${Thread.currentThread().name}")
    }
    launch(Dispatchers.Default) { //will get dispatched to DefaultDispatcher 
        println("Default: I'm working in thread ${Thread.currentThread().name}")
    }
    launch(newSingleThreadContext("MyOwnThread")) {// will get its own new thread
        println("newSingleThreadContext: I'm working in thread ${Thread.currentThread().name}")
    }    
}

Run the CoroutineDispatcher Example in a Kotlin Playground. The print order changes per execution in the playground.

You’ll see how each of the dispatchers prints its own context – its own thread. Furthermore, you can see how you can create your own single-threaded contexts if you need a specific thread for some coroutine.

Exception Handling

On the JVM, threads are at the core of the Kotlin coroutines machinery. The JVM has a well-defined way of dealing with terminating threads and uncaught exceptions.

If an uncaught exception occurs in a thread, the JVM will query the thread for an UncaughtExceptionHandler . The JVM then pass it the terminating thread and the uncaught exception. This is important because coroutines and Java concurrency deal with the same exception behavior.

Coroutine builders fall into two exception categories. The first propagate automatically, like the launch() , so if bad things happen, you’ll know soon enough. The second expose exceptions for the user to handle, such as async() . They won’t propagate until you call await() to get the value.

In Android, builders that propagate exceptions also rely on Thread.UncaughtExceptionHandler . It installs as a global coroutine exception handler. However, coroutine builders allow the user to provide a CoroutineExceptionHandler to have more control over how you deal with exceptions.

import kotlinx.coroutines.*

fun main() = runBlocking<Unit> {
    // propagating exception to the default Thread.UncaughtExceptionHandler
    val job = GlobalScope.launch {
        throw AssertionError()
    }
    
    // blocks thread execution until coroutine completes
    job.join()
    
    // launches async coroutine but exception is not propagated until await is called
    val deferred = GlobalScope.async(Dispatchers.Default) {
        throw AssertionError()
    }
  
    //defines a specific handler
    val handler = CoroutineExceptionHandler { _, exception -> 
            println("We caught $exception") 
    }

    // propagating exception using a custom CoroutineExceptionHandler
    GlobalScope.launch(handler) {
        throw AssertionError()
    } 
    
    // This exception is finally propagated calling await and should be handled by user eg. with try {} catch {}
    deferred.await() 
}

Run the Propagating exception handling example in a Kotlin Playground.

You should see an error being caught immediately. After that, comment out the first throw clause. You should once again see an exception thrown, but this time from async() . If you comment out the await() CoroutineExceptionHandler catches the exception, and prints out which exception happened.

Knowing this, there are three ways to handle exceptions. First is using try/catch within a launch() , when you don’t have a custom exception handler. The second is by wrapping await() calls in a try/catch block. And the last is one is to use an exception handler, to provide one place to catch exceptions.

Time to Code

You’re going to work on a modified version of the RWDC2018 app from the Android Background Processing video course developed by Joe Howard. The modified app only displays photos taken at RWDevCon 2018 .

The app retrieves these photos by using background threads. You’re going to replace the background threads implementation with Kotlin Coroutines.

Downloading the Project

Download the starter and final projects by clicking the Download Materials button at the top or bottom of this tutorial. Then, import the starter project in Android Studio. Take a moment to familiarize yourself with the structure of the project.

coroutines-start-1-650x414.png

Next, navigate to the PhotosRepository.kt in Android Studio. This class contains the thread code to download the banner and photos for the RecyclerView .

The photos download in a background thread. You then store the results in LiveData using postValue() . postValue() updates the data on the main thread.

coroutines-start-2-650x414.png

Adding Dependencies

You need to add the Kotlin Coroutine dependencies to the app module. First, open build.gradle in the app module and add the following dependencies:

  • ‘org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1’ : Core primitives to work with coroutines, such as builders, dispatchers and suspends functions.
  • ‘org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1’ : Provides Dispatchers.Main context for Android applications.

To help you with that, copy and paste the following snippet into the Gradle script:

implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.2.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1'

Sync the project to download the dependencies.

Life-cycle Awareness

Now that you have the dependency for Kotlin coroutines in your project, you can start implementing them. You’ll begin with the end in mind. It sounds kind of apocalyptic, but it’s not! :]

You need to prepare your code to clean up active coroutines before you start implementing them. You’ll provide a way to cancel any active coroutines if the user decides to rotate or background the app, triggering Fragment and Activity life-cycle.

You’re going to extend these Android life-cycle events to Kotlin classes which will handle coroutines internally.

Updating the Repository

First, open Repository.kt and have it extend LifecycleObserver . Then add a new function which will provide the ability to hook in the life-cycle from the PhotosFragment . Add the function like below:

interface Repository : LifecycleObserver {
    fun getPhotos(): LiveData<List<String>>
    fun getBanner(): LiveData<String>

    fun registerLifecycle(lifecycle: Lifecycle)
}

Updating Injection Singleton

Next, open Injection.kt . Change the method signature on provideViewModelFactory() to include a Lifecycle parameter. Then register the Lifecycle in the Repository .

package com.raywenderlich.android.rwdc2018.app

import android.arch.lifecycle.Lifecycle
import com.raywenderlich.android.rwdc2018.repository.PhotosRepository
import com.raywenderlich.android.rwdc2018.repository.Repository
import com.raywenderlich.android.rwdc2018.ui.photos.PhotosViewModelFactory


object Injection {

    private fun provideRepository(): Repository {
        return PhotosRepository()
    }

    fun provideViewModelFactory(lifecycle: Lifecycle): PhotosViewModelFactory {
        val repository = provideRepository()
        repository.registerLifecycle(lifecycle)
        return PhotosViewModelFactory(repository)
    }
}

Updating the PhotosFragment

Now, open PhotosFragment.kt . Provide the lifecylce as an argument in provideViewModelFactory() in onAttach .

override fun onAttach(context: Context?) {
    super.onAttach(context)

    val viewModelFactory = Injection.provideViewModelFactory(lifecycle)
    viewModel = ViewModelProviders.of(this, viewModelFactory).get(PhotosViewModel::class.java)
}

Registering the Lifecycle

Next, open PhotosRepository.kt again, and implement the new function.

override fun registerLifecycle(lifecycle: Lifecycle) {
    lifecycle.addObserver(this)
}

Main-Safe Design

Google encourages main-safety when writing coroutines. The concept is similar to how the Android system creates a main thread when an app launches.

The main thread is in charge of dispatching events to the appropriate user interface widgets. You should delegate I/O and CPU intensive operations to a background thread to avoid jank in the app.

Main-safety is a design pattern for Kotlin coroutines. It lets coroutines use the Dispatchers.Main, or main thread, as a default threading context. They then favor delegating to Dispatchers.IO for heavy I/O operations or Dispatchers.Default for CPU heavy operations.

A CoroutineScope interface is available to classes which require scoped coroutines. However, you must define a CoroutineContext instance which will the scope will use for all the coroutines. Let’s do that.

Defining CoroutineScope

First, open the PhotosRepository . Next, implement CoroutineScope . Then define a Job and CoroutineScope .

class PhotosRepository : Repository, CoroutineScope {
  private val TAG = PhotosRepository::class.java.simpleName
  private val job: Job = Job()
  override val coroutineContext: CoroutineContext
    get() = Dispatchers.Main + job

  //...omitted code...

}

The Job will determine if the coroutine is active and you will then use to cancel it. Per the main-safe design pattern, the Dispatchers.Main defines the CoroutineScope .

Hooking Into the Life-cycle

Now, add the following code to the PhotosRepository . This cancels any active coroutines when Android calls the PhotosFragment Lifecycle.Event.ON_STOP .

@OnLifecycleEvent(Lifecycle.Event.ON_STOP)
private fun cancelJob() {
  Log.d(TAG, "cancelJob()")
  if (job.isActive) {
      Log.d(TAG, "Job active, canceling")
      job.cancel()
  }
}

A typical implementation is to include a Job instance plus a Dispatcher as context for the scope. Implementing the interface will let you call launch() at any place and handle cancellation with the Job you provided. The suspend functions can then call withContext(Dispatchers.IO) or withContext(Dispatchers.Default) to delegate work to background threads if necessary. Keeping the initial threading tied to the main thread.

Introducing Coroutines

Both the fetchBanner() and fetchPhotos() use a Runnable and execute with a new Thread . First, you have to change the method implementation to use Kotlin coroutines. Then, you’ll run the project, to see if everything works as before.

The banner and images will download in the background. They’ll display like before with the separate background thread implementation.

// Dispatchers.Main
private suspend fun fetchBanner() {
  val banner = withContext(Dispatchers.IO) {
    // Dispatchers.IO
    val photosString = PhotosUtils.photoJsonString()
    // Dispatchers.IO
    PhotosUtils.bannerFromJsonString(photosString ?: "")
  }
 // Dispatchers.Main
 if (banner != null) {
    // Dispatchers.Main
    bannerLiveData.value = banner
  }
}

// Dispatchers.Main
private suspend fun fetchPhotos() {
  val photos = withContext(Dispatchers.IO) {
    // Dispatchers.IO
    val photosString = PhotosUtils.photoJsonString()
    // Dispatchers.IO
    PhotosUtils.photoUrlsFromJsonString(photosString ?: "")
  }
  // Dispatchers.Main
  if (photos != null) {
    // Dispatchers.Main
    photosLiveData.value = photos
  }
}

The functions above are annotated with comments. They show what thread or thread pool executes each line of code. Because they are now marked with suspend , you have to change these function declarations, to avoid compiler errors:

override fun getPhotos(): LiveData<List<String>> {
  launch { fetchPhotos() }
  return photosLiveData
}

override fun getBanner(): LiveData<String> {
  launch { fetchBanner() }
  return bannerLiveData
}

In this case, the thread is Dispatchers.Main and the thread pool is Dispatchers.IO . This helps visualize the main-safety design.

Now build and deploy the app to the emulator. Filter logcat with PhotosFragment and then background the app. In the log prints you’ll see a PhotsFragment Lifecycle.Event.ON_STOP triggering an active coroutine Job to cancel.

2019-05-21 22:35:06.937 29522-29522/com.raywenderlich.android.rwdc2018 D/PhotosRepository: cancelJob()  2019-05-21 22:35:06.937 29522-29522/com.raywenderlich.android.rwdc2018 D/PhotosRepository: Job active, canceling

Congratulations! You’ve successfully converted asynchronous code to Kotlin coroutines. And everything still works, but looks nicer! :]

Where to Go From Here?

You can download the completed project by clicking on the Download Materials button at the top or bottom of the tutorial. Continue building your understanding of Kotlin Coroutines and how you can use them in Android app development. Resources like Kotlin Coroutines by Tutorials , kotlinx.coroutines and Coroutines Guide are great references. If you have any questions or comments, please join the forum below.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK