Arquitetura multi-módulo Android. A a Z

Olá pessoal!

Há pouco tempo, percebemos que um aplicativo móvel não é apenas um thin client, mas um número muito grande de lógicas muito diferentes que precisam ser otimizadas. É por isso que fomos inspirados pelas idéias da arquitetura Clean, sentimos o que é DI, aprendemos a usar o Dagger 2 e agora, com os olhos fechados, somos capazes de dividir qualquer recurso em camadas.

Mas o mundo não pára e, com a solução de velhos problemas, surgem novos. E o nome desse novo problema é monomodularidade. Você geralmente descobre esse problema quando o tempo de montagem voa para o espaço. Isso é exatamente quantos relatórios sobre a transição para a multimodularidade ( um , dois ) começam.
Mas, por alguma razão, todo mundo ao mesmo tempo esquece que a monomodularidade afeta fortemente não apenas o tempo de montagem, mas também sua arquitetura. Aqui responda as perguntas. Qual é o tamanho do seu AppComponent? Você vê periodicamente no código que o recurso A, por algum motivo, puxa o repositório do recurso B, embora não deva ser assim, bem, ou deveria ser de alguma forma mais de nível superior? Os recursos têm algum tipo de contrato? E como você organiza a comunicação entre os recursos? Existem regras?
Você acha que resolvemos o problema com as camadas, ou seja, verticalmente tudo parece estar bem, mas horizontalmente algo está errado? E apenas quebrar os pacotes e controlar a revisão não resolve o problema.

E a questão da segurança para os mais experientes. Quando você mudou para a multi-modularidade, não precisava escavar metade do aplicativo, sempre arraste o código de um módulo para outro e viva com um projeto não montado por um período decente de tempo?

No meu artigo, quero contar como cheguei à multimodularidade precisamente do ponto de vista arquitetural. Que problemas me incomodaram e como tentei resolvê-los em etapas. E no final, você encontrará um algoritmo para alternar da monomodularidade para a multimodularidade sem lágrimas e dor.

Respondendo à primeira pergunta, qual o tamanho do AppComponent, posso confessar - grande, muito grande. E isso constantemente me atormentava. Como isso aconteceu? Primeiro de tudo, isso se deve a uma organização de DI. É com DI que vamos começar.

Como eu fiz DI antes


Eu acho que muitas pessoas formaram em suas cabeças algo como este diagrama das dependências dos componentes e os escopos correspondentes:


O que temos aqui


AppComponent , que absorveu absolutamente todas as dependências com escopos Singleton . Eu acho que quase todo mundo tem esse componente.

FeatureComponents . Cada recurso tinha seu próprio escopo e era um subcomponente de AppComponent ou um recurso sênior.
Vamos nos concentrar um pouco nos recursos. Primeiro de tudo, o que é um recurso? Vou tentar com minhas próprias palavras. Um recurso é um módulo de programa independente máximo e logicamente completo que resolve um problema específico do usuário, com dependências externas claramente definidas e que é relativamente fácil de usar novamente em outro programa. Os recursos podem ser grandes e pequenos. Recursos podem conter outros recursos. E eles também podem usar ou executar outros recursos através de dependências externas claramente definidas. Se usarmos nosso aplicativo (Kaspersky Internet Security para Android), os recursos poderão ser considerados antivírus, anti-roubo, etc.

ScreenComponents . Um componente para uma tela específica, também com seu próprio escopo e também sendo um subcomponente do componente de recurso correspondente.

Agora uma lista de "por que razão"


Por que subcomponentes?
Nas dependências de componentes, não gostei do fato de que um componente pode depender de vários componentes ao mesmo tempo, o que, a meu ver, poderia levar ao caos dos componentes e de suas dependências. Quando você tem um relacionamento estrito de um para muitos (um componente e seus subcomponentes), fica mais seguro e mais óbvio. Além disso, por padrão, todas as dependências do pai estão disponíveis para o subcomponente, o que também é mais conveniente.

Por que existe um escopo para todos os recursos?
Como, então, procedi das considerações de que cada recurso é algum tipo de seu próprio ciclo de vida, que não é o mesmo que o de outros, por isso é lógico criar seu próprio escopo. Há mais um ponto para muitas coisas mesquinhas, que mencionarei abaixo.

