Arquitectura asincrónica de la capa de ejecución de tareas

En las aplicaciones móviles de las redes sociales, al usuario le gusta, escribe un comentario, luego hojea el feed, inicia el video y vuelve a poner el me gusta. Todo esto es rápido y casi simultáneo. Si la implementación de la lógica de negocios de la aplicación está bloqueando por completo, entonces el usuario no podrá ir a la cinta hasta que se cargue lo mismo para grabar con sellos. Pero el usuario no esperará, por lo tanto, en la mayoría de las aplicaciones móviles, las tareas asincrónicas funcionan, que se inician y se completan independientemente entre sí. El usuario realiza varias tareas al mismo tiempo y no se bloquean entre sí. Una tarea asincrónica comienza y se ejecuta mientras el usuario inicia la siguiente.



Al descifrar el informe de Stepan Goncharov en AppsConf, tocaremos la asincronía: profundizaremos en la arquitectura de las aplicaciones móviles, discutiremos por qué deberíamos separar una capa para realizar tareas asincrónicas, analizaremos los requisitos y las soluciones existentes, analizaremos los pros y los contras, y consideraremos una de las implementaciones de este enfoque. También aprendemos cómo administrar tareas asincrónicas, por qué cada tarea tiene su propia ID, cuáles son las estrategias de ejecución y cómo ayudan a simplificar y acelerar el desarrollo de toda la aplicación.


Sobre el orador: Stepan Goncharov ( stepango ) trabaja en Grab, es como Uber, pero en el sudeste asiático. Ha estado involucrado en el desarrollo de Android durante más de 9 años. Interesado en Kotlin desde 2014 y desde 2016, lo utiliza en la producción. Organizado por Kotlin User Group en Singapur. Esta es una de las razones por las cuales todos los ejemplos de código estarán en Kotlin, y no porque esté de moda.

Analizaremos un enfoque para diseñar los componentes de su aplicación. Esta es una guía de acción para aquellos que desean agregar nuevos componentes a la aplicación, diseñarlos convenientemente y luego expandirlos. Los desarrolladores de iOS pueden usar el enfoque de iOS. El enfoque también se aplica a otras plataformas. He estado interesado en Kotlin desde 2014, por lo que todos los ejemplos estarán en este idioma. Pero no se preocupe: puede escribir lo mismo en Swift, Objective-C y otros lenguajes.

Comencemos con los problemas y desventajas de las extensiones reactivas . Los problemas son típicos para otras primitivas asíncronas, por lo que decimos RX: tenga en cuenta el futuro y la promesa, y todo funcionará de manera similar.

Problemas de RX


Alto umbral de entrada . RX es bastante complejo y grande: tiene 270 operadores y no es fácil enseñar a todo el equipo cómo usarlos correctamente. No discutiremos este problema, está más allá del alcance del informe.

En RX, debe administrar manualmente sus suscripciones, así como monitorear el ciclo de vida de la aplicación . Si ya se suscribió a Single u Observable, no puede compararlo con otro Single , porque siempre recibirá un nuevo objeto y siempre habrá diferentes suscripciones para el tiempo de ejecución. En RX no hay forma de comparar suscripciones y transmisiones .

Intentaremos resolver algunos de estos problemas. Resolveremos cada problema una vez y luego reutilizaremos el resultado.

Problema número 1: realizar una tarea más de una vez


Un problema común en el desarrollo es el trabajo innecesario y la repetición de las mismas tareas más de una vez. Imagine que tenemos un formulario para ingresar datos y un botón para guardar. Cuando se presiona, se envía una solicitud, pero si hace clic varias veces mientras se guarda el formulario, se enviarán varias solicitudes idénticas. Dimos el botón para probar el control de calidad, presionaron 40 veces en un segundo; recibimos 40 solicitudes porque, por ejemplo, la animación no tenía tiempo para funcionar.

¿Cómo resolver el problema? Cada desarrollador tiene su propio enfoque favorito para resolver: uno pegará un debounce , el otro bloqueará el botón en caso de que haga clic en clickable = false . No existe un enfoque general, por lo que estos errores aparecerán o desaparecerán de nuestra aplicación. Solucionamos el problema solo cuando el control de calidad nos dice: "¡Oh, hice clic aquí y se rompió"!

