Llegué a Tinkoff hace un par de años, en un nuevo proyecto, Clientes y Proyectos , que recién comenzaba.
Ahora no recuerdo mis sentimientos de la nueva arquitectura para mí. Pero lo recuerdo con certeza: era inusual que Rx se usara en otro lugar, fuera de los viajes habituales a la red y a la base. Ahora que esta arquitectura ya ha pasado por un camino evolutivo de desarrollo, quiero hablar finalmente sobre lo que sucedió y lo que sucedió.

En mi opinión, todas las arquitecturas populares actualmente (MVP, MVVM e incluso MVI) han estado en la arena durante mucho tiempo y no siempre han sido bien merecidas. ¿No tienen defectos? Veo muchos de ellos. Decidimos en nuestro lugar que es suficiente para soportarlo, y (re) inventamos una nueva arquitectura asincrónica.
Describiré brevemente lo que no me gusta de las arquitecturas actuales. Algunos puntos pueden ser controvertidos. Quizás nunca has encontrado esto, escribes una programación perfecta y generalmente Jedi. Entonces perdóname, pecador.
Entonces mi dolor es:
- Enorme presentador / ViewModel.
- Una gran cantidad de cambio de mayúsculas y minúsculas en MVI.
- Incapacidad para reutilizar partes de Presenter / ViewModel y, como resultado, la necesidad de duplicar el código.
- Montones de variables mutables que se pueden modificar desde cualquier lugar. En consecuencia, dicho código es difícil de mantener y modificar.
- Actualización de pantalla no descompuesta.
- Es difícil escribir pruebas.
Problema
En cada momento, la aplicación tiene un cierto estado que define su comportamiento y lo que ve el usuario. Este estado incluye todos los valores de las variables, desde indicadores simples hasta objetos individuales. Cada una de estas variables vive su propia vida y está controlada por diferentes partes del código. Puede determinar el estado actual de la aplicación solo verificándolos todos, uno tras otro.
Un artículo sobre la arquitectura moderna de Kotlin MVI
Capítulo 1. La evolución es nuestro todo
Inicialmente, escribimos en MVP, pero un poco mutado. Fue una mezcla de MVP y MVI. Había entidades de MVP en forma de presentador e interfaz de visualización:
interface NewTaskView { val newTaskAction: Observable<NewTaskAction> val taskNameChangeAction: Observable<String> val onChangeState: Consumer<SomeViewState> }
Ya aquí puedes notar la captura: Ver aquí está muy lejos de los cánones de MVP. Había un método en el presentador:
fun bind(view: SomeView): Disposable
En el exterior, se aprobó una implementación de interfaz que se suscribió de manera reactiva a los cambios de la interfaz de usuario. ¡Y ya huele a MVI!
Más es más. En Presenter, se crearon diferentes interactores y se suscribieron a los cambios de Vista, pero no llamaron a los métodos de IU directamente, sino que devolvieron un Estado global, en el que había todos los posibles estados de pantalla:
compositeDisposable.add( Observable.merge(firstAction, secondAction) .observeOn(AndroidSchedulers.mainThread()) .subscribe(view.onChangeState)) return compositeDisposable
class SomeViewState(val progress: Boolean? = null, val error: Throwable? = null, val errorMessage: String? = error?.message, val result: TaskUi? = null)
La actividad era descendiente de la interfaz SomeViewStateMachine:
interface SomeViewStateMachine { fun toSuccess(task: SomeUiModel) fun toError(error: String?) fun toProgress() fun changeSomeButton(buttonEnabled: Boolean) }
Cuando el usuario hizo clic en algo en la pantalla, un evento entró en el presentador y creó un nuevo modelo, que fue dibujado por una clase especial:
class SomeViewStateResolver(private val stateMachine: SomeViewStateMachine) : Consumer<SomeViewState> { override fun accept(stateUpdate: SomeViewState) { if (stateUpdate.result != null) { stateMachine.toSuccess(stateUpdate.result) } else if (stateUpdate.error != null && stateUpdate.progress == false) { stateMachine.toError(stateUpdate.errorMessage) } else if (stateUpdate.progress == true) { stateMachine.toProgress() } else if (stateUpdate.someButtonEnabled != null) { stateMachine.changeSomeButton(stateUpdate.someButtonEnabled) } } }
De acuerdo, algún MVP extraño, e incluso lejos de MVI. Buscando inspiración
Capítulo 2. Redux

