Padrões de modularização comuns

Não existe uma única estratégia de modularização adequada para todos os projetos. Devido à natureza flexível do Gradle, há poucas restrições para a organização de um projeto. Esta página apresenta um panorama de algumas regras gerais e padrões comuns que você pode empregar ao desenvolver apps Android multimódulo.

Princípio de coesão alta e acoplamento baixo

Uma maneira de caracterizar uma base de código modular seria usando as propriedades de acoplamento e coesão. O acoplamento mede o grau de dependência dos módulos entre si. Nesse contexto, a coesão mede como os elementos de um único módulo são funcionalmente relacionados. Como regra geral, você precisa se esforçar para ter um acoplamento baixo e uma coesão alta:

  • O acoplamento baixo significa que os módulos precisam ter o máximo de independência possível, de modo que as mudanças em um módulo tenham zero ou pouco impacto nos outros. Os módulos não devem ter conhecimento sobre o funcionamento interno uns dos outros.
  • Coesão alta significa que os módulos têm que abranger um conjunto de códigos que atua como um sistema. Eles precisam ter responsabilidades definidas de forma clara e ficar dentro dos limites de um determinado conhecimento do domínio. Vamos usar um aplicativo de e-book como exemplo. Pode ser inadequado combinar códigos relacionados a livros e pagamentos no mesmo módulo desse app, porque esses são dois domínios funcionais diferentes.

Tipos de módulos

A maneira de organizar seus módulos depende principalmente da arquitetura do app. Veja abaixo alguns tipos comuns de módulos que você pode introduzir no seu app enquanto segue nossa arquitetura recomendada.

Módulos de dados

Um módulo de dados geralmente contém um repositório, fontes de dados e classes de modelo. As três principais responsabilidades de um módulo de dados são:

  1. Encapsular todos os dados e a lógica de negócios de um determinado domínio: cada módulo de dados precisa ser responsável por processar dados que representam um domínio específico. Vários tipos de dados podem ser processados, contanto que eles estejam relacionados.
  2. Expor o repositório como uma API externa: a API pública de um módulo de dados precisa ser um repositório, porque é responsável por expor os dados ao restante do app.
  3. Ocultar todos os detalhes de implementação e fontes de dados de fora: as fontes de dados só podem ser acessadas por repositórios do mesmo módulo. Elas continuam ocultas para o lado externo. Para aplicar isso, use a palavra-chave de visibilidade private ou internal do Kotlin.
Figura 1. Exemplos de módulos de dados e o conteúdo deles.

Módulos de recursos

Um recurso é uma parte isolada da funcionalidade de um app que geralmente corresponde a uma tela ou várias intimamente relacionadas, como um fluxo de inscrição ou finalização de compra. Caso o app tenha uma navegação na barra da parte de baixo da tela, é provável que cada destino seja um recurso.

Figura 2. Cada guia desse aplicativo pode ser definida como um recurso.

Os recursos estão associados a telas ou destinos no seu app. Portanto, eles provavelmente têm uma interface associada e um ViewModel para processar a lógica e o estado. Um recurso não precisa estar limitado a uma única visualização ou destino de navegação. Os módulos de recursos dependem dos módulos de dados.

Figura 3. Exemplos de módulos de recursos e o conteúdo deles.

Módulos de apps

Os módulos do app são um ponto de entrada para ele. Eles dependem de módulos de recursos e geralmente fornecem navegação raiz. Um único módulo do app pode ser compilado para vários binários diferentes graças às variantes de build.

Figura 4. Gráfico de dependências para os módulos de variação de produto *Demo* (demonstração) e *Full* (completo).

Caso seu app seja destinado a vários tipos de dispositivo, como automóveis, wearables ou TVs, defina um módulo de app para cada um deles. Isso ajuda a separar as dependências específicas da plataforma.

Figura 5. Gráfico de dependências do app para Wear.

Módulos comuns