¿Una solución escalable?


Para evitar tales situaciones, ajustaremos RX u otro marco asincrónico; agregaremos ID a todas las operaciones asincrónicas . La idea es simple: necesitamos alguna forma de compararlos, porque generalmente este método no está en los marcos. Podemos completar la tarea, pero no sabemos si ya se ha completado o no.

Llamemos a nuestro contenedor "Actuar": ya se han tomado otros nombres. Para hacer esto, cree un pequeño tipo de typealias y una interface simple en la que solo haya un campo:

 typealias Id = String interface Act { val id: Id } 

Esto es conveniente y reduce ligeramente la cantidad de código. Más tarde, si a String no le gusta, lo reemplazaremos por otra cosa. En este pequeño fragmento de código, observamos un hecho curioso.

Las interfaces pueden contener propiedades.

Para los programadores que provienen de Java, esto es inesperado. Por lo general, agregan métodos getId() dentro de la interfaz, pero esta es la solución incorrecta, desde el punto de vista de Kotlin.

¿Cómo vamos a diseñar?


Una pequeña digresión. Al diseñar, me adhiero a dos principios. El primero es desglosar los requisitos del componente y la implementación en piezas pequeñas . Esto permite un control granular sobre la escritura de código. Cuando crea un componente grande e intenta hacer todo de una vez, esto es malo. Por lo general, este componente no funciona y comienza a insertar muletas, por lo que le recomiendo que escriba en pequeños pasos controlados y que lo disfrute. El segundo principio es verificar la operatividad después de cada paso y repetir el procedimiento nuevamente.

¿Por qué no hay suficiente identificación?


Volvamos al problema. Dimos el primer paso: agregamos una ID y todo fue simple: la interfaz y el campo. Esto no nos dio nada, porque la interfaz no contiene ninguna implementación y no funciona por sí sola, pero le permite comparar operaciones.

A continuación, agregaremos componentes que nos permitirán usar la interfaz y comprender que queremos ejecutar algún tipo de solicitud por segunda vez cuando esto no sea necesario. Lo primero que haremos es introducir nuevas abstracciones .

Presentación de nuevas abstracciones: MapDisposable


Es importante elegir el nombre correcto y la abstracción familiar para los desarrolladores que trabajan en su base de código. Como tengo ejemplos sobre RX, utilizaremos el concepto RX y nombres similares a los utilizados por los desarrolladores de la biblioteca. Entonces, podemos explicar fácilmente a nuestros colegas lo que hicieron, por qué y cómo debería funcionar. Para seleccionar un nombre, consulte la documentación de CompositeDiposable .

Creemos una pequeña interfaz MapDisposable que contiene información sobre las tareas actuales y las llamadas dispose () en la eliminación . No daré la implementación, puedes ver todas las fuentes en mi GitHub .

Llamamos MapDisposable de esta manera porque el componente funcionará como un Map, pero tendrá propiedades CompositeDiposable.

Presentación de nuevas abstracciones: ActExecutor


El siguiente componente abstracto es ActExecutor. Comienza o no inicia nuevas tareas, depende de MapDisposable y delega el manejo de errores. Cómo elegir un nombre: consulte la documentación .

Tome la analogía más cercana del JDK. Tiene un ejecutor en el que puede pasar hilo y hacer algo. Me parece que este es un componente genial y está bien diseñado, así que tomemos como base.

Creamos ActExecutor y una interfaz simple para él, siguiendo el principio de pequeños pasos simples. El nombre en sí dice que es un componente al que transmitimos algo y comienza a hacer algo. ActExecutor tiene un método en el que pasamos Act y, por si acaso, manejamos los errores, porque sin ellos no hay forma.

 interface ActExecutor { fun execute( act: Act, e: (Throwable) -> Unit = ::logError) } interface MapDisposable { fun contains(id: Id): Boolean fun add(id: Id, disposable: () -> T) fun remove(id: Id) } 

