[Translation] The modern approach to competition in Android: Korutin in Kotlin

[Translation] The modern approach to competition in Android: Korutin in Kotlin


Hello, Habr!

We remind you that we have already opened pre-order a long-awaited book about the Kotlin language from the famous Big Nerd Ranch Guides series. Today we decided to bring to your attention a translation of an article telling about Kotlin's quilts and the correct work with streams in Android. The topic is being discussed very actively, so for the sake of completeness, we also recommend watching this article with Habr and this detailed post from the Axmor Software blog.

A modern Java/Android concurrency framework creates callback hell and leads to blocking states, as Android does not have a simple enough way to guarantee thread safety.

Kotlin korutiny is a very effective and complete toolkit that makes managing competitiveness much easier and more productive.

Suspending and Blocking: What's the Difference

Cortinas do not replace threads, but rather provide a framework for managing them. The coruntine philosophy is to define a context that allows to wait until background operations are completed without blocking the main thread.

The goal of Corutin in this case is to avoid callbacks and simplify competition.

The simplest example

To begin with, let's take the simplest example: run coruntine in the context of Main (main thread). In it, we will extract the image from the IO stream and send this image for processing back to Main .

  launch (Dispatchers.Main) {
  val image = withContext (Dispatchers.IO) {getImage ()}//get from IO context
  imageView.setImageBitmap (image)//Return to the main thread
 }  

The code is simple as a single-threaded function. Moreover, while getImage is executed in the dedicated thread pool IO , the main thread is free and can take on any other task! The withContext function suspends the current coruntine while its action is running ( getImage () ). As soon as getImage () returns and the looper from the main thread becomes available, quorumine will resume work in the main stream and call imageView.setImageBitmap (image) .

The second example: now we need to have 2 background tasks performed so that they can be used. We will use the async/await duet so that these two tasks are performed in parallel, and use their result in the main thread as soon as both tasks are ready:

  val job = launch (Dispatchers.Main) {
  val deferred1 = async (Dispatchers.Default) {getFirstValue ()}
  val deferred2 = async (Dispatchers.IO) {getSecondValue ()}
  useValues ​​(deferred1.await (), deferred2.await ())
 }
 job.join ()//suspends the execution of the current coroutine until the task is completed  

async is similar to launch , but returns deferred (Kotlin entity equivalent to Future ), so its result can be obtained using await () . When called without parameters, it works in the default context for the current scope.

Again, the main thread remains free while we wait for our 2 values.
As you can see, the launch function returns a Job that you can use to wait until the operation completes — this is done using the join () function. It works like in any other language, with the proviso that it simply pauses the coruntine, and does not block the flow .

Dispatching

Dispatching is a key concept when working with Corutin. This is an action that allows you to "jump" from one thread to another.

Consider how the equivalent for dispatching in Main looks like in java, that is,

  runOnUiThread:
 public final void runOnUiThread (Runnable action) {
  if (Thread.currentThread ()! = mUiThread) {
  mHandler.post (action);//Dispatching
  } else {
  action.run ();//Immediate execution
  }
 }  

The Main implementation for Android is a dispatcher based on Handler . So this is really a very suitable implementation:

  launch (Dispatchers.Main) {...}
  vs
 launch (Dispatchers.Main, CoroutineStart.UNDISPATCHED) {...}

//Starting with kotlinx 0.26:
 launch (Dispatchers.Main.immediate) {...}  

launch (Dispatchers.Main) sends Runnable to Handler , so its code is not executed immediately.

launch (Dispatchers.Main, CoroutineStart.UNDISPATCHED) will immediately execute its lambda expression in the current thread.

Dispatchers.Main ensures that when corutin resumes, it will be sent to the main thread ; In addition, Handler is used here as the native Android implementation for sending app events to the loop.

Exact implementation looks like this:

  val Main: HandlerDispatcher = HandlerContext (mainHandler, "Main")  

Here is a good article to help you understand the subtleties of dispatching in Android:
val job = Job () val exceptionHandler = CoroutineExceptionHandler {   coroutineContext, throwable - & gt; whatever (throwable) } launch (Disaptchers.Default + exceptionHandler + job) {...}
job.cancel () will cancel all coroutines whose job is the parent. A exceptionHandler will receive all the exceptions thrown in these quiches.

Scope

The coroutineScope interface simplifies error handling:
If any of its subsidiary Corutin fails, all scope and all subsidiary Corutins will be canceled.

In the async example, if the value could not be retrieved, and another task continued to work - we have a damaged state, and something needs to be done about it.
When working with coroutineScope , the useValues ​​ function will be called only if the retrieval of both values ​​was successful. Also, if deferred2 refuses, deferred1 will be canceled.

  coroutineScope {
  val deferred1 = async (Dispatchers.Default) {getFirstValue ()}
  val deferred2 = async (Dispatchers.IO) {getSecondValue ()}
  useValues ​​(deferred1.await (), deferred2.await ())
 }  

