De copiar e colar para componentes: reutilizando código em diferentes aplicativos



O Badoo desenvolve várias aplicações, e cada uma delas é um produto separado, com suas próprias características, gerenciamento, produto e equipes de engenharia. Mas todos trabalhamos juntos no mesmo escritório e resolvemos problemas semelhantes.

O desenvolvimento de cada projeto ocorreu à sua maneira. A base de código foi influenciada não apenas por diferentes prazos e soluções de produtos, mas também pela visão dos desenvolvedores. No final, percebemos que os projetos têm a mesma funcionalidade, que é fundamentalmente diferente na implementação.

Decidimos chegar a uma estrutura que nos daria a oportunidade de reutilizar recursos entre aplicativos. Agora, em vez de desenvolver funcionalidades em projetos individuais, criamos componentes comuns que se integram a todos os produtos. Se você está interessado em como chegamos a isso, seja bem-vindo ao gato.

Mas primeiro, vamos nos debruçar sobre os problemas, cuja solução levou à criação de componentes comuns. Havia vários deles:

  • copiar e colar entre aplicativos;
  • processos que inserem varas nas rodas;
  • arquitetura diferente de projetos.



Este artigo é uma versão em texto do meu relatório com o AppsConf 2019 , que pode ser visualizada aqui .

Problema: copiar e colar


Algum tempo atrás, quando as árvores estavam mais nebulosas, a grama era mais verde e eu era um ano mais jovem, muitas vezes tivemos a seguinte situação.

Há um desenvolvedor, vamos chamá-lo de Lesha. Ele cria um módulo interessante para sua tarefa, conta a seus colegas e o coloca no repositório do aplicativo, onde ele o usa.

O problema é que todos os nossos aplicativos estão em repositórios diferentes.



O desenvolvedor Andrey atualmente está trabalhando em outro aplicativo em um repositório diferente. Ele quer usar este módulo em sua tarefa, que é suspeitamente semelhante ao que Lesha estava envolvido. Mas há um problema: o processo de reutilização de código é completamente depurado.

Nessa situação, Andrei escreverá sua decisão (o que acontece em 80% dos casos) ou copiará e cole a solução do Lyosha e alterará tudo nela para que se adapte à sua aplicação, tarefa ou humor.



Depois disso, Lesha pode atualizar seu módulo adicionando alterações ao seu código para sua tarefa. Ele não conhece outra versão e atualizará apenas seu repositório.

Esta situação traz vários problemas.

Primeiro, temos várias aplicações, cada uma com seu próprio histórico de desenvolvimento. Ao trabalhar em cada aplicativo, a equipe do produto geralmente criava soluções difíceis de trazer para uma única estrutura.

Em segundo lugar, equipes separadas estão envolvidas em projetos, que se comunicam mal entre si e, portanto, raramente se informam sobre atualizações / reutilização de um ou outro módulo.

Em terceiro lugar, a arquitetura do aplicativo é muito diferente: do MVP ao MVI, da atividade divina à atividade única.

Bem, o "destaque do programa": os aplicativos estão em repositórios diferentes, cada um com seus próprios processos.

No início da luta contra esses problemas, estabelecemos o objetivo final: reutilizar nossas melhores práticas (lógica e interface do usuário) entre todos os aplicativos.

Decisões: estabelecemos processos


Dos problemas acima, dois estão relacionados aos processos:

  1. Dois repositórios que compartilharam projetos com uma parede impenetrável.
  2. Equipes separadas sem comunicação estabelecida e requisitos diferentes das equipes de aplicação do produto.

Vamos começar com o primeiro: estamos lidando com dois repositórios com a mesma versão do módulo. Teoricamente, poderíamos usar git-subtree ou soluções similares e colocar módulos de projetos comuns em repositórios separados.



O problema ocorre durante a modificação. Diferentemente dos projetos de código aberto, que possuem uma API estável e são distribuídos por fontes externas, as alterações geralmente ocorrem em componentes internos que quebram tudo. Ao usar a subárvore, cada uma dessas migrações se torna um problema.