MapDisposable también es limitado: tome la interfaz de Mapa y copie los métodos que contains , add y remove . El método add difiere de Map: el segundo argumento es el lambda por belleza y conveniencia. La conveniencia es que podemos sincronizar el lambda para evitar condiciones de carrera inesperadas. Pero no hablaremos de esto, continuaremos con la arquitectura.

Implementación de interfaz


Hemos declarado todas las interfaces e intentaremos implementar algo simple. Tome CompletableAct y SingleAct .

 class CompletableAct ( override val id: Id, override val completable: Completable ) : Act class SingleAct<T : Any>( override val id: Id, override val single: Single<T> ) : Act 

CompletableAct es un contenedor sobre Completable. En nuestro caso, simplemente contiene una identificación, que es lo que necesitamos. SingleAct es casi lo mismo. También podemos implementar Maybe y Flowable, pero nos detenemos en las dos primeras implementaciones.

Para Single, especificamos el tipo genérico <T : Any> . Como desarrollador de Kotlin, prefiero usar tal enfoque.

Trate de usar genéricos no nulos.

Ahora que tenemos un conjunto de interfaces, implementamos algo de lógica para evitar la ejecución de las mismas solicitudes.

 class ActExecutorImpl ( val map: MapDisposable ): ActExecutor { fun execute( act: Act, e: (Throwable) -> Unit ) = when { map.contains(act.id) -> { log("${act.id} - in progress") } else startExecution(act, e) log("${act.id} - Started") } } 

Tomamos un mapa y verificamos si hay una solicitud en él. Si no, comenzamos a ejecutar la solicitud y la agregamos al Mapa solo en tiempo de ejecución. Después de la ejecución con cualquier resultado: error o éxito, elimine la solicitud del Mapa.

