Une approche moderne de la concurrence dans Android: Corotins chez Kotlin

Bonjour, Habr!

Nous vous rappelons que nous avons déjà une précommande pour le livre tant attendu sur la langue Kotlin de la célèbre série Big Nerd Ranch Guides. Aujourd'hui, nous avons décidé de porter à votre attention la traduction d'un article décrivant les coroutines Kotlin et le bon fonctionnement des flux dans Android. Le sujet est discuté très activement, par conséquent, pour être complet, nous vous recommandons également de consulter cet article de Habr et cet article détaillé du blog Axmor Software.

Le cadre concurrentiel moderne de Java / Android inflige un enfer aux rappels et conduit à des états de blocage, car Android n'a pas un moyen assez simple de garantir la sécurité des threads.

Les coroutines Kotlin sont une boîte à outils très efficace et complète qui rend la gestion de la concurrence beaucoup plus facile et plus productive.

Pause et blocage: quelle est la différence

Les coroutines ne remplacent pas les threads, mais fournissent plutôt un cadre pour les gérer. La philosophie de corutin est de définir un contexte qui vous permet d' attendre la fin des opérations en arrière-plan sans bloquer le thread principal.

L'objectif de Corutin dans ce cas est de se passer de rappels et de simplifier la concurrence.

Exemple le plus simple

Pour commencer, prenons l'exemple le plus simple: exécutez coroutine dans le contexte de Main (thread principal). Dans celui-ci, nous allons extraire l'image du flux d' IO et renvoyer cette image pour traitement vers Main .

 launch(Dispatchers.Main) { val image = withContext(Dispatchers.IO) { getImage() } //    IO imageView.setImageBitmap(image) //     } 

Le code est simple en tant que fonction à un seul thread. De plus, alors que getImage est exécuté dans le pool alloué de threads d' IO , le thread principal est libre et peut assumer n'importe quelle autre tâche! La fonction withContext interrompt la coroutine actuelle pendant l'exécution de son action ( getImage() ). Dès que getImage() revient et que le looper du thread principal devient disponible, la coroutine reprend le travail dans le thread principal et appelle imageView.setImageBitmap(image) .

Le deuxième exemple: nous avons maintenant besoin de 2 tâches d'arrière-plan pour qu'elles puissent être utilisées. Nous utiliserons le duo async / attente pour que ces deux tâches soient effectuées en parallèle, et utiliserons leur résultat dans le thread principal dès que les deux tâches seront prêtes:

 val job = launch(Dispatchers.Main) { val deferred1 = async(Dispatchers.Default) { getFirstValue() } val deferred2 = async(Dispatchers.IO) { getSecondValue() } useValues(deferred1.await(), deferred2.await()) } job.join() //    ,      

async est similaire au launch , mais retourne deferred (une entité Kotlin équivalente à Future ), donc son résultat peut être obtenu en utilisant await() . Lorsqu'il est appelé sans paramètres, il fonctionne dans le contexte par défaut de la portée actuelle.

Encore une fois, le thread principal reste libre pendant que nous attendons nos 2 valeurs.
Comme vous pouvez le voir, la fonction de launch renvoie Job , qui peut être utilisée pour attendre la fin de l'opération - cela se fait à l'aide de la fonction join() . Il fonctionne comme dans n'importe quelle autre langue, avec la mise en garde qu'il suspend simplement la coroutine et ne bloque pas le flux .

Envoi

La répartition est un concept clé lorsque vous travaillez avec des coroutines. Cette action vous permet de "sauter" d'un fil à l'autre.

Considérez à quoi ressemble l'équivalent pour la répartition dans Main en Java, c'est-à-dire

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

Main implémentation du contexte Main pour Android est un Handler basé sur un Handler . Il s'agit donc bien d'une implémentation très adaptée:

 launch(Dispatchers.Main) { ... } vs launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) { ... } //   kotlinx 0.26: launch(Dispatchers.Main.immediate) { ... } 

launch(Dispatchers.Main) envoie Runnable à Handler , donc son code ne s'exécute pas immédiatement.

launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) exécutera immédiatement son expression lambda dans le thread actuel.

Dispatchers.Main s'assure que lorsque la coroutine reprend son travail, elle sera dirigée vers le fil principal ; en outre, Handler est utilisé ici comme une implémentation Android native pour l'envoi à la boucle d'événements de l'application.

L'implémentation exacte ressemble à ceci:

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

Voici un bon article pour vous aider à comprendre les subtilités de l'envoi dans Android:
Comprendre Android Core: Looper, Handler et HandlerThread .

Contexte Coroutine

Le contexte de la coroutine (également appelé gestionnaire de la coroutine) détermine dans quel thread son code sera exécuté, que faire si une exception est levée et fait référence au contexte parent pour propager l'annulation.

 val job = Job() val exceptionHandler = CoroutineExceptionHandler { coroutineContext, throwable -> whatever(throwable) } launch(Disaptchers.Default+exceptionHandler+job) { ... } 

