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 simplePara 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() }
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 .
DespachoEl 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);
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) { ... }
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 rutinaEl 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.
AlcanceLa 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) { … }
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 canalesDefinició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.
ActoresConsidere 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) } } }
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 + CoroutinesLos 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()
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) {
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 }
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í:
requestBrowsing(url, listener)
analiza la carpeta ubicada en url
.- El
listener
recibe onMediaAdded(media: Media)
para cualquier archivo multimedia que se encuentre en esta carpeta. - 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)
Simplificando la situación con devoluciones de llamada (parte 2): actualizaciónEl 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) }
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ílogoChannel
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
.