Un enfoque moderno de la competencia en Android: Corotins en Kotlin

Hola Habr!

Le recordamos que ya tenemos un pedido anticipado para el libro tan esperado en el idioma Kotlin de la famosa serie Big Nerd Ranch Guides. Hoy decidimos llamar su atención sobre la traducción de un artículo que habla sobre las corutinas de Kotlin y sobre el trabajo correcto con las transmisiones en Android. El tema se está discutiendo muy activamente, por lo tanto, para completar, también recomendamos que consulte este artículo de Habr y esta publicación detallada del blog de Axmor Software.

El marco competitivo moderno en Java / Android inflige un infierno en las devoluciones de llamada y conduce a estados de bloqueo, ya que Android no tiene una forma bastante simple de garantizar la seguridad de los hilos.

Las rutinas de Kotlin son un conjunto de herramientas muy efectivo y completo que hace que la gestión de la competencia sea mucho más fácil y productiva.

Pausa y bloqueo: ¿cuál es la diferencia?

Las rutinas no reemplazan los hilos, sino que proporcionan un marco para administrarlas. La filosofía de corutin es definir un contexto que le permita esperar a que se completen las operaciones en segundo plano sin bloquear el hilo principal.

El objetivo de Corutin en este caso es prescindir de las devoluciones de llamada y simplificar la competencia.

Ejemplo más simple

Para empezar, tomemos el ejemplo más simple: ejecutar coroutine en el contexto de Main (hilo principal). En él, extraeremos la imagen de la secuencia de IO y la enviaremos para su procesamiento a Main .

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

El código es simple como una función de subproceso único. Además, mientras getImage se ejecuta en el grupo asignado de subprocesos de IO , el subproceso principal es gratuito y puede asumir cualquier otra tarea. La función withContext detiene la rutina actual mientras se ejecuta su acción ( getImage() ). Tan pronto como getImage() regrese y el looper del hilo principal esté disponible, la rutina reanudará el trabajo en el hilo principal y llamará a imageView.setImageBitmap(image) .

El segundo ejemplo: ahora necesitamos completar 2 tareas en segundo plano para poder usarlas. Utilizaremos el dúo async / await para que estas dos tareas se realicen en paralelo, y usaremos su resultado en el hilo principal tan pronto como ambas tareas estén listas:

 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 es similar al launch , pero devuelve deferred (una entidad de Kotlin equivalente a Future ), por lo que su resultado se puede obtener usando await() . Cuando se llama sin parámetros, funciona en el contexto predeterminado para el alcance actual.

Nuevamente, el hilo principal permanece libre mientras esperamos nuestros 2 valores.
Como puede ver, la función de launch devuelve Job , que se puede usar para esperar hasta que se complete la operación; esto se hace usando la función join() . Funciona como en cualquier otro idioma, con la advertencia de que simplemente suspende la rutina y no bloquea el flujo .

Despacho

El envío es un concepto clave cuando se trabaja con corutinas. Esta acción le permite "saltar" de un hilo a otro.

Considere cómo se ve el equivalente para despachar en Main en Java, es decir,

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

Main Context Implementation para Android es un Handler basado en Handler . Entonces, esta es una implementación muy adecuada:

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

launch(Dispatchers.Main) envía Runnable a Handler , por lo que su código no se ejecuta de inmediato.

launch(Dispatchers.Main, CoroutineStart.UNDISPATCHED) ejecutará inmediatamente su expresión lambda en el hilo actual.

Dispatchers.Main asegura que cuando la rutina reanude el trabajo, se dirigirá al hilo principal ; Además, Handler se usa aquí como una implementación nativa de Android para enviar al bucle de eventos de la aplicación.

La implementación exacta se ve así:

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

Aquí hay un buen artículo para ayudarlo a comprender las complejidades del envío en Android:
Comprender Android Core: Looper, Handler y HandlerThread .

Contexto de rutina

El contexto de rutina (también conocido como el administrador de rutina) determina en qué subproceso se ejecutará su código, qué hacer si se produce una excepción, y se refiere al contexto principal para propagar la cancelación.

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

job.cancel() cancelará todas las corutinas cuyo padre es job . Una excepción Handler recibirá todas las excepciones lanzadas en estas rutinas.

Alcance

La interfaz coroutineScope simplifica el manejo de errores:
Si cualquiera de sus corutinas hijas falla, entonces todo el alcance y todas las corutinas hijas también serán canceladas.

En el ejemplo async , si no fue posible extraer el valor, mientras otra tarea continuó funcionando, tenemos un estado dañado y debemos hacer algo al respecto.

Cuando se trabaja con coroutineScope , la función useValues solo se llamará si la extracción de ambos valores es exitosa. Además, si deferred2 falla, deferred1 se cancelará.

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

También puede "poner en el alcance" una clase completa para establecer un CoroutineContext predeterminado y usarlo.

Una clase de ejemplo que implementa la interfaz CoroutineScope :

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

Ejecución de Corutin en CoroutineScope :

El administrador de launch o async predeterminado ahora se convierte en el administrador de alcance actual.

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

Lanzamiento autónomo de coroutine (fuera de cualquier CoroutineScope):

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

Incluso puede definir el alcance de una aplicación configurando el despachador Main predeterminado:

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

Observaciones

  • Las rutinas limitan la interoperabilidad con Java
  • Limite la mutabilidad para evitar bloqueos
  • Las corutinas están diseñadas para esperar, no para organizar hilos
  • Evite las E / S en Dispatchers.Default (y Main ...): para eso es Dispatchers.IO
  • Las secuencias consumen muchos recursos, por lo que se utilizan contextos de subproceso único
  • Dispatchers.Default basa en ForkJoinPool , introducido en Android 5+
  • Las rutinas se pueden usar a través de canales

