Construyendo un sistema de componentes reactivos con Kotlin



Hola a todos! Mi nombre es Anatoly Varivonchik, soy desarrollador de Android en Badoo. Hoy compartiré con ustedes la traducción de la segunda parte del artículo de mi colega Zsolt Kocsi sobre la implementación de MVI, que usamos diariamente en el proceso de desarrollo. La primera parte está aquí .

Que queremos y como lo hacemos


En la primera parte del artículo, presentamos Características , los elementos centrales de MVICore que pueden reutilizarse. Pueden tener la estructura más simple e incluir un solo reductor , o pueden convertirse en una herramienta completamente funcional para administrar tareas asincrónicas, eventos y mucho más.

Cada característica es rastreable: existe la oportunidad de suscribirse a los cambios en su estado y recibir notificaciones al respecto. Sin embargo, Feature se puede suscribir a una fuente de entrada. Y esto tiene sentido, porque con la inclusión de Rx en la base del código, ya tenemos muchos objetos y suscripciones observables en varios niveles.

Es en relación con el aumento en el número de componentes reactivos que es hora de reflexionar sobre lo que tenemos y si es posible mejorar aún más el sistema.

Tenemos que responder tres preguntas:

  1. ¿Qué elementos deben usarse al agregar nuevos componentes reactivos?
  2. ¿Cuál es la forma más fácil de administrar sus suscripciones?
  3. ¿Es posible ignorar la administración del ciclo de vida / la necesidad de borrar las suscripciones para evitar pérdidas de memoria? En otras palabras, ¿podemos separar el enlace de componentes de la gestión de suscripción?

En esta parte del artículo, veremos los conceptos básicos y los beneficios de construir un sistema utilizando componentes reactivos y veremos cómo Kotlin ayuda con esto.

Elementos principales


Cuando llegamos a trabajar en el diseño y la estandarización de nuestras características , ya habíamos probado muchos enfoques diferentes y decidimos que las características estarían en forma de componentes reactivos. Primero, nos centramos en las interfaces principales. En primer lugar, necesitábamos determinar los tipos de datos de entrada y salida.

Razonamos de la siguiente manera:

  • No reinventemos la rueda, veamos qué interfaces ya existen.
  • Como ya estamos usando la biblioteca RxJava, tiene sentido referirse a sus interfaces básicas.
  • El número de interfaces debe minimizarse.

Como resultado, decidimos usar ObservableSource <T> para la salida y Consumer <T> para la entrada. ¿Por qué no Observable / Observador , preguntas? Observable es una clase abstracta de la que necesita heredar, y ObservableSource es la interfaz que implementa que satisface completamente la necesidad de implementar un protocolo reactivo.

package io.reactivex; import io.reactivex.annotations.*; /** * Represents a basic, non-backpressured {@link Observable} source base interface, * consumable via an {@link Observer}. * * @param <T> the element type * @since 2.0 */ public interface ObservableSource<T> { /** * Subscribes the given Observer to this ObservableSource instance. * @param observer the Observer, not null * @throws NullPointerException if {@code observer} is null */ void subscribe(@NonNull Observer<? super T> observer); } 

Observer , la primera interfaz que viene a la mente, implementa cuatro métodos: onSubscribe, onNext, onError y onComplete. En un esfuerzo por simplificar el protocolo tanto como sea posible, preferimos Consumer <T> , que acepta nuevos elementos utilizando un único método. Si elegimos Observer , los métodos restantes a menudo serían redundantes o funcionarían de manera diferente (por ejemplo, nos gustaría presentar errores como parte del estado, y no como excepciones, y ciertamente no interrumpir el flujo).

 /** * A functional interface (callback) that accepts a single value. * @param <T> the value type */ public interface Consumer<T> { /** * Consume the given value. * @param t the value * @throws Exception on error */ void accept(T t) throws Exception; } 

Entonces, tenemos dos interfaces, cada una de las cuales contiene un método. Ahora podemos vincularlos firmando Consumer <T> a ObservableSource <T> . Este último acepta solo instancias de Observer <T> , pero podemos envolverlo en un Observable <T> , que está suscrito a Consumer <T> :

 val output: ObservableSource<String> = Observable.just("item1", "item2", "item3") val input: Consumer<String> = Consumer { System.out.println(it) } val disposable = Observable.wrap(output).subscribe(input) 