Meus colegas da equipe do iOS têm uma experiência semelhante e acabou não tendo muito sucesso, como Anton Schukin falou na conferência Mobius no ano passado.

Depois de estudar e compreender sua experiência, mudamos para um único repositório. Todos os aplicativos Android agora estão em um só lugar, o que nos dá alguns benefícios:

  • você pode reutilizar o código com segurança usando os módulos Gradle;
  • conseguimos conectar a cadeia de ferramentas no CI usando uma infraestrutura para compilações e testes;
  • essas mudanças removeram as barreiras físicas e mentais entre as equipes, pois agora somos livres para usar os desenvolvimentos e soluções uns dos outros.

Obviamente, esta solução também tem desvantagens. Temos um projeto enorme, que às vezes não está sujeito ao IDE e Gradle. O problema pode ser parcialmente resolvido pelos módulos Carregar / Descarregar no Android Studio, mas é difícil usá-los se você precisar trabalhar simultaneamente em todos os aplicativos e alternar com frequência.

O segundo problema - interação entre equipes - consistia em várias partes:

  • equipes separadas sem comunicação estabelecida;
  • distribuição indistinta de responsabilidade por módulos comuns;
  • requisitos diferentes das equipes de produtos.

Para resolvê-lo, formamos equipes envolvidas na implementação de determinadas funcionalidades em cada aplicativo: por exemplo, bate-papo ou registro. Além do desenvolvimento, eles também são responsáveis ​​pela integração desses componentes no aplicativo.

As equipes de produto já têm componentes em mãos, melhorando e personalizando-os de acordo com as necessidades de um projeto específico.

Assim, agora a criação de um componente reutilizável faz parte do processo para toda a empresa, desde o estágio da ideia até o início da produção.

Soluções: simplificando a arquitetura


Nosso próximo passo para reutilizar foi otimizar a arquitetura. Por que fizemos isso?

Nossa base de código carrega o legado histórico de vários anos de desenvolvimento. Juntamente com o tempo e as pessoas, as abordagens mudaram. Então nos encontramos em uma situação com um zoológico inteiro de arquiteturas, o que resultou nos seguintes problemas:

  1. A integração de módulos comuns foi quase mais lenta do que a criação de novos. Além dos recursos do funcional, era necessário suportar a estrutura do componente e do aplicativo.
  2. Os desenvolvedores que precisavam alternar entre aplicativos muitas vezes passavam muito tempo dominando novas abordagens.
  3. Muitas vezes, os wrappers eram gravados de uma abordagem para outra, o que equivalia a metade do código na integração do módulo.

No final, decidimos pela abordagem MVI, que estruturamos em nossa biblioteca MVICore ( GitHub ). Estávamos particularmente interessados ​​em uma de suas características - atualizações de estado atômico, que sempre garantem validade. Fomos um pouco mais longe e combinamos os estados das camadas lógicas e de apresentação, reduzindo a fragmentação. Assim, chegamos a uma estrutura em que a única entidade é responsável pela lógica e a exibição exibe apenas o modelo criado a partir do estado.



A separação de responsabilidades ocorre através da transformação de modelos entre níveis. Graças a isso, recebemos um bônus na forma de reutilização. Conectamos os elementos de fora, ou seja, cada um deles não suspeita que o outro exista - eles simplesmente dão alguns modelos e reagem ao que lhes chega. Isso permite que você retire componentes e os use em outros lugares escrevendo adaptadores para seus modelos.

Vejamos um exemplo de uma tela simples como ela se parece na realidade.



Usamos as interfaces básicas do RxJava para indicar os tipos com os quais o elemento trabalha. A entrada é indicada pela interface Consumidor <T>, saída - ObservableSource <T>.

// input = Consumer<ViewModel> // output = ObservableSource<Event> class View( val events: PublishRelay<Event> ): ObservableSource<Event> by events, Consumer<ViewModel> { val button: Button val textView: TextView init { button.setOnClickListener { events.accept(Event.ButtonClick) } } override fun accept(model: ViewModel) { textView.text = model.text } } 

