Pattern di modularizzazione comuni

Non esiste un'unica strategia di modularizzazione adatta a tutti i progetti. Data la natura flessibile di Gradle, esistono pochi vincoli su come organizzare un progetto. Questa pagina fornisce una panoramica di alcune regole generali e pattern comuni che puoi utilizzare durante lo sviluppo di app Android multimodulo.

Principio di alta coesione e basso accoppiamento

Un modo per caratterizzare un codebase modulare consiste nell'utilizzare le proprietà di accoppiamento e coesione. L'accoppiamento misura il grado di dipendenza dei moduli l'uno dall'altro. La coesione, in questo contesto, misura la correlazione funzionale tra gli elementi di un singolo modulo. Come regola generale, dovresti cercare un accoppiamento basso e un'elevata coesione:

  • Basso accoppiamento 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 pari a zero o minimo sugli altri. I moduli non devono conoscere i meccanismi interni di altri moduli.
  • Alta coesione significa che i moduli devono comprendere una raccolta di codice che agisca come un sistema. Devono avere responsabilità chiaramente definite e rimanere entro i limiti di determinate conoscenze in materia. Prendi in considerazione un'applicazione per ebook di esempio. Potrebbe non essere appropriato combinare il codice relativo a prenotazione e pagamento nello stesso modulo, poiché sono due domini funzionali diversi.

Tipi di moduli

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

Moduli dati

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

  1. Incapsulare tutti i dati e la logica di business di un determinato dominio: ogni modulo di dati dovrebbe essere responsabile della gestione dei dati che rappresentano un determinato dominio. Può gestire molti tipi di dati, purché siano correlati.
  2. Esponi il repository come un'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 impostazione utilizzando la parola chiave per la 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 in genere corrisponde a una schermata o a una serie di schermate strettamente correlate, come una registrazione o un flusso di pagamento. Se l'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'UI e ViewModel per gestirne la logica e lo stato. Un singolo elemento non deve essere limitato a una singola visualizzazione o destinazione di navigazione. I moduli delle funzionalità dipendono dai moduli dei dati.

Figura 3. Esempi di moduli delle funzionalità e dei relativi contenuti.

Moduli dell'app

I moduli dell'app sono un punto di ingresso all'applicazione. Dipendono dai moduli delle funzionalità e di solito forniscono la navigazione root. Un singolo modulo dell'app può essere compilato su una serie di programmi binari diversi grazie alle varianti della build.

Figura 4. Grafico delle dipendenze dei moduli *Demo* e *Completa* delle dipendenze.

Se la tua app ha come target più tipi di dispositivi, ad esempio auto, Wear OS o TV, definisci un modulo dell'app per ciascuno. Ciò consente di separare le dipendenze specifiche della piattaforma.

Figura 5. Grafico delle dipendenze dell'app Wear.

Moduli comuni

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

  • Modulo UI: se utilizzi elementi personalizzati dell'interfaccia utente o branding elaborati nella tua app, ti consigliamo di incapsulare la raccolta di widget in un modulo per riutilizzare tutte le funzionalità. Questo può contribuire a rendere la UI coerente in diverse funzionalità. Ad esempio, se i temi sono centralizzati, puoi evitare il fastidioso refactoring.
  • Modulo di Analytics: il monitoraggio è spesso dettato da requisiti aziendali con poca considerazione all'architettura del software. I tracker di analisi sono spesso utilizzati in molti componenti non correlati. Se questo è il tuo caso, potrebbe essere una buona idea disporre di un modulo di analisi dedicato.
  • Modulo di rete: quando molti moduli richiedono una connessione di rete, potresti avere un modulo dedicato alla fornitura di un client HTTP. È particolarmente utile quando il cliente richiede una configurazione personalizzata.
  • Modulo utilità: le utilità, note anche come helper, sono in genere piccole porzioni di codice riutilizzate nell'applicazione. Esempi di utilità sono strumenti per l'esecuzione di test, una funzione di formattazione della valuta, uno strumento di convalida delle email o un operatore personalizzato.

Testa i moduli

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 che sono necessarie solo per l'esecuzione dei test e non durante il runtime dell'applicazione. I moduli di test vengono creati per separare il codice specifico per il test dall'applicazione principale, semplificando la gestione e la manutenzione del codice del modulo.

