Android

Modularização do Android que se mantém: 5 lições para grandes repositórios de código

O texto discute cinco lições práticas para modularização eficaz em projetos Android, focando na redução do custo de mudança à medida que o códigobase cresce. Destaca a importância de definir responsabilidades claras e direcionar dependências adequadamente, além de utilizar convenções de build e feedback automatizado para manter uma arquitetura compreensível e modular.

Compartilhar
An engineer arranges distinct architectural components around a central assembly point while a small tangle of connections remains at the edge.

Modularização do Android que se mantém: 5 lições para grandes repositórios de código

Uma maneira prática de avaliar as fronteiras dos módulos, a direção das dependências, convenções de construção e as regras que mantêm uma arquitetura do Android compreensível à medida que ela cresce.

Pressione Enter ou clique para ver a imagem em tamanho completo
Ilustração editorial gerada por IA.

Bem-vindo(a) ao artigo! 👋 Meu objetivo é ajudá-lo a avaliar a modularização do Android além da contagem de módulos e nomes de pastas. Vamos focar em uma pergunta prática: suas fronteiras realmente reduzem o custo das mudanças à medida que o repositório de código cresce?

O que vamos abordar

  1. Desenhar módulos em torno de fronteiras de mudança.
  2. Dar uma direção previsível às dependências.
  3. Cruzar fronteiras de recursos através de contratos pequenos.
  4. Usar plugins de convenção para tornar a arquitetura pretendida mais fácil de seguir.
  5. Transformar regras de arquitetura em feedback automatizado.

Vamos começar com a parte aparentemente fácil.

Criar um módulo Gradle é fácil. Dê a ele um nome, adicione um arquivo de construção, mova algum código e atualize o settings.gradle.kts.

Repita esse processo o suficiente e você terá um projeto multi-módulo.

Você não necessariamente tem uma arquitetura modular.

Às vezes, é apenas um monólito com excelente etiqueta de pasta. 📁

Um projeto pode conter dezenas de módulos nomeados com cuidado e ainda se comportar como um monólito. Uma mudança em uma funcionalidade pode exigir tocar no módulo da aplicação, um módulo compartilhado, código de navegação e três recursos não relacionados. Cada novo módulo também pode copiar as mesmas versões do SDK, configuração Compose, setup Hilt e dependências de teste.

A árvore de pastas parece organizada. O custo da mudança conta uma história diferente.

Mantenha em mente uma mudança propositalmente comum: o catálogo precisa abrir detalhes do produto. Esse caminho toca duas fronteiras de recursos, navegação raiz e as convenções de construção por trás dos dois módulos. Em um gráfico fraco, essa pequena mudança é suficiente para expor a acoplamento oculto.

Mas por que o número de módulos é uma medida ruim da modularização. Uma pergunta mais útil é:

Quantos elementos não relacionados um desenvolvedor precisa conhecer para fazer uma alteração segura?

Quanto menos partes desconectadas você precisar entender, modificar e verificar, mais fortes são suas fronteiras. Para chegar lá é preciso mais do que mover arquivos. É necessário ter responsabilidades claras, direção previsível de dependências, contratos estreitos, convenções de construção e feedback quando uma regra é violada.

Com essa pergunta em mente, vamos trabalhar nas cinco lições.

1. Projete ao redor das fronteiras de alteração, não da estética dos diretórios

A primeira pergunta a fazer sobre um módulo não deve ser: “Qual camada essa código pertence?” É: “Por que esse código deve mudar juntamente?”

Essa mudança é importante porque uma pasta pode organizar o código sem isolar. Um módulo Gradle cria uma fronteira mais forte. Ele controla a visibilidade, as dependências, o trabalho de construção, os testes e geralmente a propriedade. Essa capacidade vem com sobrecarga, então a fronteira deve proteger algo significativo.

O guia oficial de modularização do Android faz essa troca explícita. Módulos muito grosseiros podem recriar um monólito. Módulos muito finos adicionam complexidade na construção e boilerplate até a estrutura se tornar mais difícil de manter do que o código que ela foi projetada para organizar.

Não há número ideal de módulos. Há apenas um nível de granularidade que se encaixa no tamanho, taxa de alteração e modelo de propriedade de um projeto.