Como estamos falando do Dagger 2 no contexto do Clean, também mencionarei o momento em que as dependências foram entregues. Apresentadores, Interatores, Repositórios e outras classes auxiliares de dependências foram fornecidos pelo construtor. Nos testes, substituímos stubs ou moki pelo construtor e testamos silenciosamente nossa classe.
O fechamento do gráfico de dependência geralmente ocorre em atividades, fragmentos, às vezes receptores e serviços, em geral, nos locais raiz dos quais o androide pode iniciar algo. A situação clássica é quando uma atividade é criada para um recurso, o componente do recurso é iniciado e vive na atividade e no próprio recurso há três telas implementadas em três fragmentos.

Então, tudo parece ser lógico. Mas como sempre, a vida faz seus próprios ajustes.

Problemas de vida


Tarefa de exemplo


Vejamos um exemplo simples de nosso aplicativo. Temos um recurso de scanner e um recurso antifurto. Ambos os recursos têm um botão de compra estimado. Além disso, “Comprar” não é apenas enviar uma solicitação, mas também muitas lógicas diferentes relacionadas ao processo de compra. Essa é uma lógica puramente comercial, com algumas caixas de diálogo para compra imediata. Ou seja, existe um recurso separado para si - compra. Assim, em dois recursos, precisamos usar o terceiro recurso.
Do ponto de vista da interface do usuário e da navegação, temos a seguinte imagem. A tela principal é iniciada, na qual dois botões:


Ao clicar nesses botões, chegamos ao recurso do Scanner ou Anti-Theft.
Considere o recurso do scanner:


Ao clicar em "Iniciar verificação antivírus", algum tipo de trabalho de verificação é realizado. Ao clicar em "Compre-me", queremos apenas comprar, ou seja, selecionamos o recurso Compras, mas, por "Ajuda", obtemos uma tela simples com uma ajuda.
O recurso do Anti-Theft parece quase o mesmo.

Soluções potenciais


Como implementamos este exemplo em termos de DI? Existem várias opções.

Primeira opção


Selecione um recurso de compra como um componente independente que depende apenas do AppComponent .


Mas então nos deparamos com o problema: como injetar dependências de dois gráficos diferentes (componentes) em uma classe ao mesmo tempo? Somente através de muletas sujas, o que, é claro, é uma coisa dessas.

Segunda opção


Selecionamos o recurso de compra no subcomponente, que depende do AppComponent. E os componentes do Scanner e do Anti-Theft podem ser feitos subcomponentes a partir do componente Compra.


Mas, como você entende, pode haver muitas situações semelhantes nos aplicativos. E isso significa que a profundidade das dependências dos componentes pode ser realmente enorme e complexa. E esse gráfico será mais confuso do que tornar seu aplicativo mais coerente e compreensível.

Terceira opção


Selecionamos o recurso de compra não em um componente separado, mas em um módulo Dagger separado . Duas maneiras são possíveis ainda mais.

Primeira maneira
Vamos adicionar os recursos dos escopos Singleton a todas as dependências e conectar-se ao AppComponent .


A opção é popular, mas leva ao inchaço do AppComponent . Como resultado, ele aumenta de tamanho, contém todas as classes de aplicativos, e todo o ponto de usar o Dagger se resume a entrega mais conveniente de dependências para as classes - através de campos ou construtor, e não através de singletones. Em princípio, isso é DI, mas sentimos falta de pontos arquitetônicos, e acontece que todos sabem sobre todos.
Em geral, no início do caminho, se você não sabe onde atribuir uma classe a qual recurso, é mais fácil torná-la global. Isso é bastante comum quando você trabalha com o Legacy e tenta introduzir pelo menos algum tipo de arquitetura, além de ainda não conhecer todo o código. E ali, de fato, os olhos se arregalam e essas ações são justificadas. O erro é que, quando tudo está mais ou menos iminente, ninguém quer enfrentar esse AppComponent .

Segunda via
Isso é uma redução de todos os recursos em um único escopo, por exemplo, PerFeature .


Em seguida, podemos conectar o módulo Dagger do Shopping aos componentes necessários de maneira fácil e simples.
Parece conveniente. Mas, arquitetonicamente, não está isolado. Os recursos do scanner e do Anti-Theft sabem absolutamente tudo sobre o recurso Compra, todos os seus miudezas. Inadvertidamente, algo pode estar envolvido. Ou seja, o recurso de compra não possui uma API clara, a fronteira entre os recursos é embaçada, não há contrato claro. Isso é ruim. Bem, em multi-modular, o gredloid será difícil mais tarde.

Dor arquitetônica


