Pattern di modularizzazione comuni

Non esiste una singola strategia di modulazione che si adatti a tutti i progetti. A causa della natura flessibile di Gradle, ci sono pochi vincoli su come puoi organizzare un progetto. Questa pagina fornisce una panoramica di alcune regole generali e pattern comuni che puoi utilizzare durante lo sviluppo di app per Android a più moduli.

Principio di coesione elevata 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 tra i moduli. La coesione, in questo contesto, misura il modo in cui gli elementi di un singolo modulo sono funzionalmente correlati. Come regola generale, devi cercare un accoppiamento basso e un'elevata coesione:

  • Accoppiamento basso significa che i moduli devono essere il più indipendenti possibile tra loro, in modo che le modifiche a un modulo abbiano un impatto minimo o nullo sugli altri moduli. I moduli non devono essere a conoscenza del funzionamento interno di altri moduli.
  • Alta coesione significa che i moduli devono comprendere una raccolta di codice che agisce come sistema. Dovrebbero identificare chiaramente le responsabilità e rimanere entro i limiti di alcune conoscenze relative al dominio. Considera un'applicazione di ebook di esempio. Potrebbe essere inappropriato combinare codice correlato a libri e 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 dalla tua architettura di app. Di seguito sono riportati alcuni tipi comuni di moduli che potresti introdurre nella tua app seguendo la nostra architettura delle app consigliata.

Moduli dati

Un modulo dati in genere 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 dati deve essere responsabile della gestione dei dati che rappresentano un determinato dominio. Può gestire molti tipi di dati finché sono correlati.
  2. Esponi il repository come API esterna: l'API pubblica di un modulo 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. Restano nascosti all'esterno. Puoi farlo usando la parola chiave per la visibilità di Kotlin private o internal.
Figura 1. Esempi di moduli di dati e dei relativi contenuti.

Moduli delle funzionalità

Una funzionalità è una parte isolata delle funzionalità di un'app, che in genere corrisponde a una schermata o a una serie di schermate strettamente correlate, ad esempio una registrazione o un flusso di 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 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. Non è necessario che una singola funzionalità sia limitata a una singola visualizzazione o destinazione di navigazione. I moduli delle funzionalità dipendono dai moduli di dati.

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

Moduli app

I moduli dell'app sono un punto di accesso all'applicazione. Questi dipendono dai moduli funzionalità e di solito offrono una 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 relativo ai moduli di aroma del prodotto *Demo* e *Completo*.

Se la tua app ha come target più tipi di dispositivi, ad esempio auto, usura o TV, definisci un modulo app per ognuno. Questo contribuisce a separare le dipendenze specifiche della piattaforma.

Figura 5. Grafico delle dipendenze per le 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 livelli specifici nell'architettura di un'app. Di seguito sono riportati alcuni esempi di moduli comuni:

  • Modulo UI: se utilizzi elementi UI personalizzati o branding elaborato nella tua app, ti consigliamo di incapsulare la raccolta dei widget in un modulo per poter riutilizzare tutte le funzionalità. Questo può contribuire a rendere l'interfaccia utente coerente per le diverse funzionalità. Ad esempio, se il tema è centralizzato, puoi evitare un refactoring fastidioso quando si verifica un rebranding.
  • Modulo Analytics: il monitoraggio è spesso determinato dai requisiti aziendali, considerando poca considerazione dell'architettura software. I tracker di Analytics sono 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 utilizzare un modulo dedicato alla fornitura di un client http. e è particolarmente utile quando il client richiede una configurazione personalizzata.
  • Modulo utilità: le utilità, note anche come helper, sono generalmente piccole porzioni di codice riutilizzate nell'applicazione. Esempi di utilità includono helper, una funzione di formattazione della valuta, strumento di convalida delle email o un operatore personalizzato.

Moduli di test

I moduli di test sono moduli Android utilizzati esclusivamente a scopo di test. I moduli contengono codice e risorse di test e dipendenze di test, che sono richiesti solo per eseguire i test e non sono necessari durante il runtime dell'applicazione. I moduli di test vengono creati per separare il codice specifico del test dall'applicazione principale, semplificandone la gestione e la manutenzione.