Uma taxonomia útil pode começar assim:

  • :app possui o ponto de entrada e a composição final do aplicativo. Ele muda para identidade do app, navegação raiz, variantes e ligação final.
  • :feature:* possui uma capacidade ou fluxo voltado para o usuário. Ele muda com o comportamento do produto dessa funcionalidade.
  • :core:data:* possui o acesso aos dados para um domínio. Ele muda com contratos do backend, persistência, mapeamento e cache.
  • :core:ui possui o sistema de design compartilhado. Ele muda com a linguagem visual e os componentes UI reutilizáveis.
  • :core:navigation ou módulos de API de recursos possuem contratos de navegação. Eles mudam quando os contratos de rota ou chave evoluem entre limites.
  • :core:testing possui a infraestrutura de teste compartilhada. Ele muda com bibliotecas de teste, fakes, fixtures e helpers.

Este é um modelo de referência, não uma lei do Android. Um aplicativo pequeno pode ser melhor servido por :app, um módulo de dados e alguns módulos de recursos. Um domínio maior de checkout pode justificar módulos separados para pagamento, envio, endereços e confirmação.

A mesma cautela se aplica ao core. Um módulo compartilhado não deve se tornar o lugar onde código vai quando ninguém quer decidir quem é responsável por ele. Se capacidades não relacionadas mudam por razões não relacionadas, colocá-las juntas cria um novo monólito com um nome mais amigável. Eventualmente, core se torna uma gaveta de lixo com um arquivo Gradle. 🗄️

Uma regra prática é começar local e promover deliberadamente. Um caso de uso necessário por um recurso pode viver com esse recurso. Quando um segundo consumidor independente aparece, você tem evidência que a responsabilidade merece um contrato ou módulo compartilhado. Isso geralmente é mais seguro do que prever todo o futuro reuso no primeiro dia.

Um módulo ganha seu custo quando isola mudanças significativas sem tornar o código mais difícil de navegar.

2. Dê uma direção às dependências e um lar à composição

Uma vez que as responsabilidades dos módulos são claras, a próxima pergunta é se um desenvolvedor pode prever quais dependências são permitidas sem abrir todos os arquivos de build.

Um gráfico de módulos precisa ter uma direção. Uma possível regra é:

:app
-> :feature:*
-> :core:domain
-> :core:data:*
-> :core:ui
-> :core:navigation
:core:domain -> :core:data:* -> :core:models

The exact layers can change. The useful property is that dependencies move toward smaller, more stable capabilities and do not casually point back to product features.

This is also why :app deserves a specific job. Android describes an app module as an application entry point that depends on features and commonly provides root navigation. The multi-module navigation guidance similarly places the complete navigation graph in the app module.

That makes :app a natural composition root. It is the one place allowed to know how the system fits together.

It may own:

  • application identity and build variants;
  • the Application and root Activity;
  • the complete navigation host;
  • final dependency wiring;
  • conversion of app-specific build configuration into typed runtime configuration;
  • global resources and startup composition.

It should not gradually absorb checkout rules, catalog mapping, authentication behavior, or screen-specific state because those features were convenient to reach from the root.

A boring app module is a good symptom. It does not need to meet a file-count target, and composition code can still be substantial. The important part is that the concentration of knowledge is intentional. :app may know about many features so the features do not need to know about one another.

The composition root may know the system. Individual features should not need to.

Pressione Enter ou clique para ver a imagem em tamanho completo
Uma direção de dependência representativa: :app compõe recursos, e os recursos dependem apenas das capacidades do núcleo que precisam.

3. Navegar entre recursos com contratos, não atalhos

Voltemos à nossa mudança comum: o recurso do catálogo precisa abrir uma tela de detalhes do produto.

A solução tentadora é direta:

dependencies {
implementation(projects.feature.productDetail)
}

Às vezes, essa dependência reflete um limite real de propriedade e é aceitável. Mas se cada evento de navegação cria uma dependência entre recursos, o gráfico logo descreve transições de tela em vez da arquitetura. Os recursos tornam-se difíceis de construir, testar ou reutilizar independentemente.

A melhor pergunta é: qual é o contrato mais pequeno e estável que ambos os lados realmente precisam?

Existem várias respostas válidas.

Trocar um identificador através de estado compartilhado

