Pattern di modularizzazione comuni

Non esiste un'unica strategia di modularizzazione adatta a tutti i progetti. Grazie alla flessibilità di Gradle, ci sono pochi vincoli su come puoi organizzare un progetto. Questa pagina offre una panoramica di alcune regole generali e pattern comuni che puoi utilizzare durante lo sviluppo di app per Android multimodulo.

Principio di alta coesione e basso accoppiamento

Un modo per caratterizzare un codebase modulare è utilizzare le proprietà accoppiamento e coesione. L'accoppiamento misura il grado di dipendenza reciproca dei moduli. La coesione, in questo contesto, misura il modo in cui gli elementi di un singolo modulo sono correlati a livello funzionale. Come regola generale, dovresti puntare a un basso accoppiamento e un'alta coesione:

  • Accoppiamento basso significa che i moduli devono essere il più indipendenti possibile l'uno dall'altro, in modo che le modifiche a un modulo abbiano un impatto nullo o minimo sugli altri moduli. I moduli non devono conoscere il funzionamento interno di altri moduli.
  • Coesione elevata significa che i moduli devono comprendere una raccolta di codice che funge da sistema. Devono avere responsabilità chiaramente definite e rimanere entro i limiti di determinate conoscenze del dominio. Prendi in considerazione un'applicazione di esempio per ebook. Potrebbe non essere appropriato combinare il codice relativo ai libri e ai pagamenti nello stesso modulo, in quanto si tratta di due domini funzionali diversi.

Tipi di moduli

Il modo in cui organizzi i moduli dipende principalmente dall'architettura della tua app. Di seguito sono riportati alcuni tipi comuni di moduli che potresti introdurre nella tua app seguendo la nostra architettura dell'app consigliata.

Moduli di dati

Un modulo di dati in genere contiene un repository, origini dati e classi di modelli. Le tre responsabilità principali di un modulo di dati sono:

  1. Incapsula tutti i dati e la logica di business di un determinato dominio: ogni modulo di dati deve essere responsabile della gestione dei dati che rappresentano un determinato dominio. Può gestire molti tipi di dati, purché siano correlati.
  2. Esporre il repository come API esterna: l'API pubblica di un modulo di dati deve essere un repository, in quanto è responsabile dell'esposizione dei dati al resto dell'app.
  3. Nascondi tutti i dettagli di implementazione e le origini dati dall'esterno: le origini dati devono essere accessibili solo ai repository dello stesso modulo. Rimangono nascosti all'esterno. Puoi applicare questa regola utilizzando la parola chiave di visibilità private o internal di Kotlin.
Figura 1. Moduli di dati di esempio e relativi contenuti.

Moduli delle funzionalità

Una funzionalità è una parte isolata della funzionalità di un'app che di solito corrisponde a una schermata o a una serie di schermate strettamente correlate, come un flusso di registrazione o pagamento. Se la tua app ha una barra di navigazione in basso, è probabile che ogni destinazione sia una funzionalità.

Figura 2. Ogni scheda di questa applicazione può essere definita come una funzionalità.

Le funzionalità sono associate a schermate o destinazioni nella tua app. Pertanto, è probabile che abbiano un'interfaccia utente associata e ViewModel per gestire la logica e lo stato. Una singola funzionalità non deve essere limitata a una singola visualizzazione o destinazione di navigazione. I moduli delle funzionalità dipendono dai moduli di dati.

Figura 3. Moduli delle funzionalità di esempio e relativi contenuti.

Moduli app

I moduli dell'app sono un punto di accesso all'applicazione. Dipendono dai moduli delle funzionalità e di solito forniscono la navigazione principale. Un singolo modulo dell'app può essere compilato in diversi binari grazie alle varianti di build.

Figura 4. *Demo* e *Full* product flavor modules dependency graph.

Se la tua app ha come target più tipi di dispositivi, ad esempio Android Auto, Wear o TV, definisci un modulo dell'app per ciascuno. In questo modo, le dipendenze specifiche della piattaforma vengono separate.

Figura 5. Grafico delle dipendenze dell'app Android Auto.

Moduli comuni