Hablando sobre sus problemas con otros desarrolladores, nuestro (entonces todavía) líder Sergey Boishtyan se enteró de Redux .
Después de ver la charla de Dorfman sobre todas las arquitecturas y jugar con Redux , decidimos usarlo para actualizar nuestra arquitectura.
Pero primero, echemos un vistazo más de cerca a la arquitectura y veamos sus ventajas y desventajas.
Acción
Describe la acción.
Actioncreator
Es como un analista de sistemas: formatea, complementa las especificaciones de los requisitos del cliente para que los programadores lo entiendan.
Cuando el usuario hace clic en la pantalla, ActionsCreator forma una Acción que va al middleware (algún tipo de lógica de negocios). La lógica empresarial nos brinda nuevos datos que un reductor particular recibe y extrae.
Si vuelve a mirar la imagen, puede observar un objeto como Almacenar. Tienda de tiendas Reductores. Es decir, vemos que los hermanos frontales, desafortunados hermanos, han adivinado que un objeto grande se puede cortar en muchos pequeños, cada uno de los cuales será responsable de su propia parte de la pantalla. ¡Y este es solo un pensamiento maravilloso!
Código de muestra para ActionCreators simples (¡cuidado, JavaScript!):
export function addTodo(text) { return { type: ADD_TODO, text } } export function toggleTodo(index) { return { type: TOGGLE_TODO, index } } export function setVisibilityFilter(filter) { return { type: SET_VISIBILITY_FILTER, filter } }
Reductor
Actions describe el hecho de que sucedió algo, pero no indica cómo debería cambiar el estado de la aplicación en respuesta, esto es trabajo para Reducer.
En resumen, Reducer sabe cómo actualizar descompuesto la pantalla / vista.
Pros:
- Actualización de pantalla descompuesta.
- Flujo de datos unidireccional.
Contras:
- Interruptor favorito de nuevo.
function todoApp(state = initialState, action) { switch (action.type) { case SET_VISIBILITY_FILTER: return Object.assign({}, state, { visibilityFilter: action.filter }) case ADD_TODO: return Object.assign({}, state, { todos: [ ...state.todos, { text: action.text, completed: false } ] }) default: return state }
- Un montón de objetos de estado.
- Separación de lógica en ActionCreator y Reducer.
Sí, nos pareció que la separación de ActionCreator y Reducer no es la mejor opción para conectar el modelo y la pantalla, porque escribir instanceof (is) es un mal enfoque. ¡Y aquí inventamos NUESTRA arquitectura!
Capítulo 3. EBA

Qué es Action y ActionCreator en el contexto de EBA:
typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action interface ActionCreator<T> : (T) -> (Observable<Action>)
Sí, la mitad de la arquitectura es typealias y una interfaz. ¡La simplicidad es igual a la elegancia!
Se necesita acción para llamar a algo sin transmitir ningún dato. Como ActionCreator devuelve un Observable, tuvimos que ajustar Action en otra lambda para transmitir algunos datos. Y así resultó ActionMapper, una acción escrita a través de la cual podemos pasar lo que necesitamos para actualizar la pantalla / vista.
Postulados básicos:
One ActionCreator: una parte de la pantallaCon el primer párrafo, todo está claro: para que no haya un infierno de actualizaciones cruzadas incomprensibles, acordamos que un ActionCreator solo puede actualizar su parte de la pantalla. Si es una lista, solo actualiza la lista, si el botón solo la actualiza.
No se necesita dagaPero, uno se pregunta, ¿por qué Dagger no nos complació? Te digo
Una historia típica es cuando un abstracto Sergey, alias maestro de dagas, también conocido como "¿Qué hace este resumen?", Está en el proyecto.
Resulta que si experimentaste con una daga, debes explicar cada vez a cada desarrollador nuevo (y no solo nuevo). O tal vez usted mismo ya olvidó lo que hace esta anotación, y se va a google.
Todo esto complica enormemente el proceso de creación de características sin introducir mucha comodidad. Por lo tanto, decidimos que crearemos las cosas que necesitamos con nuestras manos, por lo que será más rápido de ensamblar, porque no hay generación de código. Sí, pasaremos cinco minutos adicionales escribiendo todas las dependencias con nuestras manos, pero ahorraremos mucho tiempo en la compilación. Sí, no hemos abandonado la daga en todas partes, se usa a nivel global, crea algunas cosas comunes, pero las escribimos en Java para una mejor optimización, a fin de no atraer kapt.
Esquema de arquitectura :

Componente es un análogo del mismo componente de Dagger, solo que sin Dagger. Su tarea es crear una carpeta. Binder une a ActionCreators. De Ver a Binder Los eventos ocurren sobre lo que sucedió, y de Binder a Ver, se envían acciones que actualizan la pantalla.
Actioncreator

Ahora veamos qué tipo de cosa es esta: ActionCreator. En el caso más simple, simplemente procesa la acción unidireccionalmente. Supongamos que existe tal situación: el usuario hizo clic en el botón "Crear una tarea". Se abrirá otra pantalla, donde la describiremos, sin solicitudes adicionales.
Para hacer esto, simplemente nos suscribimos al botón usando RxBinding de nuestro amado Jake y esperamos que el usuario haga clic en él. Tan pronto como se produce un clic, Binder enviará el Evento a un ActionCreator específico, que llamará nuestra Acción, que nos abrirá una nueva pantalla. Tenga en cuenta que no hubo interruptores. A continuación, mostraré en el código por qué es así.
Si de repente necesitamos ir a la red o la base de datos, hacemos estas solicitudes allí mismo, pero a través de los interactores que pasamos al constructor ActionCreator a través de la interfaz para llamarlos:
Descargo de responsabilidad: el formato del código no está del todo bien aquí, tengo sus reglas para el artículo para que el código se lea bien.
class LoadItemsActionCreator( private val getItems: () -> Observable<List<ViewTyped>>, private val showLoadedItems: ActionMapper<DiffResult<ViewTyped>>, private val diffCalculator: DiffCalculator<ViewTyped>, private val errorItem: ErrorView, private val emptyItem: ViewTyped? = null) : ActionOnEvent
Por las palabras "por la interfaz de su llamada" quise decir exactamente cómo se declara getItems (aquí ViewTyped es nuestra interfaz para trabajar con listas). Por cierto, hemos reutilizado este ActionCreator en ocho partes diferentes de la aplicación, porque está escrito lo más versátil posible.
Como los eventos son de naturaleza reactiva, podemos ensamblar una cadena agregando otros operadores allí, por ejemplo, startWith (showLoadingAction) para mostrar la carga y onErrorReturn (errorAction) para mostrar un estado de pantalla con un error.
¡Y todo esto es reactivo!
Ejemplo
class AboutFragment : CompositionFragment(R.layout.fragment_about) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } }) val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.clicks(), openProcessingPersDataEvent = personalDataProtection.clicks(), unbindEvent = unBindEvent) component.binder().bind(events) }
Veamos finalmente la arquitectura usando el código como ejemplo. Para comenzar, elegí una de las pantallas más simples: sobre la aplicación, porque es una pantalla estática.
Considere crear un componente:
val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } } )
Argumentos de componentes - Actions / ActionMappers - ayudan a asociar View con ActionCreators. En ActionMapper'e setVersionName pasamos la versión del proyecto y asignamos este valor al texto en la pantalla. En openPdfAction, un par de enlaces a un documento y un nombre para abrir la siguiente pantalla donde el usuario puede leer este documento.
Aquí está el componente en sí:
class AboutComponent( private val setVersionName: ActionMapper<String>, private val openPdfAction: ActionMapper<Pair<String, String>>) { fun binder(): AboutEventsBinder { val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, someUrlString) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, anotherUrlString) val setVersionName = setVersionName.toSimpleActionCreator( moreComponent::currentVersionName ) return AboutEventsBinder(setVersionName, openPolicyPrivacy, openProcessingPersonalData) } }
Déjame recordarte que:
typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action
OK, sigamos adelante.
fun binder(): AboutEventsBinder
Echemos un vistazo a AboutEventsBinder con más detalle.
class AboutEventsBinder(private val setVersionName: ActionOnEvent, private val openPolicyPrivacy: ActionOnEvent, private val openProcessingPersonalData: ActionOnEvent) : BaseEventsBinder<AboutEvents>() { override fun bindInternal(events: AboutEvents): Observable<Action> { return Observable.merge( setVersionName(events.bindEvent), openPolicyPrivacy(events.openPolicyPrivacyEvent), openProcessingPersonalData(events.openProcessingPersDataEvent)) } }
ActionOnEvent es otro tipo de letra, para no escribir siempre.
ActionCreator<Observable<*>>
En AboutEventsBinder, pasamos ActionCreators y, invocandolos, enlazamos a un evento específico. Pero para comprender cómo se conecta todo esto, veamos la clase base: BaseEventsBinder.
abstract class BaseEventsBinder<in EVENTS : BaseEvents>( private val uiScheduler: Scheduler = AndroidSchedulers.mainThread() ) { fun bind(events: EVENTS) { bindInternal(events).observeOn(uiScheduler) .takeUntil(events.unbindEvent) .subscribe(Action::invoke) } protected abstract fun bindInternal(events: EVENTS): Observable<Action> }
Vemos el método familiar bindInternal, que redefinimos en el sucesor. Ahora considere el método de enlace. Toda la magia está aquí. Aceptamos el heredero de la interfaz BaseEvents, páselo a bindInternal para conectar Eventos y Acciones. Una vez que decimos que lo que sea que venga, lo ejecutamos en el ui-stream y nos suscribimos. También vemos un truco interesante: takeUntil.
interface BaseEvents { val unbindEvent: EventObservable }
Una vez definido el campo unbindEvent en BaseEvents para controlar la cancelación de la suscripción, debemos implementarlo en todos los herederos. Este maravilloso campo le permite darse de baja automáticamente de la cadena tan pronto como se complete este evento. ¡Es genial! Ahora no puedes seguir y no te preocupes por el ciclo de vida y dormir tranquilo.
val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, policyPrivacyUrl) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, personalDataUrl)
De vuelta al componente. Y aquí ya puedes ver el método de reutilización. Escribimos una clase que puede abrir la pantalla de visualización de PDF, y no nos importa qué url sea. Sin duplicación de código.
class OpenPdfActionCreator( private val openPdfAction: ActionMapper<Pair<String, String>>, private val pdfUrl: String) : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { openPdfAction(pdfUrl to pdfUrl.substringAfterLast(FILE_NAME_DELIMITER)) } } }
El código ActionCreator también es lo más simple posible, aquí solo realizamos algunas manipulaciones de cadenas.
Volvamos al componente y consideremos el siguiente ActionCreator:
setVersionName.toSimpleActionCreator(moreComponent::currentVersionName)
Una vez que nos volvimos demasiado flojos para escribir los mismos ActionCreators inherentemente simples. Utilizamos el poder de Kotlin y escribimos extension'y. Por ejemplo, en este caso, solo necesitábamos pasar una cadena estática a ActionMapper.
fun <R> ActionMapper<R>.toSimpleActionCreator( mapper: () -> R): ActionCreator<Observable<*>> { return object : ActionCreator<Observable<*>> { override fun invoke(event: Observable<*>): Observable<Action> { return event.map { this@toSimpleActionCreator(mapper()) } } } }
Hay momentos en que no necesitamos transmitir nada, solo llamamos a alguna Acción, por ejemplo, para abrir la siguiente pantalla:
fun Action.toActionCreator(): ActionOnEvent { return object : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { this@toActionCreator } } } }
Entonces, con el componente terminado, regrese al fragmento:
val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.throttleFirstClicks(), openProcessingPersDataEvent = personalDataProtection.throttleFirstClicks(), unbindEvent = unBindEvent)
Aquí vemos la creación de una clase responsable de recibir eventos del usuario. Y desvincular y vincular son solo eventos del ciclo de vida de la pantalla que recogemos utilizando la biblioteca Navi de Trello.
fun <T> NaviComponent.observe(event: Event<T>): Observable<T> = RxNavi.observe(this, event) val unBindEvent: Observable<*> = observe(Event.DESTROY_VIEW) val bindEvent: Observable<*> = Observable.just(true) val bindEvent = observe(Event.POST_CREATE)
La interfaz de eventos describe los eventos de una pantalla en particular, además debe heredar BaseEvents. Lo siguiente es siempre una implementación de la interfaz. En este caso, los eventos resultaron ser uno a uno con los que vienen de la pantalla, pero sucede que debes mantener dos eventos juntos.
Por ejemplo, los eventos de carga de la pantalla al abrir y volver a cargar en caso de error deben combinarse en uno solo cargando la pantalla.
interface AboutEvents : BaseEvents { val bindEvent: EventObservable val openPolicyPrivacyEvent: EventObservable val openProcessingPersDataEvent: EventObservable } class AboutEventsImpl(override val bindEvent: EventObservable, override val openPolicyPrivacyEvent: EventObservable, override val openProcessingPersDataEvent: EventObservable, override val unbindEvent: EventObservable) : AboutEvents
¡Volvemos al fragmento y combinamos todo juntos! Le pedimos al componente que cree y nos devuelva el cuaderno, luego llamamos al método de enlace en él, donde pasamos el objeto que observa los eventos de la pantalla.
component.binder().bind(events)
Llevamos unos dos años escribiendo un proyecto sobre esta arquitectura. ¡Y no hay límite para la felicidad de los gerentes en la velocidad de compartir funciones! No tienen tiempo para encontrar uno nuevo, ya que estamos terminando el viejo. La arquitectura es muy flexible y le permite reutilizar una gran cantidad de código.
La desventaja de esta arquitectura puede llamarse no conservación del estado. No tenemos un modelo completo que describa el estado de la pantalla, como en MVI, pero podemos manejarlo. Me gusta - ver abajo.
Capítulo 4. Bonificación
Creo que todos conocen el problema de la analítica: a nadie le gusta escribirlo, porque se arrastra a través de todas las capas y desfigura los desafíos. Hace algún tiempo, y tuvimos que enfrentarlo. Pero gracias a nuestra arquitectura, se obtuvo una implementación muy hermosa.
Entonces, cuál fue mi idea: la analítica generalmente sale en respuesta a las acciones del usuario. Y solo tenemos una clase que acumula acciones del usuario. Ok, empecemos.
Paso 1 Cambiamos ligeramente la clase base BaseEventsBinder ajustando eventos en trackAnalytics:
abstract class BaseEventsBinder<in EVENTS : BaseEvents>( private val trackAnalytics: TrackAnalytics<EVENTS> = EmptyAnalyticsTracker(), private val uiScheduler: Scheduler = AndroidSchedulers.mainThread()) { @SuppressLint("CheckResult") fun bind(events: EVENTS) { bindInternal(trackAnalytics(events)).observeOn(uiScheduler) .takeUntil(events.unbindEvent) .subscribe(Action::invoke) } protected abstract fun bindInternal(events: EVENTS): Observable<Action> }
Paso 2 Creamos una implementación estable de la variable trackAnalytics para mantener la compatibilidad con versiones anteriores y no romper los herederos que aún no necesitan análisis:
interface TrackAnalytics<EVENTS : BaseEvents> { operator fun invoke(events: EVENTS): EVENTS } class EmptyAnalyticsTracker<EVENTS : BaseEvents> : TrackAnalytics<EVENTS> { override fun invoke(events: EVENTS): EVENTS = events }
Paso 3 Escribimos la implementación de la interfaz TrackAnalytics para la pantalla deseada, por ejemplo, para la pantalla de lista de proyectos:
class TrackProjectsEvents : TrackAnalytics<ProjectsEvents> { override fun invoke(events: ProjectsEvents): ProjectsEvents { return object : ProjectsEvents by events { override val boardClickEvent = events.boardClickEvent.trackTypedEvent { allProjectsProjectClick(it.title) } override val openBoardCreationEvent = events.openBoardCreationEvent.trackEvent { allProjectsAddProjectClick() } override val openCardsSearchEvent = events.openCardsSearchEvent.trackEvent { allProjectsSearchBarClick() } } } }
Aquí nuevamente usamos el poder de Kotlin en forma de delegados. Ya tenemos un heredero de interfaz creado por nosotros, en este caso ProjectsEvents. Pero para algunos eventos, debe redefinir cómo van los eventos y agregar un enlace a su alrededor con el envío de análisis. De hecho, trackEvent es solo doOnNext:
inline fun <T> Observable<T>.trackEvent(crossinline event: AnalyticsSpec.() -> Unit): Observable<T> = doOnNext { event(analyticsSpec) } inline fun <T> Observable<T>.trackTypedEvent(crossinline event: AnalyticsSpec.(T) -> Unit): Observable<T> = doOnNext { event(analyticsSpec, it) }
Paso 4 Queda por transferir esto a Binder. Como lo construimos en un componente, tenemos la oportunidad, si de repente lo necesita, de agregar dependencias adicionales al constructor. Ahora el constructor ProjectsEventsBinder se verá así:
class ProjectsEventsBinder( private val loadItems: LoadItemsActionCreator, private val refreshBoards: ActionOnEvent, private val openBoard: ActionCreator<Observable<BoardId>>, private val openScreen: ActionOnEvent, private val openCardSearch: ActionOnEvent, trackAnalytics: TrackAnalytics<ProjectsEvents>) : BaseEventsBinder<ProjectsEvents>(trackAnalytics)
Puedes ver otros ejemplos en GitHub .
Preguntas y respuestas
¿Cómo se mantiene el estado de la pantalla?De ninguna manera Bloqueamos la orientación. Pero también usamos argumentos / intención y guardamos la variable OPENED_FROM_BACKSTACK allí. Y al diseñar Binder, lo miramos. Si es falso, cargue los datos de la red. Si es verdadero, del caché. Esto le permite recrear rápidamente la pantalla.
Para todos los que están en contra del bloqueo de orientación: intente probar y depositar análisis sobre la frecuencia con la que sus usuarios dan la vuelta al teléfono y cuántos tienen una orientación diferente. Los resultados pueden sorprender.
No quiero escribir componentes, ¿cómo puedo hacer amigos con la daga?No le aconsejo, pero si no le importa compilar el tiempo, también puede crear Componente a través de una daga. Pero no lo intentamos.
No escribo en kotlin, ¿cuáles son las dificultades con la implementación en Java?De todos modos, se puede escribir en Java, solo que no se verá tan hermoso.
Si le gusta el artículo, la siguiente parte será sobre cómo escribir pruebas en dicha arquitectura (entonces quedará claro por qué hay tantas interfaces). Spoiler: la escritura es fácil y puede escribir en todas las capas, excepto en el componente, pero no necesita probarlo, solo crea un objeto de carpeta.
Gracias a los colegas del equipo de desarrollo móvil de Tinkoff Business por su ayuda al escribir este artículo.