Dois recursos podem trocar um ID do produto e carregar os dados correspondentes através de uma capacidade de dados compartilhada. Nenhum recurso precisa ter acesso à implementação do outro.

Isso funciona bem quando os dados já têm um proprietário claro e ambos os recursos realmente usam isso.

Dependendo de um contrato pequeno da API

Um recurso pode expor chaves de rota ou interfaces públicas de um módulo da API, mantendo telas e detalhes de implementação internos. Isso adiciona módulos, então é mais útil quando o limite é importante o suficiente para justificar a estrutura extra.

Deixe a camada de composição conectar callbacks

A navegação nos dá um exemplo concreto. Mantenha a tela sem consciência de NavController, expor um evento e deixe a camada do aplicativo conectar esse evento ao destino. Cada recurso pode possuir sua pequena superfície pública de navegação em vez de colocar cada rota em um módulo central:

// :feature:catalog
@Serializable
data object CatalogRoute
fun NavGraphBuilder.catalogDestination(
onProductSelected: (String) -> Unit,
) {
composable<CatalogRoute> {
CatalogScreen(
onProductSelected = onProductSelected,
)
}
}
// :feature:product-detail
@Serializable
private data class ProductDetailRoute(val productId: String)
fun NavController.navigateToProductDetail(productId: String) {
navigate(ProductDetailRoute(productId))
}

Em seguida, compõa ambos os lados onde o gráfico completo é conhecido:

NavHost(
navController = navController,
startDestination = CatalogRoute,
) {
catalogDestination(
onProductSelected = { productId ->
navController.navigateToProductDetail(productId)
},
)
productDetailDestination()
}

A orientação atual do Android recomenda extrair destinos em NavGraphBuilder funções de extensão e passar callbacks de navegação para componíveis da tela. Isso mantém o código UI isolado do controlador e mais fácil de testar. O Navigation Compose suportou rotas seguras por tipo como as acima desde a versão 2.8.0.

Esses trechos são ilustrativos, não um exemplo pronto para uso. Uma versão executável também precisa do artefato Navigation Compose, da configuração de serialização Kotlin, das importações e do destino detalhe do produto que consome sua rota privada. A propriedade é a parte importante: o catálogo possui CatalogRoute, o detalhe do produto possui sua rota e extensão de navegação, e :app possui a conexão final.

Pressione Enter ou clique para ver a imagem em tamanho completo
Contratos de navegação por recurso se encontram no :app, sem uma dependência implementacional entre recursos.

Para colaboração mais aberta, a injeção de dependência pode fornecer um ponto de extensão. Um recurso pode contribuir com uma tarefa inicial, um canal de análise ou uma entrada de navegação através de multibinding do Dagger. O consumidor recebe um conjunto de implementações sem nomear cada tipo concreto.

Mas há uma condição importante: o módulo contribuinte deve ainda fazer parte da dependência e do gráfico de componentes do aplicativo. Com Hilt, isso geralmente significa que a contribuição está no gráfico de dependências em tempo de compilação transitivo do :app. Módulos de recursos dinâmicos exigem uma integração diferente. Um conjunto multibound também promete não ter ordem de execução, então as contribuições de inicialização devem ser independentes ou carregar metadados para um orquestrador determinístico.

As multibindings removem o conhecimento direto entre produtor e consumidor. Elas não teleportam um módulo na construção. O Gradle permanece obstinadamente comprometido com a causalidade.

Módulos de rota central, divisões API e implementação, módulos mediadores, callbacks e multibindings resolvem diferentes problemas de acoplamento. Escolha o contrato mais estável e pequeno que permita à camada de composição conectar ambos os lados. Adicionar cada padrão apenas substituiria um tipo específico de acoplamento por cerimônia.

4. Faça a arquitetura pretendida ser o caminho mais fácil

Diagramas claros e tabelas de dependência ajudam as pessoas a entender uma arquitetura. A construção deve ajudá-las a segui-la.

Considere um módulo típico de recursos. Seu arquivo de build deve descrever o que é específico sobre aquele recurso:

plugins {
id("app.android.feature")
id("app.android.library.compose")
}
android {
namespace = "com.example.app.feature.catalog"
}
dependencies {
implementation(projects.core.data.catalog)
testImplementation(projects.core.testing)
}