Deshacerse de bloqueos y devoluciones de llamada utilizando canales

Definición del canal de la documentación de JetBrains:

Channel Channel conceptualmente muy similar a BlockingQueue . La diferencia clave es que no bloquea la operación de venta, proporciona un send suspendido (u offer sin bloqueo) y, en lugar de bloquear la operación de toma, proporciona una receive suspendida.


Actores

Considere una herramienta simple para trabajar con canales: Actor .

Actor , de nuevo, es muy similar a Handler : definimos el contexto de la rutina (es decir, el hilo en el que vamos a realizar acciones) y trabajamos con ella en un orden secuencial.

La diferencia, por supuesto, es que las corutinas se usan aquí; Puede especificar la potencia y el código ejecutado: pausa .

En principio, el actor redirigirá cualquier comando al canal de rutina. Garantiza la ejecución de un comando y restringe las operaciones en su contexto . ¡Este enfoque ayuda perfectamente a deshacerse de synchronize llamadas synchronize y mantener todos los hilos 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)) 

En este ejemplo, utilizamos las clases de Kotlin selladas, eligiendo qué acción realizar.

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

Además, todas estas acciones se pondrán en cola, nunca se ejecutarán en paralelo. Esta es una manera conveniente de lograr límites de variabilidad .

Android Life Cycle + Coroutines

Los actores también pueden ser muy útiles para controlar la interfaz de usuario de Android, simplificar la cancelación de tareas y evitar sobrecargar el hilo principal.
Implementemos esto y llamemos a job.cancel() cuando se destruya la actividad.

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

La clase SupervisorJob es similar al Job normal con la única excepción de que la cancelación solo se extiende en la dirección descendente.

Por lo tanto, no cancelamos todas las rutinas en una Activity cuando una de ellas falla.

Las cosas están un poco mejor con una función de extensión que le permite acceder a este CoroutineContext desde cualquier View en CoroutineScope .

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

Ahora podemos combinar todo esto, la función setOnClick crea un actor combinado para controlar sus acciones onClick . En el caso de múltiples toques, las acciones intermedias serán ignoradas, eliminando así los errores ANR (la aplicación no responde), y estas acciones se realizarán en el ámbito de la Activity . Por lo tanto, cuando se destruye la actividad, todo esto se cancelará.

 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) } } 

En este ejemplo, configuramos el Channel en Conflated para que ignore algunos eventos si hay demasiados. Puede reemplazarlo con Channel.UNLIMITED si prefiere poner en cola eventos sin perder ninguno de ellos, pero aún así quiere proteger la aplicación de los errores ANR.

También puede combinar las rutinas y los marcos de Lifecycle para automatizar la cancelación de tareas relacionadas con la interfaz de usuario:

 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) { /*    ! */ } 

Simplifique la situación con devoluciones de llamada (parte 1)

Aquí se explica cómo transformar el uso de API basadas en devolución de llamada con Channel .

La API funciona así:

  1. requestBrowsing(url, listener) analiza la carpeta ubicada en url .
  2. El listener recibe onMediaAdded(media: Media) para cualquier archivo multimedia que se encuentre en esta carpeta.
  3. Se llama a listener.onBrowseEnd() al analizar la carpeta

Aquí está la antigua función de refresh en el proveedor de contenido para el navegador 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() } } } 

¿Cómo mejorarlo?

Crea un canal que se ejecutará en refresh . Ahora las devoluciones de llamada del navegador solo dirigirán los medios a este canal y luego lo cerrarán.

Ahora la función de refresh ha vuelto más clara. Ella crea un canal, llama al navegador VLC, luego forma una lista de archivos multimedia y lo procesa.

En lugar de select o consumeEach puede usar for esperar a los medios, y este ciclo se interrumpirá tan pronto como se cierre el browserChannel del browserChannel .

 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() } 

Simplificando la situación con devoluciones de llamada (parte 2): actualización

El segundo enfoque: no usamos las corutinas de Kotlinx en absoluto, pero usamos un marco central de la rutina.

¡Mira cómo funcionan realmente las corutinas!

La función retrofitSuspendCall envuelve una solicitud de Retrofit Call para convertirla en una función de suspend .

Usando suspendCoroutine llamamos al método Call.enqueue y hacemos una pausa en la rutina. La devolución de llamada proporcionada de esta manera llamará a continuation.resume(response) para reanudar la rutina con una respuesta del servidor tan pronto como se reciba.

Luego, solo necesitamos combinar nuestras funciones de Retrofit en retrofitSuspendCall para devolver los resultados de la consulta usándolos.

 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) 

Por lo tanto, la llamada que bloquea la red se realiza en el subproceso dedicado de actualización, la rutina está aquí, esperando una respuesta del servidor, ¡y no hay ningún lugar para usarla en la aplicación!

Esta implementación está inspirada en la biblioteca gildor / kotlin-coroutines-retrofit .

También hay un JakeWharton / retrofit2-kotlin-coroutines-adapter con otra implementación que da un resultado similar.

Epílogo

Channel se puede usar de muchas otras formas; Echa un vistazo a BroadcastChannel para ver implementaciones más potentes que te pueden resultar útiles.

También puede crear canales utilizando la función Producir .

Finalmente, usando canales es conveniente organizar la comunicación entre los componentes de la interfaz de usuario: el adaptador puede transmitir eventos de clic a su fragmento / actividad a través del Channel o, por ejemplo, a través del Actor .

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


All Articles