I moduli comuni, noti anche come moduli principali, contengono codice utilizzato di frequente da altri moduli. Riduce la ridondanza e non rappresenta un livello specifico nell'architettura di un'app. Di seguito sono riportati alcuni esempi di moduli comuni:

  • Modulo UI: se utilizzi elementi UI personalizzati o un branding elaborato nella tua app, ti consigliamo di incapsulare la raccolta di widget in un modulo per riutilizzare tutte le funzionalità. In questo modo, l'interfaccia utente sarà coerente tra le diverse funzionalità. Ad esempio, se il tema è centralizzato, puoi evitare un refactoring doloroso quando viene eseguito un rebranding.
  • Modulo Analytics: il monitoraggio è spesso dettato dai requisiti aziendali con poca considerazione per l'architettura del software. I tracker di Analytics vengono spesso utilizzati in molti componenti non correlati. Se questo è il tuo caso, potrebbe essere una buona idea avere un modulo di analisi dedicato.
  • Modulo di rete: quando molti moduli richiedono una connessione di rete, potresti valutare la possibilità di avere un modulo dedicato alla fornitura di un client HTTP. È particolarmente utile quando il client richiede una configurazione personalizzata.
  • Modulo di utilità: le utilità, note anche come helper, sono in genere piccoli frammenti di codice riutilizzati nell'applicazione. Esempi di utilità includono helper di test, una funzione di formattazione della valuta, uno strumento di convalida delle email o un operatore personalizzato.

Moduli di test

I moduli di test sono moduli Android utilizzati solo a scopo di test. I moduli contengono codice di test, risorse di test e dipendenze di test necessari solo per l'esecuzione dei test e non necessari durante il runtime dell'applicazione. I moduli di test vengono creati per separare il codice specifico del test dall'applicazione principale, rendendo il codice del modulo più facile da gestire e mantenere.

Casi d'uso per i moduli di test

I seguenti esempi mostrano situazioni in cui l'implementazione di moduli di test può essere particolarmente vantaggiosa:

  • Codice di test condiviso: se il progetto contiene più moduli e parte del codice di test è applicabile a più di un modulo, puoi creare un modulo di test per condividere il codice. In questo modo puoi ridurre la duplicazione e semplificare la manutenzione del codice di test. Il codice di test condiviso può includere classi o funzioni di utilità, come asserzioni o matcher personalizzati, nonché dati di test, come risposte JSON simulate.

  • Configurazioni di build più pulite: i moduli di test consentono di avere configurazioni di build più pulite, in quanto possono avere il proprio file build.gradle. Non devi ingombrare il file build.gradle del modulo dell'app con configurazioni pertinenti solo per i test.

  • Test di integrazione: i moduli di test possono essere utilizzati per archiviare i test di integrazione che vengono utilizzati per testare le interazioni tra le diverse parti dell'app, tra cui l'interfaccia utente, la logica di business, le richieste di rete e le query del database.

  • Applicazioni su larga scala: i moduli di test sono particolarmente utili per applicazioni su larga scala con codebase complessi e più moduli. In questi casi, i moduli di test possono contribuire a migliorare l'organizzazione e la manutenibilità del codice.

Figura 6. I moduli di test possono essere utilizzati per isolare i moduli che altrimenti sarebbero dipendenti l'uno dall'altro.

Comunicazione tra moduli

I moduli raramente esistono in totale separazione e spesso si basano su altri moduli e comunicano con loro. È importante mantenere un basso accoppiamento anche quando i moduli lavorano insieme e scambiano informazioni di frequente. A volte la comunicazione diretta tra due moduli non è auspicabile, ad esempio in caso di vincoli di architettura. Potrebbe anche essere impossibile, ad esempio con le dipendenze cicliche.

Figura 7. Una comunicazione diretta e bidirezionale tra i moduli è impossibile a causa di dipendenze cicliche. È necessario un modulo di mediazione per coordinare il flusso di dati tra altri due moduli indipendenti.

Per superare questo problema, puoi avere un terzo modulo di mediazione tra altri due moduli. Il modulo mediatore può ascoltare i messaggi di entrambi i moduli e inoltrarli in base alle necessità. Nella nostra app di esempio, la schermata di pagamento deve sapere quale libro acquistare anche se l'evento ha avuto origine in una schermata separata che fa parte di una funzionalità diversa. In questo caso, il mediatore è il modulo proprietario del grafico di navigazione (di solito un modulo dell'app). Nell'esempio, utilizziamo la navigazione per trasferire i dati dalla funzionalità Home alla funzionalità di pagamento utilizzando il componente Navigation.

navController.navigate("checkout/$bookId")

La destinazione di pagamento riceve un ID libro come argomento, che utilizza per recuperare informazioni sul libro. Puoi utilizzare l'handle dello stato salvato per recuperare gli argomenti di navigazione all'interno di ViewModel di una funzionalità di destinazione.

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

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

Non devi passare oggetti come argomenti di navigazione. Utilizza invece ID semplici che le funzionalità possono utilizzare per accedere e caricare le risorse desiderate dal livello dati. In questo modo, l'accoppiamento rimane basso e non viene violato il principio dell'unica fonte attendibile.

Nell'esempio riportato di seguito, entrambi i moduli delle funzionalità dipendono dallo stesso modulo di dati. In questo modo è possibile ridurre al minimo la quantità di dati che il modulo mediatore deve inoltrare e mantenere basso l'accoppiamento tra i moduli. Anziché passare oggetti, i moduli devono scambiare ID primitivi e caricare le risorse da un modulo di dati condiviso.