You can also “put in the scope” a whole class to set its default CoroutineContext context and use it.

Example of a class implementing the CoroutineScope interface:

  open class ScopedViewModel: ViewModel (), CoroutineScope {
  protected val job = Job ()
  override val coroutineContext = Dispatchers.Main + job

  override fun onCleared () {
  super.onCleared ()
  job.cancel ()
  }
 }  

Running Corutin at CoroutineScope :

The default launch or async manager now becomes the manager of the current scope.

  launch {
  val foo = withContext (Dispatchers.IO) {...}
//lambda expression is executed in the context of the CoroutineContext scope
  ...
 }

 launch (Dispatchers.Default) {
//lambda expression is executed in the default thread pool
  ...
 }  

Autonomous launch of a cortina (outside of any CoroutineScope):

  GlobalScope.launch (Dispatchers.Main) {
//lambda expression is executed in the main thread.
  ...
 }  

You can even define the scope for the application by setting the Main manager to default:

  object AppScope: CoroutineScope by GlobalScope {
  override val coroutineContext = Dispatchers.Main.immediate
 }
  

Remarks

  • Coroutines limit Java interoperability
  • Limit volatility to avoid blocking
  • Cortuses are designed to wait, not stream,
  • Avoid I/O in Dispatchers.Default (and Main ...) - Dispatchers.IO is designed for this
  • Threads are resource intensive, so single-threaded contexts are used.
  • Dispatchers.Default is based on ForkJoinPool , which appeared in Android 5+
  • Qorutinas can be used through channels

Getting rid of locks and callbacks using channels

Channel definition from JetBrains documentation:

The
Channel channel is conceptually very similar to BlockingQueue . The key difference is that it does not block the put operation, it provides a suspending send (or non-blocking offer ), and instead of blocking a take operation it provides a suspending receive .


Actors

Consider a simple tool for working with channels: Actor .

Actor , again, is very similar to Handler : we define the context of the cortina (that is, the stream in which we are going to perform the actions) and work with it in a sequential order. < br/>
The difference, of course, is that cortinas are used here; you can specify the power, and the executable code to pause .

In principle, actor will redirect any command to the Corutina channel. It guarantees the execution of a command and restricts operations in its context . This approach perfectly helps to get rid of synchronize calls and keep all threads free!

  protected val updateActor by lazy {
  actor & lt; Update & gt; (capacity = Channel.UNLIMITED) {
  for (update in channel) when (update) {
  Refresh - & gt;  updateList ()
  is Filter - & gt;  filter.filter (update.query)
  is MediaUpdate - & gt;  updateItems (update.mediaList as List & lt; T & gt;)
  is MediaAddition - & gt;  addMedia (update.media as T)
  is MediaListAddition - & gt;  addMedia (update.mediaList as List & lt; T & gt;)
  is MediaRemoval - & gt;  removeMedia (update.media as T)
  }
  }
 }//use
 fun filter (query: String?) = updateActor.offer (Filter (query))//or
 suspend fun filter (query: String?) = updateActor.send (Filter (query))  

In this example, we use the sealed Kotlin classes, choosing which action to perform.

  sealed class Update
 object Refresh: Update ()
 class Filter (val query: String?): Update ()
 class MediaAddition (val media: Media): Update ()  

Moreover, all these actions will be put in a queue, they will never be executed in parallel. This is a convenient way to achieve limitability of variability .

Android + Corutin Life Cycle

Actors can be very useful for controlling the user interface of Android, simplify the cancellation of tasks and prevent overloading the main stream.
Let's implement this and call job.cancel () when you destroy activity.

  class MyActivity: AppCompatActivity (), CoroutineScope {
  protected val job = SupervisorJob ()//Job instance for this activity
  override val coroutineContext = Dispatchers.Main.immediate + job


  override fun onDestroy () {
  super.onDestroy ()
  job.cancel ()//cancel task when killing activity
  }
 }  

The SupervisorJob class is similar to the usual Job with the only exception that cancellation only extends in the downstream direction.

Therefore, we do not cancel all of the corutin in Activity when one of them refuses.

Things are a little better with the
extension function that allows access to this CoroutineContext from any View in CoroutineScope .

  val View.coroutineContext: CoroutineContext?
  get () = (context as? CoroutineScope)?. coroutineContext  

Now we can combine all this, the setOnClick function creates a combined actor to control its onClick actions. In the case of multiple clicks, intermediate actions will be ignored, thus eliminating ANR errors (the application is not responding), and these actions will be performed in the Activity scope. Therefore, if you destroy the activity, all this will be canceled.

  fun View.setOnClick (action: suspend () - & gt; Unit) {
//run one actor as the parent of the context task
  val scope = (context as? CoroutineScope) ?: AppScope
  val eventActor = scope.actor & lt; Unit & gt; (capacity = Channel.CONFLATED) {
  for (event in channel) action ()
  }
//set the listener to activate this actor
  setOnClickListener {eventActor.offer (Unit)}
 }  