Casi d'uso dei moduli di test

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

  • Codice di test condiviso: se nel progetto sono presenti più moduli e parte del codice di test è applicabile a più moduli, 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 del test condiviso può includere funzioni o classi di utilità, come asserzioni o abbinamenti 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 un proprio file build.gradle. Non è necessario riempire 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 testare le interazioni tra diverse parti della tua app, tra cui l'interfaccia utente, la logica di business, le richieste di rete e le query di database.

  • Applicazioni su larga scala: i moduli di test sono particolarmente utili per le applicazioni su larga scala con codebase complessi e più moduli. 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

Raramente i moduli esistono nella separazione totale e spesso si basano su altri moduli e comunicano con loro. È importante mantenere l'accoppiamento basso anche quando i moduli funzionano insieme e si scambiano spesso informazioni. A volte la comunicazione diretta tra due moduli non è desiderabile come nel caso dei vincoli di architettura. Potrebbe anche essere impossibile, ad esempio con dipendenze cicliche.

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

Per risolvere il 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, se necessario. 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 un'altra funzionalità. In questo caso, il mediatore è il modulo proprietario del grafico di navigazione (di solito un modulo per le app). In questo esempio utilizziamo la navigazione per trasmettere i dati dalla funzionalità relativa alla home page alla funzionalità di pagamento utilizzando il componente Navigazione.

navController.navigate("checkout/$bookId")

La destinazione di pagamento riceve un ID libro come argomento, utilizzato per recuperare informazioni sul libro. Puoi utilizzare l'handle di 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 dovresti passare oggetti come argomenti di navigazione. Utilizza invece ID semplici che le funzionalità possono utilizzare per accedere alle risorse desiderate dal livello dati e caricarle. In questo modo manterrai basso l'accoppiamento e non violi il principio della fonte di verità.

Nell'esempio riportato di seguito, entrambi i moduli delle funzionalità dipendono dallo stesso modulo di dati. Ciò consente di ridurre al minimo la quantità di dati che il modulo del mediatore deve inoltrare e mantiene basso l'accoppiamento tra i moduli. Anziché trasmettere oggetti, i moduli devono scambiare gli ID originari e caricare le risorse da un modulo di dati condivisi.

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

Inversione delle dipendenze

L'inversione delle dipendenze è 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 del calcestruzzo: moduli che dipendono dal modulo di astrazione e implementare 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. Invece di moduli di alto livello che dipendono direttamente da quelli di basso livello, quelli di alto livello e quelli di implementazione dipendono dal modulo di astrazione.

Esempio

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

Per farlo, il modulo delle funzionalità dipende dal modulo di astrazione piuttosto che da un'implementazione specifica del database. Questa astrazione definisce l'API del database dell'app. In altre parole, imposta le regole su come interagire con il database. Ciò consente al modulo delle funzionalità di utilizzare qualsiasi database senza dover conoscere i dettagli di implementazione sottostanti.

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

Iniezione di dipendenze

A questo punto ti starai chiedendo in che modo il modulo delle funzionalità è collegato al modulo di implementazione. La risposta è Iniezione di dipendenza. Il modulo delle funzionalità non crea direttamente l'istanza di database richiesta. Al contrario, specifica le dipendenze necessarie. Queste dipendenze vengono poi fornite esternamente, generalmente 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 di implementazione e API, puoi sviluppare più implementazioni per la stessa API e passare da un'implementazione all'altra senza modificare il codice che utilizza l'API. Ciò potrebbe essere particolarmente utile negli scenari in cui vuoi fornire capacità o comportamenti diversi in contesti diversi. Ad esempio, un'implementazione fittizia per i test e un'implementazione reale per la produzione.
  • Disaccoppiamento: la separazione fa sì che i moduli che utilizzano le astrazioni non dipendono da alcuna tecnologia specifica. Se in un secondo momento scegli di cambiare il tuo database da Room a Firestore, sarebbe più facile perché le modifiche verrebbero effettuate solo nel modulo specifico che svolgeva il lavoro (modulo di implementazione) senza influire sugli altri moduli utilizzando l'API del database.
  • Testabilità: la separazione delle API dalle loro implementazioni può semplificare notevolmente il test. Puoi scrivere scenari di test in base ai contratti API. Puoi anche utilizzare implementazioni diverse per testare vari scenari e casi limite, comprese le implementazioni fittizie.
  • Prestazioni di build migliorate: quando separi un'API e la sua implementazione in moduli diversi, le modifiche al modulo di implementazione non obbligano il sistema a ricompilare i moduli a seconda del modulo API. Questo porta a tempi di build più rapidi e a un aumento della produttività, in particolare in progetti di grandi dimensioni, in cui i tempi di build possono essere significativi.