Casi d'uso per i moduli di test

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

  • Codice di test condiviso: se nel progetto sono presenti più moduli e parte del codice di test è applicabile a più di un modulo, puoi creare un modulo di test per condividere il codice. Questo può aiutarti a ridurre la duplicazione e a semplificare la gestione 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 ti consentono di avere configurazioni di build più pulite, in quanto possono avere un proprio file build.gradle. Non è necessario 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 utilizzati per verificare le interazioni tra diverse parti dell'app, inclusi interfaccia utente, logica di business, richieste di rete e query di database.

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

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

Comunicazione da modulo a modulo

I moduli raramente esistono completamente separati e spesso si affidano ad altri moduli e comunicano con loro. È importante mantenere basso l'accoppiamento anche quando i moduli lavorano insieme e si scambiano informazioni di frequente. A volte la comunicazione diretta tra due moduli non è desiderabile, come nel caso di vincoli dell'architettura. Potrebbe anche essere impossibile, ad esempio con le dipendenze cicliche.

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

Per risolvere questo problema, puoi utilizzare un terzo modulo per la mediazione tra gli altri due moduli. Il modulo mediatore può ascoltare i messaggi di entrambi i moduli e inoltrarli secondo 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 che possiede il grafico di navigazione (di solito un modulo dell'app). Nell'esempio, utilizziamo la navigazione per passare i dati dalla funzionalità della home page alla funzionalità di pagamento utilizzando il componente Navigazione.

navController.navigate("checkout/$bookId")

La destinazione di pagamento riceve un ID libro come argomento che utilizza per recuperare le informazioni sul libro. Puoi utilizzare l'handle dello stato salvato per recuperare gli argomenti di navigazione all'interno dell'elemento 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 usare per accedere alle risorse desiderate e caricarle dal livello dati. In questo modo, si mantiene basso l'accoppiamento e non si viola il principio dell'unica fonte di riferimento.

Nell'esempio riportato di seguito, entrambi i moduli delle funzionalità dipendono dallo stesso modulo dati. In questo modo è possibile ridurre al minimo la quantità di dati necessaria per l'inoltro del modulo mediatore e mantenere basso l'accoppiamento tra i moduli. Anziché passare gli oggetti, i moduli dovrebbero scambiarsi ID primitivi e caricare le risorse da un modulo dati condiviso.

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

Inversione delle dipendenze

L'inversione delle dipendenze avviene 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 nella tua applicazione interagiscono tra loro. I moduli di astrazione definiscono l'API del 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 dovrebbero dipendere solo dall'astrazione stessa, piuttosto che dalle implementazioni specifiche.

Figura 9. Invece dei moduli di alto livello che dipendono direttamente dai moduli di basso livello, i moduli di alto livello e di implementazione dipendono dal modulo di astrazione.

Esempio

Immagina un modulo di funzionalità che necessita di un database per funzionare. Il modulo della funzionalità non è interessato al modo in cui viene implementato il database, che si tratti di un database di una stanza locale o di un'istanza Firestore remota. Deve solo archiviare e leggere i dati dell'applicazione.

Per ottenere questo risultato, il modulo delle caratteristiche dipende dal modulo di astrazione piuttosto che dall'implementazione di un database specifico. Questa astrazione definisce l'API del database dell'app. In altre parole, imposta le regole per l'interazione con il database. Ciò consente al modulo della funzionalità di utilizzare qualsiasi database senza dover conoscere i dettagli di implementazione sottostanti.

Il modulo di implementazione concreta fornisce l'implementazione effettiva delle API definite nel modulo di astrazione. Per fare ciò, il modulo di implementazione dipende anche dal modulo di astrazione.

Iniezione di dipendenze

Probabilmente ti starai chiedendo in che modo il modulo sulle funzionalità è collegato al modulo per l'implementazione. La risposta è Dependency Injection. Il modulo delle funzionalità non crea direttamente l'istanza di database richiesta. ma specifica le dipendenze necessarie. Queste dipendenze vengono quindi fornite esternamente, in genere 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 tra API e moduli 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 e un'implementazione reale per la produzione.
  • Decouplicazione: la separazione significa che i moduli che utilizzano le astrazioni non dipendono da una tecnologia specifica. Se scegli di cambiare il database da Stanza a Firestore in un secondo momento, sarebbe più semplice perché le modifiche avverranno solo nel modulo specifico che svolge il job (modulo di implementazione) e non influiranno su altri moduli che utilizzano l'API del tuo database.
  • Testabilità: separare le API dalle relative implementazioni può semplificare notevolmente i test. Puoi scrivere scenari di test in base ai contratti API. Puoi anche utilizzare diverse implementazioni per testare vari scenari e casi limite, incluse implementazioni fittizie.
  • Prestazioni della build migliorate: quando separi un'API e la sua implementazione in moduli diversi, le modifiche nel modulo di implementazione non obbligano il sistema di compilazione a ricompilare i moduli a seconda del modulo API. Questo porta a tempi di creazione più rapidi e a una maggiore produttività, soprattutto nei progetti di grandi dimensioni in cui i tempi di creazione possono essere significativi.

Quando separare

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

  • Funzionalità diverse: se puoi implementare parti del tuo 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 Play o la tua API di fatturazione interna.
  • Applicazioni multiple: se stai sviluppando più applicazioni con funzionalità condivise per diverse piattaforme, puoi definire API comuni e sviluppare implementazioni specifiche per ogni piattaforma.
  • Team indipendenti: la separazione consente a sviluppatori o team diversi di lavorare contemporaneamente su diverse parti del codebase. Gli sviluppatori dovrebbero concentrarsi sulla comprensione dei contratti API e sul loro corretto utilizzo. Non devono preoccuparsi dei dettagli di implementazione di altri moduli.
  • Codebase di grandi dimensioni: se il codebase è di grandi dimensioni o complesso, la separazione dell'API dall'implementazione rende il codice più gestibile. Consente di suddividere il codebase in unità più granulari, comprensibili e gestibili.

Come implementarla?

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. Creazione di moduli di implementazione: i moduli di implementazione devono basarsi sul modulo API e implementare il comportamento di un'astrazione.
    Invece dei moduli di alto livello che dipendono 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. Rendere dipendenti i moduli di alto livello da moduli di astrazione: anziché dipendere direttamente da un'implementazione specifica, rendi i moduli dipendenti da moduli di astrazione. I moduli di alto livello non hanno bisogno di conoscere i dettagli di implementazione, hanno solo bisogno del 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 di solito è consigliabile utilizzare il modulo dell'app. Per fornire l'implementazione, specificala come dipendenza per la variante della build selezionata o per un set di origini di test.
    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. Così 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 l'overhead della configurazione. Se il numero di moduli raggiunge una determinata soglia, gestire una configurazione coerente diventa un problema. Ad esempio, è importante che i moduli utilizzino dipendenze della stessa versione. Se hai bisogno di aggiornare un numero elevato di moduli solo per passare a una versione delle dipendenze, non si tratta solo di un impegno, ma anche di un margine di potenziali errori. Per risolvere questo problema, puoi utilizzare uno degli strumenti di Gradle per centralizzare la configurazione:

  • I catalogo delle versioni sono un elenco sicuro dei tipi di dipendenze generato da Gradle durante la sincronizzazione. È una posizione centralizzata in cui dichiarare tutte le dipendenze ed è disponibile per tutti i moduli di un progetto.
  • Utilizza i plug-in della convenzione per condividere la logica di build tra i moduli.

Esponi il meno possibile

L'interfaccia pubblica di un modulo dovrebbe essere minima e mostrare solo gli elementi essenziali. Non dovrebbe però tralasciare alcun dettaglio di implementazione all'esterno. Limitare l'ambito di tutto al minimo possibile. Usa l'ambito di visibilità private o internal di Kotlin per rendere privato il modulo delle dichiarazioni. Quando dichiari delle dipendenze nel modulo, preferisci implementation anziché api. Quest'ultimo espone dipendenze transitorie ai consumatori del modulo. L'utilizzo dell'implementazione può migliorare i tempi di compilazione poiché riduce il numero di moduli da ricreare.

Preferisco i moduli Kotlin e Java

Android Studio supporta tre tipi di moduli essenziali:

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

Poiché i moduli Android hanno un overhead, preferibilmente, ti consigliamo di utilizzare il tipo Kotlin o Java il più possibile.