Honestamente, por muito tempo usei a terceira opção, a primeira maneira . Essa foi uma medida necessária quando começamos a transferir gradualmente nosso legado para os trilhos normais. Mas, como mencionei, com essa abordagem, seus recursos começam a se misturar um pouco. Todos podem saber sobre cada um, sobre os detalhes da implementação e isso para todos. E o inchaço AppComponent indicou claramente que algo precisa ser feito.
Aliás, a terceira opção ajudaria no descarregamento do AppComponent . Mas o conhecimento de implementações e recursos de mixagem não vai a lugar algum. Bem, é claro, reutilizar recursos entre aplicativos seria muito difícil.

Conclusões intermediárias


Então, o que queremos no final? Que problemas queremos resolver? Vamos direto ao ponto, começando pelo DI e passando para a arquitetura:

  • Um mecanismo de DI conveniente que permite usar recursos em outros recursos (em nosso exemplo, queremos usar o recurso Compras no Scanner e Anti-Theft), sem muletas e dores.
  • O AppComponent mais fino.
  • Os recursos não devem estar cientes das implementações de outros recursos.
  • Por padrão, os recursos não devem ser acessíveis a ninguém. Quero ter algum tipo de mecanismo de controle estrito.
  • É possível atribuir o recurso a outro aplicativo com um número mínimo de gestos.
  • Uma transição lógica para a multi-modularidade e as melhores práticas para essa transição.

Eu falei especificamente sobre multi-modularidade apenas no final. Vamos alcançá-la, não vamos nos antecipar.

"Viver de uma nova maneira"


Agora tentaremos implementar gradualmente a lista de desejos acima.
Vamos lá!

Aprimoramentos de DI


Vamos começar com o mesmo DI.

Recusa de um grande número de escopos


Como escrevi acima, antes de minha abordagem era esta: para cada recurso, seu próprio escopo. De fato, não há lucros especiais com isso. Tenha apenas um grande número de escopos e uma certa quantidade de dor de cabeça.
Essa cadeia é suficiente: Singleton - PerFeature - PerScreen .

Abandono de subcomponentes em favor de dependências de componentes