Quando separare

È consigliabile separare le API dalle loro implementazioni nei seguenti casi:

  • Diverse funzionalità: se puoi implementare parti del sistema in diversi 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 funzioni con Google 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 piattaforma.
  • Team indipendenti: la separazione consente a sviluppatori o team diversi di lavorare su parti diverse del codebase contemporaneamente. Gli sviluppatori dovrebbero concentrarsi sulla comprensione dei contratti API e sul loro corretto utilizzo. Non deve preoccuparsi dei dettagli di implementazione degli altri moduli.
  • Codebase di grandi dimensioni: se il codebase è grande o complesso, separare l'API dall'implementazione rende il codice più gestibile. Consente di suddividere il codebase in unità più granulari, comprensibili e gestibili.

Come implementare?

Per implementare l'inversione delle dipendenze, segui questi passaggi:

  1. Crea un modulo di astrazione: questo modulo deve contenere API (interfacce e modelli) che definiscono il comportamento della tua funzionalità.
  2. Crea moduli di implementazione: i moduli di implementazione devono fare affidamento sul modulo API e implementare il comportamento di un'astrazione.
    Anziché i moduli di alto livello che dipendono direttamente da quelli di basso livello, quelli di alto livello e quelli 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: invece di dipendere direttamente da un'implementazione specifica, rendi i tuoi moduli dipendenti dai moduli di astrazione. I moduli di alto livello non hanno bisogno di 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 dipendenze. L'implementazione specifica dipende dalla configurazione del progetto, ma di solito il modulo dell'app è la soluzione ideale. Per fornire l'implementazione, specificala come dipendenza per la variante di build selezionata o un set di origine di test.
    Il modulo dell&#39;app fornisce un&#39;implementazione effettiva.
    Figura 12. Il modulo dell'app fornisce un'implementazione effettiva.

Best practice generali

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

Mantieni la configurazione coerente

Ogni modulo introduce un overhead per la configurazione. Se il numero di moduli raggiunge una determinata soglia, la gestione della configurazione coerente diventa una sfida. Ad esempio, è importante che i moduli utilizzino dipendenze della stessa versione. Se devi aggiornare un numero elevato di moduli solo per avviare una versione di dipendenza, non è solo uno sforzo, ma anche uno spazio per potenziali errori. Per risolvere questo problema, puoi utilizzare uno degli strumenti del Gradle per centralizzare la configurazione:

  • I catalogi delle versioni sono un elenco sicuro di tipo di dipendenze generate da Gradle durante la sincronizzazione. È un centro centralizzato per dichiarare tutte le tue dipendenze ed è disponibile per tutti i moduli di un progetto.
  • Utilizza i plug-in della convenzione per condividere la logica di compilazione tra i moduli.

Esponi il meno possibile

L'interfaccia pubblica di un modulo deve essere minima e mostrare solo i concetti essenziali. Non deve divulgare dettagli sull'implementazione. Quasi il più possibile l'ambito. Utilizza l'ambito di visibilità di Kotlin private o internal per rendere private le dichiarazioni. Quando dichiaghi le dipendenze nel modulo, preferisci implementation rispetto a api. Il secondo livello espone le dipendenze transitive ai consumatori del modulo. L'uso dell'implementazione può migliorare i tempi di compilazione perché riduce il numero di moduli che devono essere ricostruiti.

Preferisci moduli Kotlin e Java

Esistono tre tipi di moduli essenziali supportati da Android Studio:

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

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