Para muy atento: no hay sincronización, pero la sincronización está en el código fuente en GitHub.

 fun startExecution(act: Act, e: (Throwable) -> Unit) { val removeFromMap = { mapDisposable.remove(act.id) } mapDisposable.add(act.id) { when (act) { is CompletableAct -> act.completable .doFinally(removeFromMap) .subscribe({}, e) is SingleAct<*> -> act.single .doFinally(removeFromMap) .subscribe({}, e) else -> throw IllegalArgumentException() } } 

Use lambdas como último argumento para mejorar la legibilidad del código. Es hermoso y tus colegas te lo agradecerán.

Usaremos más chips Kotlin y agregaremos funciones de extensión para Completable y Single. Con ellos, no tenemos que buscar un método de fábrica para crear un CompletableAct y SingleAct: los crearemos mediante funciones de extensión.

 fun Completable.toAct(id: Id): Act = CompletableAct(id, this) fun <T: Any> Single<T>.toAct(id: Id): Act = SingleAct(id, this) 

Las funciones de extensión se pueden agregar a cualquier clase.

Resultado


Hemos implementado varios componentes y una lógica muy simple. Ahora la regla principal que debemos seguir es no forzar una suscripción a mano . Cuando queremos ejecutar algo, se lo entregamos a través de Ejecutor. Así como con hilo, nadie los inicia ellos mismos.

 fun act() = Completable.timer(2, SECONDS).toAct("Hello") executor.apply { execute(act()) execute(act()) execute(act()) } Hello - Act Started Hello - Act Duplicate Hello - Act Duplicate Hello - Act Finished 

Una vez acordamos dentro del equipo, y ahora siempre hay una garantía de que los recursos de nuestra aplicación no se gastarán en la ejecución de solicitudes idénticas e innecesarias.

El primer problema fue resuelto. Ahora ampliemos la solución para darle flexibilidad.

Problema número 2: ¿qué tarea cancelar?


Además de en los casos en que es necesario cancelar una solicitud posterior , es posible que debamos cancelar la anterior. Por ejemplo, editamos la información sobre nuestro usuario por primera vez y la enviamos al servidor. Por alguna razón, el envío tardó mucho tiempo y no se completó. Editamos el perfil de usuario nuevamente y enviamos la misma solicitud por segunda vez. En este caso, no tiene sentido generar una ID especial para la solicitud: la información del segundo intento es más relevante y la solicitud anterior se cancela .

La solución actual no funcionará, porque siempre cancelará la ejecución de la solicitud con información relevante. Necesitamos expandir de alguna manera la solución para solucionar el problema y agregar flexibilidad. Para hacer esto, ¿entiendes lo que todos queremos? Pero queremos entender qué tarea cancelar, cómo no copiar y pegar y cómo llamarla.

Agregar componentes


Llamamos estrategias de comportamiento de consulta y creamos dos interfaces para ellas: StrategyHolder y Strategy . También creamos 2 objetos que son responsables de qué estrategia aplicar.

 interface StrategyHolder { val strategy: Strategy } sealed class Strategy object KillMe : Strategy() object SaveMe : Strategy() 

No uso enum , me gusta más la clase sellada . Son más livianos, consumen menos memoria y son más fáciles y más convenientes de expandir.

La clase sellada es más fácil de extender y escribir más corta.

Actualización de componentes existentes


En este punto, todo es simple. Teníamos una interfaz simple, ahora será el heredero de StrategyHolder. Como se trata de interfaces, no hay problema con la herencia. En la implementación de CompletableAct, insertaremos otra override y agregaremos el valor predeterminado allí para asegurarnos de que los cambios sigan siendo compatibles con el código existente.

 interface Act : StrategyHolder { val id: String } class CompletableAct( override val id: String, override val completable: Completable, override val strategy: Strategy = SaveMe ) : Act 

Estrategias


Elegí la estrategia SaveMe , que me parece obvia. Esta estrategia solo cancela las siguientes solicitudes: la primera solicitud siempre estará activa hasta que se complete.

Trabajamos un poco en nuestra implementación. Teníamos un método de ejecución, y ahora hemos agregado una verificación de estrategia allí.

  • Si la estrategia SaveMe es la misma que hicimos antes, entonces nada ha cambiado.
  • Si la estrategia es KillMe , elimine la solicitud anterior y lance una nueva.

 override fun execute(act: Act, e: (Throwable) -> Unit) = when { map.contains(act.id) -> when (act.strategy) { KillMe -> { map.remove(act.id) startExecution(act, e) } SaveMe -> log("${act.id} - Act duplicate") } else -> startExecution(act, e) } 

Resultado


Pudimos gestionar fácilmente las estrategias escribiendo un mínimo de código. Al mismo tiempo, nuestros colegas están contentos y podemos hacer algo como esto.

 executor.apply { execute(Completable.timer(2, SECONDS) .toAct("Hello", KillMe)) execute(Completable.timer(2, SECONDS) .toAct("Hello", KillMe)) execute(Completable.timer(2, SECONDS) .toAct("Hello«, KillMe)) } Hello - Act Started Hello - Act Canceled Hello - Act Started Hello - Act Canceled Hello - Act Started Hello - Act Finished 

Creamos una tarea asincrónica, pasamos la estrategia, y cada vez que comenzamos una nueva tarea, todas las anteriores, y no las siguientes, serán canceladas.

Problema número 3: las estrategias no son suficientes


Pasemos a un problema interesante que encontré en un par de proyectos. Ampliaremos nuestra solución para tratar casos más complicados. Uno de estos casos, especialmente relevante para las redes sociales, es "me gusta / no me gusta" . Hay una publicación y queremos que nos guste, pero como desarrolladores no queremos bloquear toda la interfaz de usuario y mostrar el diálogo en pantalla completa con la carga hasta que se complete la solicitud. Sí, y el usuario será infeliz. Queremos engañar al usuario: presiona el botón y, como si ya hubiera sucedido algo así, ha comenzado una hermosa animación. Pero, de hecho, no había nada parecido: esperamos hasta que el engaño se haga realidad. Para evitar el fraude, debemos manejar de manera transparente el disgusto por el usuario.

Sería bueno manejar esto correctamente para que el usuario obtenga el resultado deseado. Pero es difícil para nosotros, como desarrolladores, tratar con solicitudes diferentes y mutuamente excluyentes cada vez.

Hay demasiadas preguntas ¿Cómo entender que las consultas están relacionadas? ¿Cómo almacenar estas conexiones? ¿Cómo manejar scripts complejos y no copiar y pegar? ¿Cómo nombrar nuevos componentes? Las tareas son complejas y lo que ya hemos implementado no es adecuado para la solución.

Grupos y estrategias para grupos


Cree una interfaz simple llamada GroupStrategyHolder . Es un poco más complicado: dos campos en lugar de uno.

 interface GroupStrategyHolder { val groupStrategy: GroupStrategy val groupKey: String } sealed class GroupStrategy object Default : GroupStrategy() object KillGroup : GroupStrategy() 

Además de la estrategia para una solicitud específica, presentamos una nueva entidad: un grupo de solicitudes. Este grupo también tendrá estrategias. Consideraremos solo la opción más simple con dos estrategias: Predeterminada : la estrategia predeterminada cuando no hacemos nada con las consultas y KillGroup : elimina todas las consultas existentes del grupo y lanza una nueva.

 interface Act : StrategyHolder, GroupStrategyHolder { val id: String } class CompletableAct( override val id: String, override val completable: Completable, override val strategy: Strategy = SaveMe, override val groupStrategy: GroupStrategy = Default override val groupKey: String = "" ) : Act 

Repetimos los pasos que mencioné anteriormente: tomamos la interfaz, la expandimos y agregamos dos campos adicionales a CompletableAct y SingleAct.

Implementación de actualización


Regresamos al método Ejecutar. La tercera tarea es más complicada, pero la solución es bastante simple: verificamos la estrategia del grupo para una solicitud específica y, si es KillGroup, matamos a todo el grupo y ejecutamos la lógica habitual.

 MapDisposable -> GroupDisposable ... override fun execute(act: Act, e: (Throwable) -> Unit) { if (act.groupStrategy == KillGroup) groupDisposable.removeGroup(act.groupKey) return when { groupDisposable.contains(act.groupKey, act.id) -> when (act.strategy) { KillMe -> { stop(act.groupKey, act.id) startExecution(act, e) } SaveMe -> log("${act.id} - Act duplicate") } else -> startExecution(act, e) } } 

El problema es complejo, pero ya tenemos una infraestructura bastante adecuada: podemos expandirla y resolver el problema. Si nos fijamos en nuestro resultado, ¿qué debemos hacer ahora?

Resultado


 fun act(id: String)= Completable.timer(2, SECONDS).toAct( id = id, groupStrategy = KillGroup, groupKey = "Like-Dislike-PostId-1234" ) executor.apply { execute(act(“Like”)) execute(act(“Dislike”)) execute(act(“Like”)) } Like - Act Started Like - Act Canceled Dislike - Act Started Dislike - Act Canceled Like - Act Started Like - Act Finished 

Si necesitamos consultas tan complejas, agregamos dos campos: groupStrategy e group ID. ID de grupo es un parámetro específico, porque para admitir muchas solicitudes paralelas de me gusta / no me gusta, debe crear un grupo para cada par de solicitudes que pertenecen al mismo objeto. En este caso, puede nombrar el grupo Like-Dislike-PostId y agregar el ID de la publicación allí. Cada vez que nos gusten las publicaciones vecinas, nos aseguraremos de que todo funcione correctamente para la publicación anterior y para la siguiente.

En nuestro ejemplo sintético, estamos tratando de ejecutar una secuencia like-dislike-like. Cuando realizamos la primera acción, y luego la segunda, la anterior se cancela y la siguiente similar cancela la aversión anterior. Esto es lo que quería.

En el último ejemplo, utilizamos parámetros con nombre para crear actos. Esto ayuda a mejorar la legibilidad del código, especialmente cuando hay muchos parámetros.

Para una lectura más fácil, use parámetros con nombre.

Arquitectura


Veamos cómo esta decisión puede afectar nuestra arquitectura. En los proyectos, a menudo veo que View Model o Presenter asumen una gran responsabilidad, como los hacks, para manejar de alguna manera la situación con like / dislike. Por lo general, toda esta lógica en el Modelo de vista: una gran cantidad de código duplicado con bloqueo de botones, controladores de LifeCycle, suscripciones.



Todo lo que nuestro Ejecutor está haciendo ahora fue una vez en Presenter o View Model. Si la arquitectura es madura, los desarrolladores podrían llevar esta lógica a algún tipo de interactuadores o casos de uso, pero la lógica se duplicó en varios lugares.

Después de adoptar a Executor, el modelo de vista se vuelve más simple y toda la lógica se les oculta. Si alguna vez trajo esto al presentador y al interactor, entonces sabrá que el interactor y el presentador se están volviendo más fáciles. En general, quedé satisfecho.



¿Qué más agregar?


Otra ventaja de la solución actual es que es extensible. ¿Qué más nos gustaría agregar como desarrolladores que trabajan en una aplicación móvil y luchan con errores y muchas solicitudes concurrentes todos los días?

Las posibilidades


La implementación del ciclo de vida se mantuvo detrás de escena, pero como desarrolladores móviles, todos siempre pensamos en esto y nos preocupamos para que nada se escape. Me gustaría guardar y restaurar las solicitudes de reinicio de la aplicación.

Cadenas de llamadas. Debido al envoltorio de las cadenas RX, es posible serializarlas, porque de forma predeterminada RX no serializa.

Pocas personas saben cuántas solicitudes concurrentes se están ejecutando en un momento determinado en sus aplicaciones. No diría que este es un gran problema para las aplicaciones pequeñas y medianas. Pero para una aplicación grande que hace mucho trabajo en segundo plano, es bueno comprender las causas de los bloqueos y las quejas de los usuarios. Sin infraestructura adicional, los desarrolladores simplemente no tienen información para entender la razón: tal vez la razón está en la interfaz de usuario, o tal vez en una gran cantidad de solicitudes constantes en segundo plano. Podemos ampliar nuestra solución y agregar algún tipo de métrica .

Consideremos las posibilidades con más detalle.

Procesamiento del ciclo de vida


 class ActExecutorImpl( lifecycle: Lifecycle ) : ActExecutor { inir { lifecycle.doOnDestroy { cancelAll() } } ... 

Este es un ejemplo de implementación del ciclo de vida. En el caso más simple: con fragmentos de Destroy o cancelados con Activity , pasamos el controlador del ciclo de vida a nuestro ejecutor , y cuando ocurre el evento onDestroy, eliminamos todas las solicitudes . Esta es una solución simple que elimina la necesidad de copiar y pegar código similar en Ver modelos. LifeData hace aproximadamente lo mismo.

Guardar / Restaurar


Como tenemos envoltorios, podemos crear clases separadas para Actos , dentro de los cuales habrá lógica para crear tareas asincrónicas. Además, podemos guardar este nombre en la base de datos y restaurarlo desde la base de datos al inicio de la aplicación utilizando el método de fábrica o algo similar.

Al mismo tiempo, tendremos la oportunidad de trabajar sin conexión y reiniciaremos las solicitudes que se completaron con errores cuando aparece Internet. En ausencia de Internet o con errores de solicitud, los guardamos en la base de datos y luego los restauramos y ejecutamos nuevamente. Si puede hacer esto con RX regular sin envoltorios adicionales, escriba los comentarios, sería interesante.

Cadenas de llamadas


También podemos vincular nuestros actos . Otra opción de extensión es ejecutar cadenas de consulta . Por ejemplo, tiene una entidad que debe crearse en el servidor, y otra entidad, que depende de la primera, debe crearse exactamente en el momento en que estamos seguros de que la primera solicitud fue exitosa. Esto también se puede hacer. Por supuesto, esto no es tan trivial, pero es posible tener una clase que controle el inicio de todas las tareas asincrónicas. Usar RX desnudo es más difícil de hacer.

Métricas


Es interesante ver cuántas consultas paralelas se realizan en promedio en segundo plano . Al tener métricas, puede comprender la causa de las quejas de los usuarios sobre el letargo. Como mínimo, podemos excluir de la lista de razones la ejecución en el fondo de lo que no esperábamos.

, , , , - - 10% . , .

Conclusión


— , . «» . , , , , .

, , . — - , , — . — . , . . .

. Kotlin, . , .

AppsConf 2018, AppsConf 2019 . 38 : , Android, UX, , - , , Kotlin.

, youtube- 22–23 .

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


All Articles