job.cancel() annulera toutes les coroutines dont le parent est job . Un exceptionHandler recevra toutes les exceptions levées dans ces coroutines.

Portée

L'interface coroutineScope simplifie la gestion des erreurs:
Si l'une de ses coroutines filles échoue, la portée entière et toutes les coroutines enfants seront également annulées.

Dans l'exemple async , s'il n'était pas possible d'extraire la valeur, alors qu'une autre tâche continuait de fonctionner, nous avons un état endommagé et nous devons y remédier.

Lorsque vous travaillez avec coroutineScope , la fonction useValues sera appelée uniquement si l'extraction des deux valeurs est réussie. De plus, si deferred2 échoue, deferred1 sera annulé.

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

Vous pouvez également «mettre dans la portée» une classe entière pour définir un CoroutineContext par défaut et l'utiliser.

Un exemple de classe qui implémente l'interface CoroutineScope :

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

Exécuter Corutin dans CoroutineScope :

Le gestionnaire de launch ou async par défaut devient maintenant le gestionnaire d'étendue actuel.

 launch { val foo = withContext(Dispatchers.IO) { … } // -    CoroutineContext   … } launch(Dispatchers.Default) { // -        … } 

Lancement autonome de coroutine (en dehors de tout CoroutineScope):

 GlobalScope.launch(Dispatchers.Main) { // -    . … } 

Vous pouvez même définir la portée d'une application en définissant le répartiteur Main par défaut:

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

Remarques

  • Les coroutines limitent l'interopérabilité avec Java
  • Limitez la mutabilité pour éviter les verrous
  • Les coroutines sont conçues pour attendre, pas pour organiser les threads
  • Évitez les E / S dans Dispatchers.Default (et Main ...) - c'est à cela que Dispatchers.IO est destiné
  • Les flux consomment des ressources, donc des contextes à un seul thread sont utilisés
  • Dispatchers.Default basé sur ForkJoinPool , introduit dans Android 5+
  • Les coroutines peuvent être utilisées via des canaux

Se débarrasser des verrous et des rappels à l'aide des canaux

Définition de canal à partir de la documentation JetBrains:

Channel Channel conceptuellement très similaire à BlockingQueue . La principale différence est qu'il ne bloque pas l'opération de vente, il prévoit une suspension d' send (ou une offer non bloquante), et au lieu de bloquer l'opération de prise, il prévoit une suspension de receive .


Acteurs

Considérez un outil simple pour travailler avec les canaux: Actor .

Actor , encore une fois, est très similaire à Handler : nous définissons le contexte de la coroutine (c'est-à-dire le thread dans lequel nous allons effectuer des actions) et travaillons avec lui dans un ordre séquentiel.

La différence, bien sûr, est que les corutines sont utilisées ici; Vous pouvez spécifier la puissance et le code exécuté - pause .

En principe, l' actor redirigera toute commande vers le canal coroutine. Il garantit l'exécution d'une commande et limite les opérations dans son contexte . Cette approche aide parfaitement à se débarrasser des appels de synchronize et à garder tous les threads libres!

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

Dans cet exemple, nous utilisons les classes Kotlin scellées, en choisissant l'action à effectuer.

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

De plus, toutes ces actions seront mises en file d'attente, elles ne seront jamais exécutées en parallèle. Il s'agit d'un moyen pratique pour atteindre les limites de variabilité .

Cycle de vie Android + Coroutines

Les acteurs peuvent également être très utiles pour contrôler l'interface utilisateur Android, simplifier l'annulation des tâches et éviter de surcharger le thread principal.
job.cancel() cela et appelons job.cancel() lorsque l'activité est détruite.

 class MyActivity : AppCompatActivity(), CoroutineScope { protected val job = SupervisorJob() //  Job    override val coroutineContext = Dispatchers.Main.immediate+job override fun onDestroy() { super.onDestroy() job.cancel() //      } } 

La classe SupervisorJob est similaire au Job normal, à la seule exception près que l'annulation ne s'étend que vers l'aval.

Par conséquent, nous n'annulons pas toutes les coroutines d'une Activity lorsque l'une d'elles échoue.

Les choses vont un peu mieux avec une fonction d'extension qui vous permet d'accéder à ce CoroutineContext depuis n'importe quelle View de CoroutineScope .

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

Maintenant, nous pouvons combiner tout cela, la fonction setOnClick crée un acteur combiné pour contrôler ses actions onClick . En cas de clics multiples, les actions intermédiaires seront ignorées, éliminant ainsi les erreurs ANR (l'application ne répond pas), et ces actions seront effectuées dans le cadre de l' Activity . Par conséquent, lorsque l'activité sera détruite, tout cela sera annulé.

 fun View.setOnClick(action: suspend () -> Unit) { //         val scope = (context as? CoroutineScope)?: AppScope val eventActor = scope.actor<Unit>(capacity = Channel.CONFLATED) { for (event in channel) action() } //       setOnClickListener { eventActor.offer(Unit) } } 

Dans cet exemple, nous avons défini le Channel sur Conflated afin qu'il ignore certains événements s'il y en a trop. Vous pouvez le remplacer par Channel.UNLIMITED si vous préférez mettre les événements en file d'attente sans en perdre aucun, mais que vous souhaitez tout de même protéger l'application des erreurs ANR.

Vous pouvez également combiner les coroutines et les frameworks Lifecycle pour automatiser l'annulation des tâches liées à l'interface utilisateur:

 val LifecycleOwner.untilDestroy: Job get() { val job = Job() lifecycle.addObserver(object: LifecycleObserver { @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) fun onDestroy() { job.cancel() } }) return job } //  GlobalScope.launch(Dispatchers.Main, parent = untilDestroy) { /*    ! */ } 

Simplifiez la situation avec les rappels (partie 1)

Voici comment transformer l'utilisation des API basées sur le rappel avec Channel .

L'API fonctionne comme ceci:

  1. requestBrowsing(url, listener) analyse le dossier situé dans url .
  2. L' listener reçoit onMediaAdded(media: Media) pour tout fichier multimédia trouvé dans ce dossier.
  3. listener.onBrowseEnd() est appelé lors de l'analyse du dossier

Voici l'ancienne fonction d' refresh du fournisseur de contenu pour le navigateur VLC:

 private val refreshList = mutableListOf<Media>() 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() } } } 

Comment l'améliorer?

Créez une chaîne qui s'exécutera en refresh . Désormais, les rappels du navigateur dirigent uniquement les médias vers cette chaîne, puis la ferment.

Maintenant, la fonction de refresh est devenue plus claire. Elle crée une chaîne, appelle le navigateur VLC, puis forme une liste de fichiers multimédias et la traite.

Au lieu des consumeEach select ou consumeEach vous pouvez utiliser for pour attendre le média, et cette boucle se rompra dès que le browserChannel fermera.

 private lateinit var browserChannel : Channel<Media> override fun onMediaAdded(media: Media) { browserChannel.offer(media) } override fun onBrowseEnd() { browserChannel.close() } suspend fun refresh() { browserChannel = Channel(Channel.UNLIMITED) val refreshList = mutableListOf<Media>() requestBrowsing(url) //        for (media in browserChannel) refreshList.add(media) //   dataset.value = refreshList parseSubDirectories() } 

Simplifier la situation avec les rappels (partie 2): Retrofit

La deuxième approche: nous n'utilisons pas du tout les coroutines kotlinx, mais nous utilisons un framework core coroutine.

Voyez comment les coroutines fonctionnent réellement!

La fonction retrofitSuspendCall encapsule une demande d' Retrofit Call pour en faire une fonction de suspend .

En utilisant suspendCoroutine nous appelons la méthode Call.enqueue et Call.enqueue la coroutine. Le rappel fourni de cette manière appellera continuation.resume(response) pour reprendre la coroutine avec une réponse du serveur dès qu'elle est reçue.

Ensuite, nous avons juste besoin de combiner nos fonctions Retrofit dans retrofitSuspendCall pour retourner les résultats de requête en les utilisant.

 suspend inline fun <reified T> retrofitSuspendCall(request: () -> Call <T> ) : Response <T> = suspendCoroutine { continuation -> request.invoke().enqueue(object : Callback<T> { override fun onResponse(call: Call<T>, response: Response<T>) { continuation.resume(response) } override fun onFailure(call: Call<T>, t: Throwable) { continuation.resumeWithException(t) } }) } suspend fun browse(path: String?) = retrofitSuspendCall { ApiClient.browse(path) } //  (   Main) livedata.value = Repo.browse(path) 

Ainsi, l'appel bloquant le réseau se fait dans le thread Retrofit dédié, la coroutine est là, en attente d'une réponse du serveur, et il n'y a nulle part où l'utiliser dans l'application!

Cette implémentation est inspirée de la bibliothèque gildor / kotlin-coroutines-retrofit .

Il existe également un adaptateur JakeWharton / retrofit2-kotlin-coroutines avec une autre implémentation donnant un résultat similaire.

Épilogue

Channel peut être utilisé de nombreuses autres manières; Consultez BroadcastChannel pour des implémentations plus puissantes que vous pourriez trouver utiles.

Vous pouvez également créer des chaînes à l'aide de la fonction Produire .

Enfin, en utilisant des canaux, il est pratique d'organiser la communication entre les composants de l'interface utilisateur: l'adaptateur peut transmettre des événements de clic à son fragment / activité via Channel ou, par exemple, via Actor .

Source: https://habr.com/ru/post/fr457224/


All Articles