Figura 8. Due moduli delle funzionalità che si basano su un modulo di dati condiviso.

Inversione delle dipendenze

L'inversione delle dipendenze si verifica quando organizzi il codice in modo che l'astrazione sia separata da un'implementazione concreta.

  • Astrazione: un contratto che definisce il modo in cui i componenti o i moduli dell'applicazione interagiscono tra loro. I moduli di astrazione definiscono l'API del tuo sistema e contengono interfacce e modelli.
  • Implementazione concreta: moduli che dipendono dal modulo di astrazione e implementano il comportamento di un'astrazione.

I moduli che si basano sul comportamento definito nel modulo di astrazione devono dipendere solo dall'astrazione stessa, anziché dalle implementazioni specifiche.

Figura 9. Anziché dipendere direttamente dai moduli di basso livello, i moduli di alto livello e di implementazione dipendono dal modulo di astrazione.

Esempio

Immagina un modulo della funzionalità che necessita di un database per funzionare. Il modulo della funzionalità non si occupa di come viene implementato il database, che si tratti di un database Room locale o di un'istanza Firestore remota. Deve solo archiviare e leggere i dati dell'applicazione.

Per raggiungere questo obiettivo, il modulo della funzionalità dipende dal modulo di astrazione anziché da un'implementazione specifica del database. Questa astrazione definisce l'API del database dell'app. In altre parole, definisce le regole per interagire con il database. In questo modo, il modulo della funzionalità può utilizzare qualsiasi database senza doverne conoscere i dettagli di implementazione sottostanti.

Il modulo di implementazione concreto fornisce l'implementazione effettiva delle API definite nel modulo di astrazione. Per farlo, il modulo di implementazione dipende anche dal modulo di astrazione.

Dependency injection

A questo punto ti starai chiedendo in che modo il modulo delle funzionalità è collegato al modulo di implementazione. La risposta è Dependency Injection. Il modulo della funzionalità non crea direttamente l'istanza del database richiesta. Specifica invece le dipendenze necessarie. Queste dipendenze vengono poi fornite esternamente, di solito nel modulo dell'app.

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

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

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

Vantaggi

I vantaggi della separazione delle API e delle relative implementazioni sono i seguenti:

  • Intercambiabilità: con una chiara separazione dei moduli API e di implementazione, puoi sviluppare più implementazioni per la stessa API e passare da una all'altra senza modificare il codice che utilizza l'API. Ciò potrebbe essere particolarmente utile negli scenari in cui vuoi fornire funzionalità o comportamenti diversi in contesti diversi. Ad esempio, un'implementazione simulata per i test rispetto a un'implementazione reale per la produzione.
  • Disaccoppiamento: la separazione significa che i moduli che utilizzano astrazioni non dipendono da alcuna tecnologia specifica. Se in un secondo momento scegli di cambiare il database da Room a Firestore, sarà più facile perché le modifiche verranno apportate solo al modulo specifico che svolge il lavoro (modulo di implementazione) e non interesseranno altri moduli che utilizzano l'API del database.
  • Testabilità: la separazione delle API dalle loro implementazioni può facilitare notevolmente i test. Puoi scrivere scenari di test in base ai contratti API. Puoi anche utilizzare implementazioni diverse per testare vari scenari e casi limite, incluse le implementazioni simulate.
  • Prestazioni di compilazione migliorate: quando separi un'API e la relativa implementazione in moduli diversi, le modifiche al modulo di implementazione non forzano il sistema di compilazione a ricompilare i moduli a seconda del modulo API. Ciò comporta tempi di compilazione più rapidi e una maggiore produttività, soprattutto in progetti di grandi dimensioni in cui i tempi di compilazione possono essere significativi.

Quando separare

È utile separare le API dalle relative implementazioni nei seguenti casi:

  • Funzionalità diverse: se puoi implementare parti del sistema in più modi, un'API chiara consente l'intercambiabilità di diverse implementazioni. Ad esempio, potresti avere un sistema di rendering che utilizza OpenGL o Vulkan oppure un sistema di fatturazione che funziona con Google Play o con la tua API di fatturazione interna.
  • Più applicazioni: se sviluppi più applicazioni con funzionalità condivise per piattaforme diverse, puoi definire API comuni e sviluppare implementazioni specifiche per piattaforma.
  • Team indipendenti: la separazione consente a diversi sviluppatori o team di lavorare contemporaneamente su parti diverse del codebase. Gli sviluppatori devono concentrarsi sulla comprensione dei contratti API e sul loro corretto utilizzo. Non devono preoccuparsi dei dettagli di implementazione di altri moduli.
  • Base di codice di grandi dimensioni: quando la base di codice è grande o complessa, separare l'API dall'implementazione rende il codice più gestibile. Consente di suddividere la codebase in unità più granulari, comprensibili e gestibili.