(Afortunadamente, la función .wrap (salida) no crea un nuevo objeto si la salida ya es un Observable <T> ).

Es posible que recuerde que el componente Característica de la primera parte del artículo utilizaba datos de entrada del tipo Deseo (correspondiente a la intención del modelo-vista-intención) y la salida del tipo Estado , y por lo tanto puede estar en ambos lados del paquete:

 // Wishes -> Feature val wishes: ObservableSource<Wish> = Observable.just(Wish.SomeWish) val feature: Consumer<Wish> = SomeFeature() val disposable = Observable.wrap(wishes).subscribe(feature) // Feature -> State consumer val feature: ObservableSource<State> = SomeFeature() val logger: Consumer<State> = Consumer { System.out.println(it) } val disposable = Observable.wrap(feature).subscribe(logger) 

Esta vinculación de Consumidor y Productor ya parece bastante simple, pero hay una forma aún más sencilla en la que no necesita crear suscripciones manualmente o cancelarlas.

Introduciendo Binder .

Enlace de esteroides


MVICore contiene una clase llamada Binder que proporciona una API simple para administrar las suscripciones Rx y tiene una serie de características interesantes.

¿Por qué es necesario?

  • Cree un enlace suscribiendo entradas para el fin de semana.
  • La capacidad de darse de baja al final del ciclo de vida (cuando es un concepto abstracto y no tiene nada que ver con Android).
  • Bonificación: Binder le permite agregar objetos intermedios, por ejemplo, para el registro o la depuración de viajes en el tiempo.

En lugar de firmar manualmente, puede volver a escribir los ejemplos anteriores de la siguiente manera:

 val binder = Binder() binder.bind(wishes to feature) binder.bind(feature to logger) 

Gracias a Kotlin, todo parece muy simple.

Estos ejemplos funcionan si el tipo de entrada y salida es el mismo. Pero, ¿y si no es así? Al implementar la función de extensión, podemos hacer que la transformación sea automática:

 val output: ObservableSource<A> = TODO() val input: Consumer<B> = TODO() val transformer: (A) -> B = TODO() binder.bind(output to input using transformer) 

Presta atención a la sintaxis: se lee casi como una oración normal (y esta es otra razón por la que amo a Kotlin). Pero Binder no solo se usa como azúcar sintáctico, también nos es útil para resolver problemas con el ciclo de vida.

Crear carpeta


Crear una instancia no parece más fácil:

 val binder = Binder() 

Pero en este caso, debe darse de baja manualmente, y debe llamar a binder.dispose() cada vez que necesite eliminar suscripciones. Hay otra forma: inyectar la instancia del ciclo de vida en el constructor. Así:

 val binder = Binder(lifecycle) 

Ahora no necesita preocuparse por las suscripciones, ya que se eliminarán al final del ciclo de vida. Al mismo tiempo, el ciclo de vida puede repetirse muchas veces (como el ciclo de inicio y finalización en la interfaz de usuario de Android), y Binder creará y eliminará suscripciones cada vez.

¿Y qué es un ciclo de vida?


La mayoría de los desarrolladores de Android, al ver la frase "ciclo de vida", representan los ciclos de Actividad y Fragmento. Sí, Binder puede trabajar con ellos, cancelando la suscripción al final del ciclo.