In this example, we set the Channel value to Conflated so that it ignores part of the events if there are too many of them. You can replace it with Channel.UNLIMITED if you prefer to queue events without losing any of them, but you still want to protect the application from ANR errors.

You can also combine cortices and lifecycle frameworks to automate the cancellation of tasks associated with the user interface:

  val LifecycleOwner.untilDestroy: Job get () {
  val job = Job ()

  lifecycle.addObserver (object: LifecycleObserver {
  @OnLifecycleEvent (Lifecycle.Event.ON_DESTROY)
  fun onDestroy () {job.cancel ()}
  })

  return job
 }//use
 GlobalScope.launch (Dispatchers.Main, parent = untilDestroy) {
/* amazing things happen here!  */
 }  

Simplify Callbacks (Part 1)

Here's how to transform the use of call-based APIs thanks to Channel .

The API works like this:

  1. requestBrowsing (url, listener) initiates the parsing of the folder located at url .
  2. The listener listener gets onMediaAdded (media: Media) for any media file found in this folder.
  3. listener.onBrowseEnd () is called upon completion of the parsing of the folder

Here is the old refresh function in the content provider for the VLC browser:

  private val refreshList = mutableListOf & lt; Media & gt; ()

 fun refresh () = requestBrowsing (url, refreshListener)

 private val refreshListener = object: EventListener {
  override fun onMediaAdded (media: Media) {
  refreshList.add (media))
  }
  override fun onBrowseEnd () {
  val list = refreshList.toMutableList ()
  refreshList.clear ()
  launch {
  dataset.value = list
  parseSubDirectories ()
  }
  }
 }  

How to improve it?

Create a channel that will be launched at refresh . Now browser callbacks will only send media to this channel, and then close it.

Now the refresh function has become clearer. It creates a channel, calls the VLC browser, then builds a list of media files and processes it.

Instead of the select or consumeEach functions, you can use for to wait for the media, and this cycle will break as soon as the browserChannel channel will close.

  private lateinit var browserChannel: Channel & lt; Media & gt;

 override fun onMediaAdded (media: Media) {
  browserChannel.offer (media)
 }

 override fun onBrowseEnd () {
  browserChannel.close ()
 }

 suspend fun refresh () {
  browserChannel = Channel (Channel.UNLIMITED)
  val refreshList = mutableListOf & lt; Media & gt; ()
  requestBrowsing (url)
//Suspends at each iteration while waiting for media.
  for (media in browserChannel) refreshList.add (media)
//The channel is closed
  dataset.value = refreshList
  parseSubDirectories ()
 }  

Simplify callbacks (part 2): Retrofit

The second approach: we do not use kotlinx cortuins at all, but we use a corutin core framework.

See how the Korutins actually work!

The retrofitSuspendCall function wraps the Retrofit Call call request to make suspend .

With suspendCoroutine , we call the Call.enqueue method and suspend the coruntine. The callback provided this way will contact continuation.resume (response) to resume the quortenine response from the server as soon as it is received.

Next, we just have to combine our Retrofit functions into retrofitSuspendCall to return the results of the queries with their help.

  suspend inline fun & lt; reified T & gt;  retrofitSuspendCall (request: () - & gt; Call & lt; T & gt;
 ): Response & lt; T & gt;  = suspendCoroutine {continuation - & gt;
  request.invoke (). enqueue (object: Callback & lt; T & gt; {
  override fun onResponse (call: Call & lt; T & gt ;, response: Response & lt; T & gt;) {
  continuation.resume (response)
  }
  override fun onFailure (call: Call & lt; T & gt ;, t: Throwable) {
  continuation.resumeWithException (t)
  }
  })
 }

 suspend fun browse (path: String?) = retrofitSuspendCall {
  ApiClient.browse (path)
 }
//use (in the context of the main cortina)
 livedata.value = Repo.browse (path)  

Thus, the call blocking the network is made in the selected Retrofit stream, the coroutine is here, waiting for a response from the server, and there is no place to use it in the application!

This implementation is inspired by the gildor/kotlin-coroutines-retrofit library.

There is also a JakeWharton/retrofit2-kotlin-coroutines-adapter with a different implementation giving a similar result.

Epilogue

Channel can be used in many other ways; look at BroadcastChannel more powerful implementations that may be useful to you.

You can also create feeds with Produce . < br/>
Finally, using channels, it is convenient to organize communication between UI components: the adapter can transmit click events to its fragment/activity via the Channel or, for example, through Actor .

Source text: [Translation] The modern approach to competition in Android: Korutin in Kotlin