Já é um ponto mais interessante. Com os Subcomponentes, você tem uma hierarquia aparentemente mais rígida, mas ao mesmo tempo possui mãos completamente atadas e não há como manobrar pelo menos de alguma maneira. Além disso, o AppComponent conhece todos os recursos e você também recebe uma enorme classe DaggerAppComponent gerada.
Com as dependências de componentes, você obtém uma vantagem super bacana. Nas dependências de componentes, você pode especificar não componentes, mas interfaces limpas (graças a Denis e Volodya). Graças a isso, você pode substituir as implementações de interface que desejar, o Dagger comerá tudo. Mesmo se o componente com o mesmo escopo for esta implementação:
@Component( dependencies = FeatureDependencies.class, modules = FeatureModule.class ) @PerFeature public abstract class FeatureComponent { // ... } public interface FeatureDependencies { SomeDependency someDependency(); } @Component( modules = AnotherFeatureModule.class ) @PerFeature public abstract class AnotherFeatureComponent implements FeatureDependencies { // ... } 


De aprimoramentos DI a aprimoramentos arquitetônicos


Vamos repetir a definição de recursos. Um recurso é um módulo de programa independente máximo logicamente completo que resolve um problema específico do usuário, com dependências externas claramente definidas e que é relativamente fácil de reutilizar em outro programa. Uma das principais expressões na definição de um recurso é "com dependências externas claramente definidas". Portanto, vamos descrever tudo o que queremos do mundo exterior para os recursos, descreveremos em uma interface especial.
Aqui, digamos, a interface de dependência externa do recurso Shopping:
 public interface PurchaseFeatureDependencies { HttpClientApi httpClient(); } 

Ou a interface de dependência externa do recurso Scanner:
 public interface ScannerFeatureDependencies { DbClientApi dbClient(); HttpClientApi httpClient(); SomeUtils someUtils(); //       PurchaseInteractor purchaseInteractor(); } 

Como já mencionado na seção sobre DI, as dependências podem ser implementadas por qualquer pessoa e, como você gosta, são interfaces puras, e nossos recursos são liberados desse conhecimento extra.

Outro componente importante de um recurso "puro" é a presença de uma API clara, pela qual o mundo exterior pode acessar o recurso.
Aqui estão os recursos da API do Shopping:
 public interface PurchaseFeatureApi { PurchaseInteractor purchaseInteractor(); } 

Ou seja, o mundo exterior pode obter um PurchaseInteractor e tentar fazer uma compra através dele. Na verdade, acima, vimos que o Scanner precisava de um PurchaseInteractor para concluir a compra.

E aqui estão os recursos da API do scanner:
 public interface ScannerFeatureApi { ScannerStarter scannerStarter(); } 

E imediatamente trago a interface e a implementação do ScannerStarter :
 public interface ScannerStarter { void start(Context context); } @PerFeature public class ScannerStarterImpl implements ScannerStarter { @Inject public ScannerStarterImpl() { } @Override public void start(Context context) { Class<?> cls = ScannerActivity.class; Intent intent = new Intent(context, cls); intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(intent); } } 

É mais interessante aqui. O fato é que o Scanner e o Anti-Theft são recursos bastante fechados e isolados. No meu exemplo, esses recursos são lançados em atividades separadas, com navegação própria etc. Isso é suficiente para simplesmente iniciar a atividade aqui. Atividade morre - o recurso morre. Você pode trabalhar com o princípio de "Atividade única" e, através dos recursos da API, passar, digamos, um FragmentManager e algum retorno de chamada através do qual o recurso relata que foi concluído. Existem muitas variações.
Também podemos dizer que temos o direito de considerar recursos como Scanner e Anti-Theft como aplicativos independentes. Ao contrário do recurso da Compra, que é um recurso adicional a algo e por si só, ele de alguma forma não existe. Sim, é independente, mas é um complemento lógico para outros recursos.

Como você pode imaginar, deve haver algum ponto que conecte os recursos, sua implementação e os recursos necessários da dependência. Este ponto é o componente Dagger.
Um exemplo de um componente de recurso do Scanner:
 @Component(modules = { ScannerFeatureModule.class, ScreenNavigationModule.class // ScannerFeatureDependencies - api    }, dependencies = ScannerFeatureDependencies.class) @PerFeature // ScannerFeatureApi - api   public abstract class ScannerFeatureComponent implements ScannerFeatureApi { private static volatile ScannerFeatureComponent sScannerFeatureComponent; //   public static ScannerFeatureApi initAndGet( ScannerFeatureDependencies scannerFeatureDependencies) { if (sScannerFeatureComponent == null) { synchronized (ScannerFeatureComponent.class) { if (sScannerFeatureComponent == null) { sScannerFeatureComponent = DaggerScannerFeatureComponent.builder() .scannerFeatureDependencies(scannerFeatureDependencies) .build(); } } } return sScannerFeatureComponent; } //           public static ScannerFeatureComponent get() { if (sScannerFeatureComponent == null) { throw new RuntimeException( "You must call 'initAndGet(ScannerFeatureDependenciesComponent scannerFeatureDependenciesComponent)' method" ); } return sScannerFeatureComponent; } //    (   ) public void resetComponent() { sScannerFeatureComponent = null; } public abstract void inject(ScannerActivity scannerActivity); //         Moxy public abstract ScannerScreenComponent scannerScreenComponent(); } 


Eu acho que nada de novo para você.

Transição para multi-modularidade


Então, você e eu fomos capazes de definir claramente os limites do recurso através da API de suas dependências e da API externa. Também descobrimos como pôr tudo em prática no Dagger. E agora chegamos ao próximo passo lógico e interessante - a divisão em módulos.
Abra um caso de teste imediatamente - será mais fácil.
Vejamos a foto em geral:

E veja a estrutura do pacote do exemplo:

Agora vamos conversar com cuidado sobre cada item.

Primeiro de tudo, vemos quatro grandes blocos: Aplicativo , API , Impl e Utils . Nas APIs , Impl e Utils, você pode observar que todos os módulos iniciam no núcleo ou no recurso . Vamos falar sobre eles primeiro.

Separação entre núcleo e recurso


Divido todos os módulos em duas categorias: núcleo e recurso .
No recurso , como você deve ter adivinhado, nossos recursos. No núcleo, existem coisas como utilitários, trabalhando com uma rede, bancos de dados etc. Mas não há interfaces de recursos lá. E o núcleo não é um monólito. Sou a favor de dividir o módulo principal em partes lógicas e contra carregá-lo com algumas outras interfaces de recursos.
No nome do módulo, primeiro escreva o núcleo ou o recurso . Além disso, o nome do módulo é um nome lógico ( scanner , rede , etc.).

Agora, cerca de quatro grandes blocos: Aplicativo, API, Impl e Utils


API
Cada recurso ou módulo principal é dividido em API e Impl . A API contém uma API externa através da qual você pode acessar um recurso ou núcleo. Só isso, e nada mais:

Além disso, o módulo api não sabe nada sobre ninguém, é um módulo absolutamente isolado.

Utils
A única exceção à regra acima pode ser considerada algumas coisas completamente utilitárias, que não fazem sentido entrar na API e na implementação.

Impl
Aqui temos uma subdivisão em core-impl e feature-impl .
Os módulos no core-impl também são completamente independentes. Sua única dependência é o módulo api . Por exemplo, dê uma olhada no build.gradle do módulo core-db-impl :
 // bla-bla-bla dependencies { implementation project(':core-db-api') // bla-bla-bla } 

Agora sobre feature-impl . Já existe a maior parte da lógica da aplicação. Os módulos do grupo feature-impl podem conhecer os módulos da API ou do grupo Utils , mas certamente não sabem nada sobre os outros módulos do grupo Impl .
Como lembramos, todas as dependências externas do recurso são acumuladas nas dependências externas. Por exemplo, para um recurso do Scan, esta API é a seguinte:
 public interface ScannerFeatureDependencies { // core-db-api DbClientApi dbClient(); // core-network-api HttpClientApi httpClient(); // core-utils SomeUtils someUtils(); // feature-purchase-api PurchaseInteractor purchaseInteractor(); } 

Assim, o build.gradle feature-scanner-impl será assim:
 // bla-bla-bla dependencies { implementation project(':core-utils') implementation project(':core-network-api') implementation project(':core-db-api') implementation project(':feature-purchase-api') implementation project(':feature-scanner-api') // bla-bla-bla } 

Você pode perguntar: por que a API de dependências externas não está no módulo da API? O fato é que este é um detalhe da implementação. Ou seja, é uma implementação específica que precisa de algumas dependências específicas. Para a dependência, o scanner da API está aqui:


Pequeno retiro arquitetônico
Vamos digerir tudo o que foi dito acima e entender por nós mesmos alguns pontos arquiteturais relacionados aos recursos -...- impl-modules e suas dependências em outros módulos.
Conheci dois dos padrões de mapeamento de dependência mais populares de um módulo:

  • Um módulo pode saber sobre qualquer um. Não há regras. Não há nada para comentar.
  • Os módulos conhecem apenas o módulo principal . E no módulo principal, todas as interfaces de todos os recursos estão concentradas. Essa abordagem não é muito atraente para mim, pois existe o risco de transformar o núcleo em outro depósito de lixo. Além disso, se quisermos transferir nosso módulo para outro aplicativo, precisaremos copiar essas interfaces para outro aplicativo e também colocá-lo no núcleo . A cópia-pasta estúpida de interfaces por si só não é muito atraente e reutilizável no futuro, quando as interfaces puderem ser atualizadas.

Em nosso exemplo, defendo o conhecimento da API e apenas dos módulos da API (bem, utils-groups). Os recursos não sabem nada sobre implementações.

Mas acontece que os recursos podem conhecer outros recursos (via API, é claro) e executá-los. Poderia ser uma bagunça?
Observação justa. É difícil elaborar algumas regras super claras. Deve haver uma medida em tudo. Já abordamos esse problema um pouco acima, dividindo os recursos em independentes (Scanner e Anti-Theft) - completamente independentes e separados, e os recursos "em contexto", ou seja, eles sempre são lançados como parte de algo (compra) e geralmente implicam lógica de negócios sem interface do usuário. É por isso que o Scanner e o Anti-Theft sabem sobre compras.
Outro exemplo Imagine que no Anti-Theft exista uma limpeza de dados, ou seja, limpe absolutamente todos os dados do telefone. Há muita lógica de negócios, ela é completamente isolada. Portanto, é lógico selecionar limpar dados como um recurso separado. E então o garfo. Se os dados de limpeza sempre são iniciados apenas no Anti-Theft e estão sempre presentes no Anti-Theft, é lógico que o Anti-Theft saiba sobre os dados de limpeza e os execute por conta própria. E o módulo de acumulação, app, só saberia sobre o Anti-Theft. Mas se os dados de limpeza podem começar em outro lugar ou nem sempre estão presentes no Anti-Theft (ou seja, podem ser diferentes em aplicativos diferentes), é lógico que o Anti-Theft não conheça esse recurso e apenas diga algo externo (via Roteador, por meio de um retorno de chamada, não importa) que o usuário tenha pressionado esse botão, e o que iniciar com ele já é um recurso do Anti-Theft para o consumidor (um aplicativo específico, aplicativo específico).

Há também uma pergunta interessante sobre a transferência de recursos para outro aplicativo. Se, por exemplo, queremos transferir o Scanner para outro aplicativo, também devemos transferir além dos módulos : feature-scanner-api e : feature-scanner-impl e os módulos dos quais o Scanner depende ( : core-utils,: core-network- api ,: core-db-api ,: feature-purchase-api ).
Sim mas! Primeiro, todos os seus módulos api são completamente independentes, e existem apenas interfaces e modelos de dados. Sem lógica. E esses módulos são claramente separados logicamente e : core-utils geralmente é um módulo comum para todas as aplicações.
Em segundo lugar, você pode coletar api-modules na forma de aar e entregá-los através do maven para outro aplicativo ou pode conectá-los na forma de um submódulo de gig. Mas você terá controle de versão, haverá controle, haverá integridade.
Assim, a reutilização do módulo (mais precisamente, o módulo de implementação) em outro aplicativo parece muito mais simples, mais clara e mais segura.

Aplicação


Parece que temos uma imagem esbelta e compreensível com recursos, módulos, suas dependências e isso é tudo. Agora chegamos ao clímax - é uma combinação de APIs e suas implementações, substituindo todas as dependências necessárias, etc., mas do ponto de vista dos módulos Gredloi. O ponto de conexão é geralmente o próprio aplicativo .
A propósito, em nosso exemplo, esse ponto ainda é um exemplo de recurso-scanner . A abordagem acima permite que você execute cada um dos seus recursos como um aplicativo separado, o que economiza bastante o tempo de construção durante o desenvolvimento ativo. Beleza!

Para começar, vamos considerar como tudo através do aplicativo acontece com o exemplo do Scanner já amado.
Recupere rapidamente o recurso:
Dependências externas sci api é:
 public interface ScannerFeatureDependencies { // core-db-api DbClientApi dbClient(); // core-network-api HttpClientApi httpClient(); // core-utils SomeUtils someUtils(); // feature-purchase-api PurchaseInteractor purchaseInteractor(); } 

Portanto : feature-scanner-impl depende dos seguintes módulos:
 // bla-bla-bla dependencies { implementation project(':core-utils') implementation project(':core-network-api') implementation project(':core-db-api') implementation project(':feature-purchase-api') implementation project(':feature-scanner-api') // bla-bla-bla } 


Com base nisso, podemos criar um componente Dagger que implementa API de dependências externas:
 @Component(dependencies = { CoreUtilsApi.class, CoreNetworkApi.class, CoreDbApi.class, PurchaseFeatureApi.class }) @PerFeature interface ScannerFeatureDependenciesComponent extends ScannerFeatureDependencies { } 

Coloquei essa interface no ScannerFeatureComponent por conveniência:
 @Component(modules = { ScannerFeatureModule.class, ScreenNavigationModule.class }, dependencies = ScannerFeatureDependencies.class) @PerFeature public abstract class ScannerFeatureComponent implements ScannerFeatureApi { // bla-bla-bla @Component(dependencies = { CoreUtilsApi.class, CoreNetworkApi.class, CoreDbApi.class, PurchaseFeatureApi.class }) @PerFeature interface ScannerFeatureDependenciesComponent extends ScannerFeatureDependencies { } } 


Agora o aplicativo. O aplicativo conhece todos os módulos de que precisa ( core, feature-, api, impl ):
 // bla-bla-bla dependencies { implementation project(':core-utils') implementation project(':core-db-api') implementation project(':core-db-impl') implementation project(':core-network-api') implementation project(':core-network-impl') implementation project(':feature-scanner-api') implementation project(':feature-scanner-impl') implementation project(':feature-antitheft-api') implementation project(':feature-antitheft-impl') implementation project(':feature-purchase-api') implementation project(':feature-purchase-impl') // bla-bla-bla } 

Em seguida, crie uma classe auxiliar. Por exemplo, FeatureProxyInjector . Isso ajudará a inicializar corretamente todos os componentes, e é através dessa classe que iremos abordar os recursos. Vamos ver como o componente do recurso Scanner é inicializado:
 public class FeatureProxyInjector { // another... public static ScannerFeatureApi getFeatureScanner() { return ScannerFeatureComponent.initAndGet( DaggerScannerFeatureComponent_ScannerFeatureDependenciesComponent.builder() .coreDbApi(CoreDbComponent.get()) .coreNetworkApi(CoreNetworkComponent.get()) .coreUtilsApi(CoreUtilsComponent.get()) .purchaseFeatureApi(featurePurchaseGet()) .build() ); } } 

Externamente, fornecemos a interface do recurso ( ScannerFeatureApi ) e, no interior, inicializamos todo o gráfico de dependência de implementação (através do método ScannerFeatureComponent.initAndGet (...) ).
DaggerPurchaseComponent_PurchaseFeatureDependenciesComponent é a implementação do PurchaseFeatureDependenciesComponent gerado pelo Dagger, sobre o qual falamos acima, onde substituímos a implementação de api-modules no construtor.
Isso é tudo mágico. Veja o exemplo novamente.

Falando em exemplo . Por exemplo, também devemos satisfazer todas as dependências externas : feature-scanner-impl . Mas, como esse é um exemplo, podemos substituir classes fictícias.
Como ficará:
 //     ScannerFeatureDependencies public class ScannerFeatureDependenciesFake implements ScannerFeatureDependencies { @Override public DbClientApi dbClient() { return new DbClientFake(); } @Override public HttpClientApi httpClient() { return new HttpClientFake(); } @Override public SomeUtils someUtils() { return CoreUtilsComponent.get().someUtils(); } @Override public PurchaseInteractor purchaseInteractor() { return new PurchaseInteractorFake(); } } //  -  Application-   public class ScannerExampleApplication extends Application { @Override public void onCreate() { super.onCreate(); ScannerFeatureComponent.initAndGet( // ,     =) new ScannerFeatureDependenciesFake() ); } } 

E o próprio recurso Scanner, por exemplo, é iniciado por meio do manifesto, para não bloquear atividades vazias adicionais:
 <?xml version="1.0" encoding="utf-8"?> <manifest xmlns:android="http://schemas.android.com/apk/res/android" package="com.example.scanner_example"> <application android:name=".ScannerExampleApplication" android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> <!--   --> <activity android:name="com.example.scanner.presentation.view.ScannerActivity"> <intent-filter> <action android:name="android.intent.action.MAIN" /> <category android:name="android.intent.category.LAUNCHER" /> </intent-filter> </activity> </application> </manifest> 


Algoritmo de transição da monomodularidade para a multimodularidade


A vida é uma coisa dura. E a realidade é que todos trabalhamos com o Legacy. Se alguém agora está vendo um novo projeto, onde você pode abençoar tudo imediatamente, então eu o invejo, mano. Mas esse não é o meu caso, e esse cara também está errado =).

Como traduzir sua aplicação em multi-módulo? Eu ouvi principalmente sobre duas opções.
Primeiro. Particionando o aplicativo aqui e agora. É verdade que seu projeto não pode ser montado por um mês ou dois =).
Segundo. Tente retirar os recursos gradualmente. Mas, ao mesmo tempo, todos os tipos de dependências desses recursos se estendem. E aqui começa a diversão. O código de dependência pode extrair outro código, a coisa toda migra para o módulo comum , para o módulo principal e vice-versa, e assim por diante. Como resultado, puxar um recurso pode implicar o trabalho com outra boa metade do aplicativo. E, novamente, no início, seu projeto não terá um período decente de tempo.

Defendo a transferência gradual do aplicativo para a multi-modularidade, pois em paralelo ainda precisamos ver novos recursos. A idéia principal é que, se o seu módulo precisar de algumas das dependências, você não deve arrastar imediatamente esse código para os módulos também . Vejamos o algoritmo de remoção do módulo usando o Scanner como exemplo:

  • Crie recursos de API, coloque-o em um novo módulo de API. Ou seja, para criar totalmente um módulo : feature-scanner-api com todas as interfaces.
  • Criar : feature-scanner-impl . Transfira fisicamente todo o código relacionado ao recurso para este módulo. Tudo o que seu recurso depende, o estúdio destacará imediatamente.
  • Identifique dependências de recursos externos. Crie interfaces apropriadas. Essas interfaces são divididas em api-modules lógicos. Ou seja, em nosso exemplo, crie os módulos : core-utils ,: core-network-api,: core-db-api ,: feature-purchase-api com as interfaces correspondentes.
    Aconselho que você invista imediatamente no nome e no significado dos módulos. É claro que, com o tempo, as interfaces e os módulos podem ser embaralhados, recolhidos, etc., isso é normal.
  • Crie API de dependências externas ( ScannerFeatureDependencies ). Dependendo : feature-scanner-impl registra api-modules recém-criados.
  • Como temos todo o legado no aplicativo , eis o que fazemos. No aplicativo, conectamos todos os módulos criados para o recurso (módulo api do recurso, módulo impl de recurso, módulos api da dependência externa do recurso).
    Ponto super importante . Em seguida, no aplicativo, criamos implementações de todas as interfaces de dependência de recursos necessárias (Scanner em nosso exemplo). Essas implementações provavelmente serão apenas proxies das dependências de sua API para a implementação atual dessas dependências no projeto. Ao inicializar um componente de recurso, substitua os dados de implementação.
    Difícil em palavras, quer um exemplo? Então ele já é! De fato, algo semelhante já existe no recurso-scanner-exemplo. Mais uma vez, darei um código ligeiramente adaptado:
     //     ScannerFeatureDependencies  app- public class ScannerFeatureDependenciesLegacy implements ScannerFeatureDependencies { @Override public DbClientApi dbClient() { return new DbClientLegacy(); } @Override public HttpClientApi httpClient() { // -  // ,      return NetworkFabric.createHttpClientLegacy(); } @Override public SomeUtils someUtils() { return new SomeUtils(); } @Override public PurchaseInteractor purchaseInteractor() { return new PurchaseInteractorLegacy(); } } //  -   ScannerFeatureComponent.initAndGet( new ScannerFeatureDependenciesLegacy() ); 

    Ou seja, a principal mensagem aqui é essa. Deixe todo o código externo necessário para o recurso viver no aplicativo , como ele fez. E o próprio recurso já funcionará com ele da maneira normal, através da API (significando dependências da API e módulos de API). No futuro, a implementação passará gradualmente para os módulos. Porém, evitaremos um jogo sem fim arrastando do módulo para o módulo o código externo necessário para o recurso. Podemos avançar em iterações claras!
  • Lucro

Aqui está um algoritmo tão simples, mas funcional, que permite avançar em direção ao seu objetivo, passo a passo.

Dicas adicionais


Quão grande / pequeno devem ser os recursos?
Tudo depende do projeto, etc. Porém, no início da transição para a multi-modularidade, aconselho que você se divida em pedaços grandes. Além disso, se necessário, você selecionará mais módulos desses módulos. Mas não moa.Não faça isso: uma / várias classes = um módulo.

Pureza do módulo de aplicativo
Ao alternar para o aplicativo multi-módulo , teremos bastante e, a partir daí, seus recursos destacados sofrerão uma contração. É possível que, durante o trabalho, você tenha que fazer alterações nesse legado, finalizar algo lá, bem, ou apenas ter um release, e não esteja preparado para os cortes nos módulos. Nesse caso, você deseja que o aplicativo e, com ele, todo o legado, conheça os recursos destacados apenas por meio da API, sem conhecimento sobre as implementações. Mas aplicativo , na verdade, combina API- e Impl-ins , mas porque o aplicativo sabe tudo.
Nesse caso, você pode criar um módulo especial: adapter , que será apenas o ponto de conexão da API e impl, e o aplicativo saberá apenas sobre a API. Eu acho que a ideia é clara. Você pode ver um exemplo na ramificação clean_app . Acrescentarei que, com o Moxy, ou melhor, com o MoxyReflector, há alguns problemas ao dividir em módulos, por causa dos quais tive que criar outro módulo adicional : stub-moxy-java . Uma pitada leve de magia, onde sem ela.
A única alteração. Isso funcionará apenas quando seu recurso e dependências relacionadas já estiverem fisicamente transferidos para outros módulos. Se você criou um recurso, mas as dependências ainda permanecem no aplicativo , como no algoritmo acima, isso não funcionará.

Posfácio


O artigo ficou bastante grande. Mas espero que realmente ajude você na luta contra a monomodularidade, entenda como deve ser e como fazer amizade com o DI.
Se você estiver interessado em mergulhar em um problema com a velocidade de construção, como medir tudo, recomendo os relatórios de Denis Neklyudov e Zhenya Suvorov (Mobius 2018 Piter, os vídeos ainda não estão disponíveis ao público).
Sobre Gradle. A diferença entre API e implementação em Gradle foi perfeitamente demonstrada por Vova Tagakov . Se você deseja reduzir o padrão de vários módulos, pode começar aqui com este artigo .
Terei o maior prazer em comentários, correções, bem como gostos! Todo o código limpo!

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


All Articles