Pero esto es solo el comienzo, porque no utiliza la interfaz de Android LifecycleOwner de ninguna manera: Binder tiene su propia versión , más universal. Es esencialmente un flujo de señal BEGIN / END:

 interface Lifecycle : ObservableSource<Lifecycle.Event> { enum class Event { BEGIN, END } // Remainder omitted } 

Puede implementar esta secuencia usando Observable (mediante mapeo), o simplemente usar la clase ManualLifecycle de la biblioteca para entornos que no son Rx (ver exactamente a continuación).

¿Cómo funciona la carpeta ? Al recibir una señal de INICIO, crea suscripciones para los componentes que configuró previamente ( entrada / salida ), y al recibir una señal de FIN, los elimina. Lo más interesante es que puedes comenzar de nuevo:

 val output: PublishSubject<String> = PublishSubject.create() val input: Consumer<String> = Consumer { System.out.println(it) } val lifecycle = ManualLifecycle() val binder = Binder(lifecycle) binder.bind(output to input) output.onNext("1") lifecycle.begin() output.onNext("2") output.onNext("3") lifecycle.end() output.onNext("4") lifecycle.begin() output.onNext("5") output.onNext("6") lifecycle.end() output.onNext("7") // will print: // 2 // 3 // 5 // 6 

Esta flexibilidad en la reasignación de suscripciones es especialmente útil cuando se trabaja con Android, cuando puede haber varios ciclos Start-Stop y Resume-Pause, además del habitual Create-Destroy.

Android Binder Lifecycles


Hay tres clases en la biblioteca:

  • CreateDestroyBinderLifecycle ( androidLifecycle )
  • StartStopBinderLifecycle ( androidLifecycle )
  • ResumePauseBinderLifecycl e ( androidLifecycle )

androidLifecycle es el valor devuelto por el método getLifecycle() , es decir, AppCompatActivity , AppCompatDialogFragment , etc. Todo es muy simple:

 fun createBinderForActivity(activity: AppCompatActivity) = Binder(   CreateDestroyBinderLifecycle(activity.lifecycle) ) 

Ciclos de vida individuales


No nos detengamos allí, porque no estamos vinculados a Android de ninguna manera. ¿Cuál es el ciclo de vida de una carpeta ? Literalmente cualquier cosa: por ejemplo, el tiempo de reproducción de un diálogo o el tiempo de ejecución de alguna tarea asincrónica. Puede, por ejemplo, vincularlo con el alcance DI, y luego cualquier suscripción se eliminará con él. Completa libertad de acción.

  1. ¿Desea guardar las suscripciones antes de que el Observable envíe el elemento? Convierta este objeto a Lifecycle y páselo a Binder . Implemente el siguiente código en la función de extensión y utilícelo más adelante:

     fun Observable<T>.toBinderLifecycle() = Lifecycle.wrap(this   .first()   .map { END }   .startWith(BEGIN) ) 
  2. ¿Desea conservar sus enlaces hasta que se complete Completable ? Sin problemas: esto se hace por analogía con el párrafo anterior:

     fun Completable.toBinderLifecycle() = Lifecycle.wrap(   Observable.concat(       Observable.just(BEGIN),       this.andThen(Observable.just(END))   ) ) 
  3. ¿Desea algún otro código que no sea Rx para decidir cuándo eliminar las suscripciones? Use ManualLifecycle como se describe arriba.

En cualquier caso, puede colocar una secuencia reactiva en la secuencia del elemento Lifecycle.Event o usar ManualLifecycle si está trabajando con código que no es Rx.

Descripción general del sistema


Binder oculta los detalles de la creación y administración de suscripciones Rx. Todo lo que queda es una breve descripción general: "El componente A interactúa con el componente B en el alcance C".

Supongamos que tenemos los siguientes componentes reactivos para la pantalla actual:



Nos gustaría que los componentes se conectaran dentro de la pantalla actual, y sabemos que:

  • UIEvent se puede alimentar directamente a AnalyticsTracker ;
  • UIEvent se puede transformar en Wish for Feature ;
  • El estado se puede transformar en un modelo de vista para una vista .

Esto se puede expresar en un par de líneas:

 with(binder) {   bind(feature to view using stateToViewModelTransformer)   bind(view to feature using uiEventToWishTransformer)   bind(view to analyticsTracker) } 

Hacemos tales apretones para demostrar la interconexión de componentes. Y dado que los desarrolladores pasamos más tiempo leyendo el código que escribiéndolo, una breve descripción general es extremadamente útil, especialmente a medida que aumenta el número de componentes.

Conclusión


Vimos cómo Binder ayuda a administrar las suscripciones de Rx y cómo le ayuda a obtener una visión general de un sistema construido a partir de componentes reactivos.

En los siguientes artículos, diremos cómo separamos los componentes reactivos de la interfaz de usuario de la lógica empresarial y cómo agregar objetos intermedios usando Binder (para el registro y la depuración del viaje en el tiempo). ¡No cambies!

Mientras tanto, echa un vistazo a la biblioteca en GitHub .

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


All Articles