Os módulos comuns, também conhecidos como módulos principais, contêm códigos que outros módulos usam com frequência. Eles diminuem a redundância e não representam nenhuma camada específica na arquitetura de um app. Estes são exemplos de módulos comuns:

  • Módulo da IU: se você usa elementos de IU personalizados ou um branding elaborado no seu app, considere encapsular sua coleção de widgets em um módulo para que todos os recursos possam reutilizar. Isso pode ajudar a deixar a IU consistente em diferentes recursos. Por exemplo, no caso de um tema centralizado, você poderá evitar uma refatoração cansativa quando houver uma mudança de marca.
  • Módulo de análise: o rastreamento geralmente é determinado por requisitos de negócios com pouca consideração à arquitetura do software. Os rastreadores de análise geralmente são usados em vários componentes não relacionados. Se esse for o caso, talvez seja uma boa ideia ter um módulo de análise dedicado.
  • Módulo de rede: quando vários módulos exigirem uma conexão de rede, considere um módulo dedicado ao fornecimento de um cliente HTTP. Isso é especialmente útil quando o cliente exige uma configuração personalizada.
  • Módulo de utilitário: os utilitários, também conhecidos como auxiliares, geralmente são pequenos códigos que são reutilizados no aplicativo. Exemplos de utilitários incluem auxiliares de teste, uma função de formatação de moeda, um validador de e-mail ou um operador personalizado.

Módulos de teste

Os módulos de teste do Android são usados apenas para testes. Os módulos contêm código, recursos e dependências que são necessários apenas para executar testes e que não são necessários durante a execução do aplicativo. Os módulos de teste são criados para separar o código específico do teste do aplicativo principal, facilitando o gerenciamento e a manutenção do código.

Casos de uso para módulos de teste

Os exemplos abaixo mostram situações em que a implementação de módulos de teste pode ser especialmente útil:

  • Código de teste compartilhado: se você tiver vários módulos no projeto e parte do código se aplicar a mais de um módulo, crie um módulo de teste para compartilhar o código. Isso pode ajudar a reduzir a duplicação e facilitar a manutenção do código de teste. O código de teste compartilhado pode incluir classes ou funções utilitárias, como declarações ou matchers personalizados, e dados de teste, como respostas JSON simuladas.

  • Configurações de build mais limpas: os módulos de teste permitem que você tenha configurações de build mais limpas, já que elas podem ter o próprio arquivo build.gradle. Não é necessário colocar configurações relevantes apenas para testes no arquivo build.gradle do módulo do app.

  • Testes de integração: os módulos de teste podem ser usados para armazenar testes de integração que são usados para testar interações entre diferentes partes do app, incluindo a interface do usuário, lógica de negócios, solicitações de rede e consultas de bancos de dados.

  • Aplicativos de grande escala: os módulos de teste são especialmente úteis para aplicativos de grande escala com bases de código complexas e vários módulos. Nesses casos, os módulos de teste podem ajudar a melhorar a organização e a manutenção do código.

Figura 6. Os módulos de teste podem ser usados para isolar os módulos que, caso contrário, seriam dependentes uns dos outros.

Comunicação entre módulos

Os módulos raramente ficam totalmente separados. No geral, eles dependem de outros módulos e se comunicam com eles. É importante manter o acoplamento baixo mesmo quando os módulos trabalham juntos e trocam informações com frequência. Às vezes, a comunicação direta entre dois módulos não é recomendável, como no caso de restrições da arquitetura. Ela também pode ser impossível, como no caso de dependências cíclicas.

Figura 7. Uma comunicação direta e bidirecional entre os módulos não é possível devido a dependências cíclicas. Um módulo de mediação é necessário para coordenar o fluxo de dados entre dois outros módulos independentes.

Para resolver esse problema, você pode ter um terceiro módulo mediando (link em inglês) dois outros módulos. O módulo mediador pode detectar e encaminhar mensagens dos dois módulos conforme necessário. Em nosso app de exemplo, a tela de finalização de compra precisa saber qual livro comprar mesmo que o evento seja originado em uma tela separada que faça parte de um recurso diferente. Nesse caso, o mediador é o módulo proprietário do gráfico de navegação, geralmente um módulo de app. No exemplo, usamos a navegação para transmitir os dados do recurso da página inicial para o de finalização da compra utilizando o componente de navegação.

navController.navigate("checkout/$bookId")

O destino de finalização de compra recebe um ID como um argumento usado para buscar informações sobre o livro. Você pode usar o gerenciador de estado salvo para recuperar argumentos de navegação dentro do ViewModel de um recurso de destino.

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

Não transmita objetos como argumentos de navegação. Em vez disso, use IDs simples com os quais os recursos possam acessar e carregar aquilo que você quer da camada de dados. Dessa forma, você mantém o acoplamento baixo e não viola o princípio de única fonte de informações.

