Arquitetura EBA, também conhecida como reatividade total

Eu vim para Tinkoff há alguns anos, em um novo projeto, Customers and Projects , que estava começando naquele momento.
Agora, não me lembro dos meus sentimentos da então nova arquitetura para mim. Mas lembro com certeza: era incomum que o Rx fosse usado em outro lugar, além das viagens usuais à rede e à base. Agora que essa arquitetura já passou por um caminho evolutivo de desenvolvimento, quero finalmente falar sobre o que aconteceu e o que aconteceu.



Na minha opinião, todas as arquiteturas atualmente populares - MVP, MVVM e até MVI - estão há muito tempo na arena e nem sempre são bem merecidas. Eles não têm falhas? Eu vejo muitos deles. Decidimos em nosso lugar que é suficiente suportar isso e (re) inventamos uma nova arquitetura assíncrona.


Descreverei brevemente o que não gosto nas arquiteturas atuais. Alguns pontos podem ser controversos. Talvez você nunca tenha encontrado isso, você escreve programação Jedi perfeita e geralmente. Então me perdoe, um pecador.
Então, minha dor é:


  • Apresentador enorme / ViewModel.
  • Uma enorme quantidade de casos de troca no MVI.
  • Incapacidade de reutilizar partes do Presenter / ViewModel e, como resultado, a necessidade de duplicar o código.
  • Montes de variáveis ​​mutáveis ​​que podem ser modificadas de qualquer lugar. Assim, é difícil manter e modificar esse código.
  • Atualização de tela não decomposta.
  • É difícil escrever testes.

Edição


A todo momento, o aplicativo tem um determinado estado que define seu comportamento e o que o usuário vê. Esse estado inclui todos os valores das variáveis ​​- de simples sinalizadores a objetos individuais. Cada uma dessas variáveis ​​tem vida própria e é controlada por diferentes partes do código. Você pode determinar o estado atual do aplicativo apenas verificando todos, um após o outro.
Um artigo sobre a moderna arquitetura Kotlin MVI


Capítulo 1. A evolução é o nosso tudo


Inicialmente, escrevemos no MVP, mas um pouco modificado. Foi uma mistura de MVP e MVI. Havia entidades do MVP na forma de um apresentador e da interface View:


interface NewTaskView { val newTaskAction: Observable<NewTaskAction> val taskNameChangeAction: Observable<String> val onChangeState: Consumer<SomeViewState> } 

Já aqui você pode perceber o problema: a exibição aqui está muito longe dos cânones do MVP. Havia um método no apresentador:


 fun bind(view: SomeView): Disposable 

Lá fora, foi aprovada uma implementação de interface que assinava reativamente as alterações na interface do usuário. E já cheira a MVI!


Mais é mais. No Presenter, diferentes interatores foram criados e inscritos nas alterações de exibição, mas não chamaram diretamente os métodos de interface do usuário, mas retornaram algum estado global, no qual havia todos os possíveis estados de tela:


 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) 

Activity foi o descendente da interface SomeViewStateMachine:


 interface SomeViewStateMachine { fun toSuccess(task: SomeUiModel) fun toError(error: String?) fun toProgress() fun changeSomeButton(buttonEnabled: Boolean) } 

Quando o usuário clicou em algo na tela, um evento chegou ao apresentador e ele criou um novo modelo, desenhado por uma classe 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) } } } 

Concordo, algum MVP estranho, e até longe do MVI. Procurando inspiração.


Capítulo 2. Redux



Falando sobre seus problemas com outros desenvolvedores, nosso (então ainda) líder Sergey Boishtyan aprendeu sobre o Redux .


Depois de assistir à conversa de Dorfman sobre todas as arquiteturas e brincar com o Redux , decidimos usá-lo para atualizar nossa arquitetura.
Mas primeiro, vamos examinar mais de perto a arquitetura e seus prós e contras.


Acção
Descreve a ação.


Actioncreator
Ele é como um analista de sistemas: formata, complementa a especificação de requisitos do cliente para que os programadores o entendam.
Quando o usuário clica na tela, o ActionsCreator forma uma ação que vai para o middleware (algum tipo de lógica de negócios). A lógica de negócios nos fornece novos dados que um determinado redutor recebe e extrai.