Usando essas interfaces, podemos expressar View como Consumer <ViewModel> e ObservableSource <Event>. Observe que o ViewModel contém apenas o estado da tela e pouco tem a ver com o MVVM. Depois de receber o modelo, podemos mostrar os dados dele e, quando clicamos no botão, enviamos o evento, que é transmitido para fora.

 // input = Consumer<Wish> // output = ObservableSource<State> class Feature: ReducerFeature<Wish, State>( initialState = State(counter = 0), reducer = ReducerImpl() ) { class ReducerImpl: Reducer<Wish, State> { override fun invoke(state: State, wish: Wish) = when (wish) { is Increment -> state.copy(counter = state.counter + 1) } } } 

O recurso já implementa o ObservableSource e o Consumidor para nós; precisamos transferir para lá o estado inicial (contador igual a 0) e indicar como alterar esse estado.

Após a transferência do Wish, é chamado Redutor, o que cria um novo com base no último estado. Além do Redutor, a lógica pode ser descrita por outros componentes. Você pode aprender mais sobre eles aqui .

Depois de criar os dois elementos, resta para nós conectá-los.


 val eventToWish: (Event) -> Wish = { when (it) { is ButtonClick -> Increment } } val stateToModel: (State) -> ViewModel = { ViewModel(text = state.counter.toString()) } Binder().apply { bind(view to feature using eventToWish) bind(feature to view using stateToModel) } 

Primeiro, indicamos como transformamos um elemento de um tipo em outro. Portanto, ButtonClick se torna Increment, e o campo do contador State entra em texto.

Agora podemos criar cada uma das cadeias com a transformação desejada. Para isso, usamos o Binder. Ele permite que você crie relacionamentos entre o ObservableSource e o Consumidor, observando o ciclo de vida. E tudo isso com uma boa sintaxe. Esse tipo de conexão nos leva a um sistema flexível que nos permite extrair e usar elementos individualmente.

Os elementos MVICore funcionam muito bem com nosso "zoológico" de arquiteturas depois de escrever wrappers da ObservableSource e Consumer. Por exemplo, podemos agrupar os métodos de Caso de Uso de Arquitetura Limpa em Desejo / Estado e usar na cadeia em vez de Recurso.



Componente


Finalmente, passamos aos componentes. Como eles são?

Considere a tela no aplicativo e divida-a em partes lógicas.



Pode ser distinguido:

  • barra de ferramentas com logotipo e botões na parte superior;
  • um cartão com um perfil e logotipo;
  • Seção do Instagram.

Cada uma dessas partes é o próprio componente que pode ser reutilizado em um contexto completamente diferente. Portanto, a seção do Instagram pode se tornar parte da edição de perfis em outro aplicativo.



No caso geral, um componente é composto por vários modos de exibição, elementos lógicos e componentes aninhados, unidos pela funcionalidade comum. E imediatamente surge a pergunta: como montá-los em uma estrutura suportada?

O primeiro problema que encontramos é que o MVICore ajuda a criar e ligar elementos, mas não oferece uma estrutura comum. Ao reutilizar elementos de um módulo comum, não está claro onde juntar essas peças: dentro da peça comum ou no lado da aplicação?

No caso geral, definitivamente não queremos fornecer à aplicação partes dispersas. Idealmente, buscamos algum tipo de estrutura que nos permita obter dependências e montar o componente como um todo com o ciclo de vida desejado.

Inicialmente, dividimos os componentes em telas. A conexão dos elementos ocorreu ao lado da criação de contêineres DI para atividade ou fragmento. Esses contêineres já conhecem todas as dependências, têm acesso ao View e ao ciclo de vida.

 object SomeScopedComponent : ScopedComponent<SomeComponent>() { override fun create(): SomeComponent { return DaggerSomeComponent.builder() .build() } override fun SomeComponent.subscribe(): Array<Disposable> = arrayOf( Binder().apply { bind(feature().news to otherFeature()) bind(feature() to view()) } ) } 