Come faccio a implementare i consigli?

Per implementare l'inversione delle dipendenze:

  1. Crea un modulo di astrazione: questo modulo deve contenere API (interfacce e modelli) che definiscono il comportamento della funzionalità.
  2. Crea moduli di implementazione: i moduli di implementazione devono basarsi sul modulo API e implementare il comportamento di un'astrazione.
    Anziché dipendere direttamente dai moduli di basso livello, i moduli di alto livello e di implementazione dipendono dal modulo di astrazione.
    Figura 10. I moduli di implementazione dipendono dal modulo di astrazione.
  3. Rendi i moduli di alto livello dipendenti dai moduli di astrazione: anziché dipendere direttamente da un'implementazione specifica, rendi i moduli dipendenti dai moduli di astrazione. I moduli di alto livello non devono conoscere i dettagli di implementazione, ma solo il contratto (API).
    I moduli di alto livello dipendono dalle astrazioni, non dall&#39;implementazione.
    Figura 11. I moduli di alto livello dipendono dalle astrazioni, non dall'implementazione.
  4. Fornisci il modulo di implementazione: infine, devi fornire l'implementazione effettiva per le tue dipendenze. L'implementazione specifica dipende dalla configurazione del progetto, ma il modulo dell'app è in genere un buon punto di partenza. Per fornire l'implementazione, specificarla come dipendenza per la variante di build o il set di origini di test selezionato.
    Il modulo dell&#39;app fornisce l&#39;implementazione effettiva.
    Figura 12. Il modulo dell'app fornisce l'implementazione effettiva.

Best practice generali

Come accennato all'inizio, non esiste un unico modo corretto per sviluppare un'app multimodulo. Proprio come esistono molte architetture software, esistono numerosi modi per modularizzare un'app. Tuttavia, i seguenti consigli generali possono aiutarti a rendere il tuo codice più leggibile, gestibile e testabile.

Mantieni la configurazione coerente

Ogni modulo introduce un sovraccarico di configurazione. Se il numero di moduli raggiunge una determinata soglia, la gestione di una configurazione coerente diventa difficile. Ad esempio, è importante che i moduli utilizzino dipendenze della stessa versione. Se devi aggiornare un numero elevato di moduli solo per aumentare una versione della dipendenza, non solo è un'operazione impegnativa, ma anche un'opportunità per potenziali errori. Per risolvere il problema, puoi utilizzare uno degli strumenti di Gradle per centralizzare la configurazione:

  • I cataloghi delle versioni sono un elenco di dipendenze con controllo del tipo generato da Gradle durante la sincronizzazione. È un luogo centrale per dichiarare tutte le tue dipendenze ed è disponibile per tutti i moduli di un progetto.
  • Utilizza i plug-in di convenzione per condividere la logica di build tra i moduli.

Esporre il meno possibile

L'interfaccia pubblica di un modulo deve essere ridotta al minimo ed esporre solo gli elementi essenziali. Non deve divulgare dettagli di implementazione all'esterno. Limita tutto il più possibile. Utilizza l'ambito di visibilità private o internal di Kotlin per rendere privati i moduli delle dichiarazioni. Quando dichiari le dipendenze nel modulo, preferisci implementation a api. Il secondo espone le dipendenze transitive ai consumatori del tuo modulo. L'utilizzo dell'implementazione può migliorare il tempo di compilazione, in quanto riduce il numero di moduli che devono essere ricompilati.

Preferisci i moduli Kotlin e Java

Android Studio supporta tre tipi essenziali di moduli:

  • I moduli dell'app sono un punto di accesso alla tua applicazione. Possono contenere codice sorgente, risorse, asset e un AndroidManifest.xml. L'output di un modulo dell'app è un Android App Bundle (AAB) o un pacchetto dell'applicazione Android (APK).
  • I moduli della libreria hanno gli stessi contenuti dei moduli dell'app. Vengono utilizzati da altri moduli Android come dipendenza. L'output di un modulo libreria è un file Android Archive (AAR) strutturalmente identico ai moduli app, ma viene compilato in un file Android Archive (AAR) che può essere utilizzato in seguito da altri moduli come dipendenza. Un modulo di libreria consente di incapsulare e riutilizzare la stessa logica e le stesse risorse in molti moduli dell'app.
  • Le librerie Kotlin e Java non contengono risorse, asset o file manifest di Android.

Poiché i moduli Android comportano un overhead, è preferibile utilizzare il tipo Kotlin o Java il più possibile.