Se você olhar a imagem novamente, poderá observar um objeto como Loja. Armazenar lojas Redutores. Ou seja, vemos que os irmãos front-end - irmãos infelizes - imaginaram que um objeto grande pode ser visto em muitos pequenos, cada um dos quais será responsável por sua própria parte da tela. E este é apenas um pensamento maravilhoso!


Código de exemplo 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 } } 

Redutor


Ações descreve o fato de que algo aconteceu, mas não indica como o estado do aplicativo deve mudar em resposta; isso é trabalho para o Redutor.

Em resumo, o Redutor sabe como atualizar decompositivamente a tela / view.


Prós:


  • Atualização da tela decomposta.
  • Fluxo de dados unidirecional.

Contras:


  • Mudar favorito novamente.
     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 } 
  • Um monte de objetos de estado.
  • Separação da lógica no ActionCreator e Reducer.

Sim, pareceu-nos que a separação do ActionCreator e do Redutor não é a melhor opção para conectar o modelo e a tela, porque escrever instanceof (is) é uma péssima abordagem. E aqui nós inventamos nossa arquitetura!


Capítulo 3. EBA



O que é Action e ActionCreator no contexto da EBA:


 typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action interface ActionCreator<T> : (T) -> (Observable<Action>) 

Sim, metade da arquitetura é tipealias e uma interface. Simplicidade é igual a elegância!


É necessária uma ação para chamar algo sem transmitir dados. Como o ActionCreator retorna um Observable, tivemos que agrupar o Action em outro lambda para transmitir alguns dados. E assim resultou o ActionMapper - uma ação digitada pela qual podemos passar o que precisamos para atualizar a tela / exibição.

Postulados básicos:

Um ActionCreator - uma parte da tela

Com o primeiro parágrafo, tudo fica claro: para que não haja um inferno de atualizações cruzadas incompreensíveis, concordamos que um ActionCreator pode atualizar apenas sua parte da tela. Se for uma lista, ele atualiza apenas a lista, se apenas o botão.


Punhal não é necessário

Mas, uma pergunta, por que Dagger não nos agradou? Eu te digo.
Uma história típica é quando um Sergey abstrato, também conhecido como mestre das adagas, conhecido como “O que esse abstrato faz?” Está no projeto.


Acontece que, se você experimentou uma adaga, precisa explicar cada vez para cada novo (e não apenas novo) desenvolvedor. Ou talvez você já tenha esquecido o que faz esta anotação e acessado o Google.


Tudo isso complica muito o processo de criação de recursos sem introduzir muita conveniência. Portanto, decidimos criar as coisas de que precisamos com nossas mãos, para que seja mais rápido montar, porque não há geração de código. Sim, gastaremos mais cinco minutos escrevendo todas as dependências com as mãos, mas economizaremos muito tempo na compilação. Sim, em todo lugar não abandonamos a adaga, ela é usada em nível global, cria algumas coisas comuns, mas as escrevemos em Java para melhor otimização, para não atrair o kapt.


Esquema de arquitetura :



Component é um análogo do mesmo componente do Dagger, apenas sem o Dagger. Sua tarefa é criar um fichário. Pasta vincula ActionCreators juntos. De Visualizar para Eventos do Fichário acontecem o que aconteceu, e de Fichário para Visualizar, são enviadas ações que atualizam a tela.


Actioncreator



Agora vamos ver que tipo de coisa é essa - ActionCreator. No caso mais simples, ele simplesmente processa a ação unidirecionalmente. Suponha que exista um cenário assim: o usuário clicou no botão "Criar uma tarefa". Outra tela deve abrir, onde a descreveremos, sem solicitações adicionais.


Para fazer isso, basta assinar o botão usando RxBinding do nosso amado Jake e esperar o usuário clicar nele. Assim que um clique ocorrer, o Binder enviará o evento para um ActionCreator específico, que chamará nossa Action, que abrirá uma nova tela para nós. Observe que não houve interruptores. Em seguida, mostrarei no código por que isso acontece.
Se subitamente precisarmos acessar a rede ou o banco de dados, faremos essas solicitações logo ali, mas através dos interatores que passamos ao construtor ActionCreator por meio da interface para chamá-los:


Isenção de responsabilidade: a formatação do código não está bem aqui, eu tenho suas regras para o artigo, para que o código seja bem lido.

 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 