O que falta é o ponto. Por uma vez, menos Gradle é a característica. ✨

Não há repetição de compileSdk, minSdk, cadeia de ferramentas Java, Compose BOM, configuração Hilt, test runner ou configuração lint. O módulo declara que é um recurso Android com Compose. Plugins de convenção definem o que esses termos significam neste projeto.

O Gradle descreve plugins de convenção como uma maneira de configurar padrões, aplicar outros plugins, definir dependências, registrar tarefas e impor padrões do projeto. A atual orientação do Gradle para estruturação de builds recomenda manter essa lógica em um build incluído, geralmente nomeado como build-logic, na maioria dos casos multi-projeto.

A forma pode ser simples:

build-logic/
settings.gradle.kts
convention/
build.gradle.kts
src/main/kotlin/
AndroidLibraryConventionPlugin.kt
AndroidFeatureConventionPlugin.kt
AndroidComposeConventionPlugin.kt

E um plugin de convenção para recursos pode compor convenções menores:

class AndroidFeatureConventionPlugin : Plugin<Project> {
override fun apply(target: Project) = with(target) {
pluginManager.apply("app.android.library")
pluginManager.apply("app.hilt")
pluginManager.apply("org.jetbrains.kotlin.plugin.serialization")
        dependencies {
add("implementation", project(":core:ui"))
add("implementation", project(":core:navigation"))
}
}
}

Este é um fragmento ilustrativo, não um plugin pronto para uso. Uma build executável também registraria os IDs de plugins em build-logic, incluiria essa build no settings.gradle.kts e faria com que os plugins aplicados fossem disponíveis para a lógica da build, fornecendo as importações necessárias. Um plugin de produção configuraria a extensão Android, opções do compilador, padrões de teste e dependências suportadas pelo contrato do módulo.

Plugins de convenção são mais que um truque para deduplicação. Eles tornam os tipos de módulos executáveis. Aplicar app.android.feature diz, "Este módulo segue nosso contrato de recurso".

Esse ganho pode se tornar acoplamento se formos ambiciosos demais.

Adicionar :core:ui pode ser razoável se cada recurso realmente usa isso. Adicionar catálogo, pagamentos, análises e três SDKs porque muitos recursos atualmente usam eles transforma a convenção em uma gaveta de dependências ocultas. Dependências opcionais devem permanecer visíveis no módulo consumidor.

A mesma sutileza se aplica ao buildSrc. Ele não está quebrado ou proibido. O Gradle ainda o descreve como o lugar mais simples para começar. Uma build incluída é a escolha mais forte quando a lógica de build precisa de melhor isolamento, teste independente ou um caminho para compartilhamento. A orientação atual do Gradle observa que alterações em buildSrc podem invalidar configurações amplamente, enquanto uma build incluída pode limitar quais consumidores ficam desatualizados.

Bordas sensíveis à versão

Antes de padronizar esse padrão, documente as partes cuja funcionalidade depende da configuração do Gradle:

  1. Uma build incluída não compartilha automaticamente o catálogo de versões raiz. Importe-o explicitamente se a lógica da build precisar do mesmo catálogo.
  2. As propriedades de tempo de execução para uma build composta, incluindo a configuração do Configuration Cache e da execução paralela, são controladas pela build raiz. Copiar essas flags para o arquivo build-logic/gradle.properties não cria uma configuração de tempo de execução independente para a mesma invocação. Um arquivo separado é necessário quando build-Logic também roda como uma build autônoma.

Também observe que os acessadores de projeto seguros por tipo, que produzem expressões como projects.core.data.catalog, ainda são documentados como incubando na Gradle 9.6.1. Eles oferecem uma conclusão útil e verificação em tempo de compilação, com a desvantagem de que a API pode ainda evoluir.

Centralize convenções estáveis, não todas as decisões que um módulo pode tomar.

5. Se uma regra importa, forneça feedback

Imagine que a documentação da arquitetura diz:

:feature:* pode depender de :core:*
:feature:* não deve depender de um :feature:* não relacionado
:core:models não deve depender de outros módulos do projeto

Isso é útil. Fornece um modelo compartilhado para revisores e novos membros da equipe.

Mas o que acontece quando alguém adiciona uma dependência proibida em um final de semana?