No exemplo abaixo, os dois módulos de recursos dependem do mesmo módulo de dados. Isso permite minimizar a quantidade de dados que o módulo mediador precisa encaminhar e mantém o acoplamento baixo entre os módulos. Em vez de transmitir objetos, os módulos precisam trocar IDs primitivos e carregar os recursos de um módulo de dados compartilhado.

Figura 8. Dois módulos de recursos que dependem de um módulo de dados compartilhado.

Inversão de dependência

A inversão de dependência é quando você organiza o código de forma que a abstração fique separada de uma implementação concreta.

  • Abstração: um contrato que define como os componentes ou módulos do aplicativo interagem entre si. Os módulos de abstração definem a API do sistema e contêm interfaces e modelos.
  • Implementação concreta: módulos que dependem do módulo de abstração e implementam o comportamento de uma abstração.

Os módulos que dependem do comportamento definido no módulo de abstração precisam depender apenas da abstração, e não das implementações específicas.

Figura 9. Em vez de módulos de alto nível diretamente dependentes de módulos de baixo nível, os de alto nível e de implementação dependem do módulo de abstração.

Exemplo

Imagine um módulo de recursos que precisa de um banco de dados para funcionar. O módulo de recurso não se preocupa com a forma como o banco de dados é implementado, seja ele local do Room ou uma instância remota do Firestore. Ele só precisa armazenar e ler os dados do aplicativo.

Para isso, o módulo de recurso depende do módulo de abstração, e não de uma implementação de banco de dados específica. Essa abstração define a API do banco de dados do app. Em outras palavras, ele define as regras para interagir com o banco de dados. Isso permite que o módulo de recursos use qualquer banco de dados sem precisar conhecer os detalhes de implementação subjacentes.

O módulo de implementação concreta fornece a implementação real das APIs definidas no módulo de abstração. Para fazer isso, o módulo de implementação também depende do módulo de abstração.

Injeção de dependência

Agora, você pode estar se perguntando como o módulo de recurso está conectado ao módulo de implementação. A resposta é injeção de dependência. O módulo de recurso não cria diretamente a instância de banco de dados necessária. Em vez disso, ele especifica as dependências necessárias. Essas dependências são então fornecidas externamente, geralmente no módulo do app.

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

Benefícios

Os benefícios de separar suas APIs e implementações são os seguintes:

  • Alternância: com uma separação clara dos módulos de API e implementação, você pode desenvolver várias implementações para a mesma API e alternar entre elas sem mudar o código que usa a API. Isso pode ser útil em cenários em que você quer fornecer diferentes recursos ou comportamentos em diferentes contextos. Por exemplo, uma implementação fictícia de teste em vez de uma implementação real para produção.
  • Desacoplamento: a separação significa que os módulos que usam abstrações não dependem de nenhuma tecnologia específica. Se você optar por mudar o banco de dados do Room para o Firestore mais tarde, será mais fácil porque as mudanças só vão acontecer no módulo específico que está fazendo o job (módulo de implementação) e não afetaria outros módulos usando a API do banco de dados.
  • Capacidade de teste: separar APIs das implementações delas pode facilitar bastante os testes. Você pode criar casos de teste nos contratos da API. Também é possível usar implementações diferentes para testar vários cenários e casos extremos, incluindo implementações simuladas.
  • Melhor desempenho do build: quando você separa uma API e a implementação dela em módulos diferentes, as mudanças no módulo de implementação não forçam o sistema de build a recompilar os módulos, dependendo do módulo da API. Isso acelera o tempo de build e aumenta a produtividade, principalmente em projetos grandes em que os tempos de build podem ser significativos.

Quando separar

É vantajoso separar suas APIs das implementações nos seguintes casos:

  • Recursos diversos: se você puder implementar partes do sistema de várias maneiras, uma API clara vai permitir a alternância de implementações diferentes. Por exemplo, você pode ter um sistema de renderização que usa OpenGL ou Vulkan, ou um sistema de faturamento que funciona com o Google Play ou sua API interna de faturamento.
  • Vários aplicativos: se você estiver desenvolvendo vários aplicativos com recursos compartilhados para diferentes plataformas, poderá definir APIs comuns e desenvolver implementações específicas por plataforma.
  • Equipes independentes: a separação permite que desenvolvedores ou equipes diferentes trabalhem simultaneamente em diferentes partes da base de código. Os desenvolvedores precisam se concentrar em entender os contratos de API e usá-los corretamente. Eles não precisam se preocupar com os detalhes de implementação de outros módulos.
  • Grande base de código: quando a base de código é grande ou complexa, separar a API da implementação torna o código mais gerenciável. Ele permite dividir a base do código em unidades mais granulares, compreensíveis e de fácil manutenção.