Pelas palavras "pela interface da chamada", quis dizer exatamente como getItems é declarado (aqui ViewTyped é a nossa interface para trabalhar com listas). A propósito, reutilizamos este ActionCreator em oito partes diferentes do aplicativo, porque ele foi escrito o mais versátil possível.


Como os eventos são de natureza reativa, podemos montar uma cadeia adicionando outros operadores, como startWith (showLoadingAction) para mostrar carregamento e onErrorReturn (errorAction) para mostrar um estado de tela com erro.
E tudo isso é reativo!


Exemplo


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

Vamos finalmente olhar para a arquitetura usando o código como exemplo. Para começar, escolhi uma das telas mais simples - sobre o aplicativo, porque é uma tela estática.
Considere criar um componente:


 val component = AboutComponent( setVersionName = { { appVersion.text = it } }, openPdfAction = { (url, name) -> { openPdf(url, name) } } ) 

Argumentos de componentes - Actions / ActionMappers - ajudam a associar o View ao ActionCreators. No ActionMapper'e setVersionName, passamos a versão do projeto e atribuímos esse valor ao texto na tela. No openPdfAction, um par de um link de documento e um nome para abrir a próxima tela em que o usuário pode ler este documento.


Aqui está o próprio componente:


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

Deixe-me lembrá-lo que:


 typealias Action = () -> Unit typealias ActionMapper<T> = (T) -> Action 

OK, vamos seguir em frente.


 fun binder(): AboutEventsBinder 

Vamos dar uma olhada no AboutEventsBinder em mais detalhes.


 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 é outra tipealias, para não escrever sempre.


 ActionCreator<Observable<*>> 

No AboutEventsBinder, transmitimos ActionCreators e, invocando-os, vinculamos a um evento específico. Mas, para entender como tudo isso se conecta, vejamos a classe 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 o método bindInternal familiar, que redefinimos no sucessor. Agora considere o método de ligação. Toda a magia está aqui. Aceitamos o herdeiro da interface BaseEvents, passamos para bindInternal para conectar eventos e ações. Quando dizemos que o que quer que venha, executamos no fluxo da interface do usuário e assinamos. Também vemos um hack interessante - takeUntil.


 interface BaseEvents { val unbindEvent: EventObservable } 

Depois de definir o campo unbindEvent em BaseEvents para controlar o cancelamento de assinatura, devemos implementá-lo em todos os herdeiros. Esse maravilhoso campo permite que você cancele automaticamente a inscrição na cadeia assim que esse evento for concluído. É simplesmente ótimo! Agora você não pode seguir e não se preocupar com o ciclo de vida e dormir em paz.


 val openPolicyPrivacy = OpenPdfActionCreator(openPdfAction, policyPrivacyUrl) val openProcessingPersonalData = OpenPdfActionCreator(openPdfAction, personalDataUrl) 

De volta ao componente. E aqui você já pode ver o método de reutilização. Nós escrevemos uma classe que pode abrir a tela de visualização em pdf e não importa para nós qual é o URL. Sem duplicação 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)) } } } 

O código do ActionCreator também é o mais simples possível, aqui apenas realizamos algumas manipulações de string.


Vamos voltar ao componente e considerar o seguinte ActionCreator:


 setVersionName.toSimpleActionCreator(moreComponent::currentVersionName) 

Certa vez, ficamos com preguiça de escrever os mesmos e inerentemente simples ActionCreators. Usamos o poder de Kotlin e escrevemos extension'y. Por exemplo, neste caso, nós apenas precisamos passar uma sequência estática para o 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()) } } } } 

Há momentos em que não precisamos transmitir nada, mas apenas chamamos Ação - por exemplo, para abrir a seguinte tela:


 fun Action.toActionCreator(): ActionOnEvent { return object : ActionOnEvent { override fun invoke(event: EventObservable): Observable<Action> { return event.map { this@toActionCreator } } } } 

Então, com o componente terminado, volte ao fragmento:


 val events = AboutEventsImpl( bindEvent = bindEvent, openPolicyPrivacyEvent = confidentialityPolicy.throttleFirstClicks(), openProcessingPersDataEvent = personalDataProtection.throttleFirstClicks(), unbindEvent = unBindEvent) 

Aqui vemos a criação de uma classe responsável por receber eventos do usuário. Desvincular e vincular são apenas eventos de ciclo de vida da tela que captamos usando a biblioteca Navi do 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) 