Se nada falhar, a regra é uma sugestão.

Os plugins de convenção fornecem o primeiro nível de feedback ao tornar a configuração esperada padrão. Por si só, eles não provam que cada aresta no gráfico de dependência do produto é permitida.

Uma estratégia de manutenção separa as preocupações:

  1. Plugins de convenção configuram valores padrão válidos para cada tipo de módulo.
  2. Validação e testes de plugins verificam a lógica de construção em si. A tarefa ValidatePlugins do Gradle verifica declarações de tarefas e propriedades de trabalho e pode falhar em avisos. Ela não valida a arquitetura do produto.
  3. Afirmações de grafo ou testes de arquitetura verificam dependências projetadas permitidas, namespaces ou direções de importação.
  4. Tarefas CI focadas fornecem feedback rápido para módulos afetados, com uma saída de construção completa quando a lógica de construção compartilhada ou configuração global mudar.
Pressione Enter ou clique para ver a imagem em tamanho completo
A documentação se torna durável quando padrões, testes e CI a transformam em feedback.

Essa última condição importa. Um script que mapeia arquivos alterados para módulos pode reduzir o trabalho de verificação local, mas mudanças em settings.gradle.kts, o catálogo de versões, a lógica de construção ou a configuração de análise estática podem afetar todo o projeto. A validação focada sem uma falha segura é rápida da mesma forma que pular os testes é rápido.

Essa também é a parte onde as afirmações de desempenho da construção precisam ser contidas.

A modularização dá ao Gradle um gráfico que pode usar para evitar a compilação, armazenamento em cache e trabalho paralelo. A orientação de otimização da construção do Android, por exemplo, recomenda classes R não transitivas para builds multi-módulo porque cada módulo contém apenas seus próprios recursos na classe R. Esse comportamento já é o padrão desde a versão 8.0 do Android Gradle Plugin.

Configuration Cache e Build Cache resolvem problemas diferentes. A modularização pode encolher classpaths, expor trabalho de tarefas independentes e melhorar o reuso de saídas de tarefas armazenáveis em cache. Em vez disso, a Configuration Cache reutiliza os resultados da configuração para invocações compatíveis de tarefas, independentemente do número de módulos. Um gráfico maior pode tornar um acerto mais valioso e um erro mais caro.

A única afirmação de desempenho honesta é a medida no projeto real. Registre a tarefa, hardware, versões do Gradle e Android Gradle Plugin, estado do cache e método de comparação. “Sente-se mais rápido” é uma observação perfeitamente normal durante o desenvolvimento. Não é um benchmark.

A regra de arquitetura é mais ampla que o desempenho:

Se uma regra importa, dê feedback aos desenvolvedores antes que a exceção se torne a nova arquitetura.

Auditor um mudança, não toda a base de código

Você não precisa redesenhar todo o módulo gráfico para aprender se está funcionando.

Escolha uma mudança de recurso recente e trace-a pelo sistema:

  1. A mudança permaneceu dentro de uma responsabilidade que muda junto?
  2. Você poderia prever suas dependências permitidas?
  3. Um recurso precisou da implementação de outro recurso?
  4. O módulo repetiu a configuração de build que descreve seu tipo em vez das necessidades específicas?
  5. O que falharia se alguém violasse a fronteira pretendida amanhã?

A primeira resposta desconfortável é sua próxima melhoria arquitetônica. Pode ser uma responsabilidade de módulo mais clara, um contrato de rota menor, um plugin convencional ou uma verificação de gráfico no CI. Você não precisa de todos os cinco ao mesmo tempo.

Uma boa modularização reduz dois raios de explosão: o código que deve mudar e o conhecimento necessário para mudá-lo com segurança. É isso que faz uma arquitetura resistir.

Se este artigo o ajudou, dê um aplauso 👏 e compartilhe-o com o colega que tem opiniões firmes sobre o que pertence a :core. Cada equipe tem uma. Siga-me aqui no Medium e em LinkedIn para mais artigos práticos sobre arquitetura Android, IA e construção de produtos úteis.

Obrigado por ler. Até a próxima!

Fonte original

Conteúdo traduzido e adaptado pela redação do Notícias Mobile. Confira também a matéria na fonte original.

Leia a matéria completa