Os problemas começaram em dois lugares ao mesmo tempo:

  1. O DI começou a trabalhar com a lógica, o que levou à descrição de todo o componente em uma classe.
  2. Como o contêiner é anexado a uma Atividade ou Fragmento e descreve pelo menos a tela inteira, há muitos elementos nessa tela / contêiner, o que se traduz em uma enorme quantidade de código para conectar todas as dependências dessa tela.

Resolvendo os problemas em ordem, começamos colocando a lógica em um componente separado. Assim, podemos coletar todos os recursos desse componente e nos comunicar com o View por meio de entrada e saída. Do ponto de vista da interface, parece um elemento MVICore comum, mas ao mesmo tempo é criado a partir de vários outros.



Tendo resolvido esse problema, compartilhamos a responsabilidade de conectar os elementos. Mas ainda compartilhamos os componentes nas telas, o que claramente não estava disponível para nós, resultando em um grande número de dependências em um só lugar.

 @Scope internal class ComponentImpl @Inject constructor( private val params: ScreenParams, news: NewsRelay, @OnDisposeAction onDisposeAction: () -> Unit, globalFeature: GlobalFeature, conversationControlFeature: ConversationControlFeature, messageSyncFeature: MessageSyncFeature, conversationInfoFeature: ConversationInfoFeature, conversationPromoFeature: ConversationPromoFeature, messagesFeature: MessagesFeature, messageActionFeature: MessageActionFeature, initialScreenFeature: InitialScreenFeature, initialScreenExplanationFeature: InitialScreenExplanationFeature?, errorFeature: ErrorFeature, conversationInputFeature: ConversationInputFeature, sendRegularFeature: SendRegularFeature, sendContactForCreditsFeature: SendContactForCreditsFeature, screenEventTrackingFeature: ScreenEventTrackingFeature, messageReadFeature: MessageReadFeature?, messageTimeFeature: MessageTimeFeature?, photoGalleryFeature: PhotoGalleryFeature?, onlineStatusFeature: OnlineStatusFeature?, favouritesFeature: FavouritesFeature?, isTypingFeature: IsTypingFeature?, giftStoreFeature: GiftStoreFeature?, messageSelectionFeature: MessageSelectionFeature?, reportingFeature: ReportingFeature?, takePhotoFeature: TakePhotoFeature?, giphyFeature: GiphyFeature, goodOpenersFeature: GoodOpenersFeature?, matchExpirationFeature: MatchExpirationFeature, private val pushIntegration: PushIntegration ) : AbstractMviComponent<UiEvent, States>( 

A solução correta nessa situação é quebrar o componente. Como vimos acima, cada tela consiste em muitos elementos lógicos que podemos dividir em partes independentes.

Depois de um pouco de reflexão, chegamos a uma estrutura em árvore e, construindo-a ingênua a partir de componentes existentes, obtivemos o seguinte esquema:



Obviamente, é quase impossível manter a sincronização de duas árvores (do View e da lógica). No entanto, se o componente for responsável por exibir sua Visualização, podemos simplificar esse esquema. Tendo estudado as soluções já criadas, repensamos nossa abordagem, contando com os RIBs da Uber.



As idéias por trás dessa abordagem são muito semelhantes às noções básicas do MVICore. RIB é um tipo de "caixa preta", comunicação com a qual ocorre através de uma interface estritamente definida a partir de dependências (ou seja, entrada e saída). Apesar da aparente complexidade de oferecer suporte a essa interface em um produto iterativo rápido, temos grandes oportunidades para reutilizar o código.

Assim, em comparação com iterações anteriores, obtemos:

  • lógica encapsulada dentro de um componente;
  • suporte para aninhamento, o que possibilita dividir telas em partes;
  • interação com outros componentes através de uma interface rigorosa de entrada / saída com suporte para MVICore;
  • conexão segura em tempo de compilação de dependências de componentes (confiando no Dagger como um DI).

Claro, isso está longe de tudo. O repositório no GitHub contém uma descrição mais detalhada e atualizada.

E aqui temos um mundo perfeito. Possui componentes a partir dos quais podemos construir uma árvore totalmente reutilizável.

Mas vivemos em um mundo imperfeito.

Bem-vindo à realidade!


Em um mundo imperfeito, há um monte de coisas que temos que aturar. Estamos preocupados com o seguinte:

  • diferentes funcionalidades: apesar de toda a unificação, ainda estamos lidando com produtos individuais com requisitos diferentes;
  • suporte: como sem novas funcionalidades nos testes A / B?
  • Legado (tudo o que foi escrito antes de nossa nova arquitetura).

A complexidade das soluções aumenta exponencialmente, pois cada aplicativo adiciona algo próprio aos componentes comuns.

Considere o processo de registro como um exemplo de um componente comum que se integra aos aplicativos. Em geral, o registro é uma cadeia de telas com ações que afetam todo o fluxo. Cada aplicativo possui telas diferentes e sua própria interface do usuário. O objetivo final é criar um componente reutilizável flexível, o que também nos ajudará a resolver os problemas da lista acima.



Requisitos diversos


Cada aplicativo possui suas próprias variações de registro exclusivas, tanto do lado lógico quanto do lado da interface do usuário. Portanto, começamos a generalizar a funcionalidade no componente com um mínimo: baixando dados e roteando todo o fluxo.



Esse contêiner transfere dados para o aplicativo do servidor, que é convertido em uma tela finalizada com lógica. O único requisito é que as telas passadas para esse contêiner precisem satisfazer dependências para interagir com a lógica de todo o fluxo.

Depois de fazer esse truque com algumas aplicações, percebemos que a lógica das telas é quase a mesma. Em um mundo ideal, criaríamos uma lógica comum personalizando a Visualização. A questão é como personalizá-los.

Como você pode se lembrar da descrição do MVICore, o View e o Feature são baseados na interface do ObservableSource e Consumer. Utilizando-os como uma abstração, podemos substituir a implementação sem alterar as partes principais.



Então, reutilizamos a lógica dividindo a interface do usuário. Como resultado, o suporte se torna muito mais conveniente.

Suporte


Considere o teste A / B para a variação de elementos visuais. Nesse caso, nossa lógica não muda, o que nos permite substituir outra interface do View pela interface existente de ObservableSource e Consumer.



Obviamente, algumas vezes novos requisitos contradizem a lógica já escrita. Nesse caso, sempre podemos retornar ao esquema original, onde o aplicativo fornece a tela inteira. Para nós, é uma espécie de "caixa preta" e não importa para o contêiner o que eles passam para ele, desde que sua interface seja observada.

Integração


Como mostra a prática, a maioria dos aplicativos usa o Activity como unidades básicas, os meios de comunicação entre os quais há muito são conhecidos. Tudo o que tivemos que fazer foi aprender a agrupar componentes no Activity e transmitir dados através de entrada e saída. Como se viu, essa abordagem funciona muito bem com fragmentos.

Para aplicativos de atividade única, nada muda muito. Quase todas as estruturas oferecem seus elementos básicos nos quais os componentes do RIB se permitem envolver.

No final


Passados ​​esses estágios, aumentamos significativamente a porcentagem de reutilização de código entre os projetos de nossa empresa. No momento, o número de componentes está se aproximando de 100, e a maioria deles implementa funcionalidade para vários aplicativos ao mesmo tempo.

Nossa experiência mostra que:

  • apesar da crescente complexidade de projetar componentes comuns, dados os requisitos de diferentes aplicativos, seu suporte é muito mais fácil a longo prazo;
  • construindo componentes isolados uns dos outros , simplificamos bastante sua integração em aplicativos criados com base em princípios diferentes;
  • as revisões de processo, juntamente com a ênfase no desenvolvimento e suporte de componentes, têm um efeito positivo na qualidade da funcionalidade geral.

Meu colega Zsolt Kocsi escreveu anteriormente sobre o MVICore e as idéias por trás dele. Eu recomendo a leitura dos artigos dele, que traduzimos em nosso blog ( 1 , 2 , 3 ).

Sobre os RIBs, você pode ler o artigo original do Uber . E, para conhecimento prático, recomendo tirar algumas lições de nós (em inglês).

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


All Articles