A interface Events descreve os eventos de uma tela específica, além de herdar BaseEvents. A seguir, é sempre uma implementação da interface. Nesse caso, os eventos acabaram sendo um com os que vêm da tela, mas acontece que você precisa manter dois eventos juntos.


Por exemplo, eventos de carregamento da tela ao abrir e recarregar em caso de erro devem ser combinados em um - basta carregar a tela.


 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 

Voltamos ao fragmento e combinamos tudo! Pedimos ao componente que crie e devolva o fichário para nós e, em seguida, chamamos o método bind, onde passamos o objeto que observa os eventos da tela.


 component.binder().bind(events) 

Estamos escrevendo um projeto nessa arquitetura há cerca de dois anos. E não há limite para a felicidade dos gerentes na velocidade do compartilhamento de recursos! Eles não têm tempo para criar um novo, pois já estamos terminando o antigo. A arquitetura é muito flexível e permite reutilizar muito código.
A desvantagem dessa arquitetura pode ser chamada de não conservação de estado. Não temos um modelo inteiro que descreva o estado da tela, como no MVI, mas podemos lidar com isso. Como - veja abaixo.


Capítulo 4. Bônus


Acho que todo mundo conhece o problema da análise: ninguém gosta de escrevê-lo, porque ele percorre todas as camadas e desfigura os desafios. Algum tempo atrás, e tivemos que enfrentá-lo. Mas, graças à nossa arquitetura, uma implementação muito bonita foi obtida.


Então, qual foi a minha ideia: a análise geralmente sai em resposta às ações do usuário. E nós apenas temos uma classe que acumula ações do usuário. Ok, vamos começar.


Etapa 1 Alteramos ligeiramente a classe base BaseEventsBinder agrupando eventos em 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> } 

Etapa 2 Criamos uma implementação estável da variável trackAnalytics para manter a compatibilidade com versões anteriores e não prejudicar os herdeiros que ainda não precisam de análises:


 interface TrackAnalytics<EVENTS : BaseEvents> { operator fun invoke(events: EVENTS): EVENTS } class EmptyAnalyticsTracker<EVENTS : BaseEvents> : TrackAnalytics<EVENTS> { override fun invoke(events: EVENTS): EVENTS = events } 

Etapa 3 Escrevemos a implementação da interface TrackAnalytics para a tela desejada - por exemplo, para a tela da lista de projetos:


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

Aqui novamente usamos o poder de Kotlin na forma de delegados. Já temos um herdador de interface criado por nós - nesse caso, ProjectsEvents. Mas, para alguns eventos, você precisa redefinir como os eventos ocorrem e adicionar uma ligação em torno deles com o envio de análises. De fato, trackEvent é apenas 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) } 

Etapa 4 Resta transferir isso para o Binder. Como a construímos em um componente, temos a oportunidade, se você precisar de repente, de adicionar dependências adicionais ao construtor. Agora o construtor ProjectsEventsBinder terá a seguinte aparência:


 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) 

Você pode ver outros exemplos no GitHub .


Perguntas e Respostas


Como você mantém o estado da tela?

De jeito nenhum. Bloqueamos a orientação. Mas também usamos argumentos / intenção e salvamos a variável OPENED_FROM_BACKSTACK lá. E ao projetar o Binder, olhamos para ele. Se for falso - carregue dados da rede. Se verdadeiro - a partir do cache. Isso permite que você recrie rapidamente a tela.


Para todos que são contra o bloqueio de orientação: tente testar e depositar análises sobre a frequência com que os usuários viram o telefone e quantas estão em uma orientação diferente. Os resultados podem surpreender.


Não quero escrever componentes, como posso fazer amizade com a adaga?

Eu não aconselho, mas se você não se importa com o tempo de compilação, também pode criar o Component através de um punhal. Mas nós não tentamos.


Eu não escrevo no kotlin, quais são as dificuldades com a implementação em Java?

Tudo o mesmo pode ser escrito em Java, mas não ficará tão bonito.


Se você gostou do artigo, a próxima parte será sobre como escrever testes em uma arquitetura (então ficará claro por que existem tantas interfaces). Spoiler - a gravação é fácil e você pode escrever em todas as camadas, exceto no componente, mas não precisa testá-lo, apenas cria um objeto fichário.


Agradecemos aos colegas da equipe de desenvolvimento móvel da Tinkoff Business por ajudarem a escrever este artigo.

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


All Articles