Como fazer a implementação?

Para implementar a inversão de dependências, siga estas etapas:

  1. Crie um módulo de abstração: esse módulo precisa conter APIs (interfaces e modelos) que definam o comportamento do recurso.
  2. Criar módulos de implementação: os módulos de implementação precisam depender do módulo de API e implementar o comportamento de uma abstração.
    Em vez de módulos de alto nível diretamente dependentes de módulos de baixo nível, os de alto nível e de implementação dependem do módulo de abstração.
    Figura 10. Os módulos de implementação dependem do módulo de abstração.
  3. Tornar módulos de alto nível dependentes de módulos de abstração: em vez de depender diretamente de uma implementação específica, torne seus módulos dependentes de módulos de abstração. Os módulos de alto nível não precisam conhecer os detalhes da implementação, eles só precisam do contrato (API).
    Os módulos de alto nível dependem de abstrações, não da implementação.
    Figura 11. Os módulos de alto nível dependem de abstrações, não da implementação.
  4. Fornecer módulo de implementação: finalmente, você precisa fornecer a implementação real para suas dependências. A implementação específica depende da configuração do projeto, mas o módulo do app geralmente é um bom lugar para fazer isso. Para fornecer a implementação, especifique-a como uma dependência da variante de build selecionada ou um conjunto de origem de teste.
    O módulo do app fornece uma implementação real.
    Figura 12. O módulo do app oferece uma implementação real.

Práticas recomendadas gerais

Como mencionado no início, não há apenas uma maneira correta de desenvolver um app multimódulo. Assim como existem várias arquiteturas de software, há inúmeras formas de modularizar um app. No entanto, as recomendações gerais a seguir podem ajudar a deixar seu código mais legível, testável e de fácil manutenção.

Manter a consistência da configuração

Todo módulo introduz um overhead de configuração. Se o número de módulos atingir um determinado limite, o gerenciamento de configurações consistentes vai se tornar um desafio. Por exemplo, é importante que os módulos usem dependências da mesma versão. Se você precisa atualizar um grande número de módulos apenas para resolver a versão de uma dependência, isso não só exige um esforço considerável como também abre espaço para possíveis erros. Para resolver esse problema, use uma das ferramentas do Gradle e centralize sua configuração:

  • Os catálogos de versões (link em inglês) são uma lista de tipos seguros de dependências geradas pelo Gradle durante a sincronização. Essa é uma plataforma central para declarar todas as dependências e está disponível para todos os módulos em um projeto.
  • Use plug-ins de convenção (link em inglês) para compartilhar a lógica de build entre módulos.

Expor o mínimo possível

A interface pública de um módulo precisa ser mínima e expor apenas o essencial. As informações de implementação não podem vazar. Defina o escopo de tudo para a menor extensão possível. Use o escopo de visibilidade private ou internal do Kotlin para tornar o módulo de declarações particular. Ao declarar dependências no módulo, prefira usar implementation em vez de api. Essa última expõe dependências transitivas aos consumidores do módulo. O uso da implementação pode melhorar o tempo de compilação porque reduz o número de módulos que precisam ser recriados.

Preferir módulos Kotlin e Java

Há três tipos essenciais de módulos que podem ser usados no Android Studio:

  • Os módulos do app são um ponto de entrada para o aplicativo. Eles podem conter o código-fonte, recursos e um AndroidManifest.xml. A saída de um módulo do app é um Android App Bundle (AAB) ou um pacote de aplicativo Android (APK).
  • Os módulos de biblioteca têm o mesmo conteúdo dos módulos do app. Eles são usados por outros módulos do Android como uma dependência. A saída de um módulo de biblioteca é um ARchive do Android (AAR) estruturalmente idêntico aos módulos do app, mas compilado para um documento do AAR, que pode ser usado mais tarde por outros módulos como uma dependência. Um módulo de biblioteca permite encapsular e reutilizar a mesma lógica e recursos em vários módulos de apps.
  • As bibliotecas Kotlin e Java não contêm recursos ou arquivos de manifesto do Android.

Como os módulos do Android vêm com overhead, recomendamos dar o máximo de preferência possível ao tipo Kotlin ou Java.