Android, MVVM e repository nel mondo reale

Pubblicato il

(Mike DeMaso) (2 novembre 2020)

Come si ottiene uninterfaccia utente reattiva mantenendo il codice pulito, preciso, leggibile e gestibile? Questa è una domanda che rimbalza nel cervello degli sviluppatori Android dal 2008.

Dopo molti anni passati a lasciare che gli sviluppatori Android lo capissero da soli, Google ha recentemente fornito alcune indicazioni sullargomento con il loro Guida allarchitettura delle app che promuove una variante di MVVM . Sebbene questo sia un ottimo inizio, lascia molte domande a cui i team devono rispondere da soli. Google fornisce anche biblioteche come LiveData , Room e DataBinding per aiutarci a ridurre i boilerplates e a separare le preoccupazioni del nostro codice. Anche terze parti hanno dato una mano con RxJava e Retrofit , che ci offre un facile modo per gestire il lavoro asincrono e recuperare le cose sulla rete. Con tutte queste cose che fluttuano intorno alla risoluzione di piccoli pezzi della domanda più ampia, come possiamo riunirli tutti per fornire unesperienza di interfaccia utente fluida che sia semplice da implementare e mantenere?

In questo articolo, condivideremo ciò che abbiamo imparato dai nostri precedenti tentativi di creare unarchitettura MVVM e mostriamo un semplice esempio di come riunire tutti questi pezzi del puzzle.

Una rapida descrizione di MVVM

Per darti unidea di cosa risolverà un repository, immergiamoci prima nel pattern MVVM di base.

Pattern MVVM
Diagramma MVVM di base

La vista

Questa è la rappresentazione dellinterfaccia utente grafica per il tuo codice. Questi possono essere rappresentati da XML (file di layout) o codice (Jetpack Compose). Di solito, esiste una forma di associazione dati che collega la vista al ViewModel. A seconda di dove tracciate la linea tra una vista e un raccoglitore, gli oggetti Activity e Fragment possono essere considerati uno o entrambi.

Il ViewModel

Questo è incaricato di trasformare i dati da il modello in un formato che abbia senso per la visualizzazione da visualizzare. Non lasciarti ingannare dalla doppia connessione con estremità a freccia tra View e ViewModel. La principale differenza tra ViewModel e Presenter in MVP è che ViewModel non contiene un riferimento alla vista. È una relazione unidirezionale, il che significa che qualcosaltro deve gestire loggetto ViewModel.

Modello

Si riferisce ai dati (o al modello di dominio) che vengono utilizzati come fonte delle informazioni che ViewModel fornisce alla vista. Questo livello è dove le cose diventano un po sfocate poiché alcuni articoli fanno riferimento esso come i dati stessi mentre altri si riferiscono ad esso come un livello di accesso ai dati. È qui che il modello di repository di Google interviene per chiarire le cose.

Entra nel repository

Google è stato menzionando questo modello da un po di tempo . I loro esempi sono unottima guida per comprendere il principio di base delluso di MVVM con un repository, ma scopriamo che mancano alcuni piccoli ( e importante) indicazioni per aiutare le persone a tradurre questi snippet in un progetto più ampio e complesso.

Diagramma MVVM di Google

Il modello del repository è progettato per” fornire unAPI pulita in modo che il resto dellapp può recuperare facilmente questi dati. ” Sfortunatamente, la semplice aggiunta di un repository alla tua architettura non forza la pulizia del tuo codice. Puoi ancora creare un pasticcio aggrovigliato senza separare correttamente e fornire un contratto chiaro tra i livelli.

In DraftKings ci siamo concentrati su alcune linee guida aggiuntive per aiutare i nostri sviluppatori a produrre codice pulito in modo coerente.

Disaccoppia i livelli con le interfacce

Qui stiamo aggiungendo interfacce per spingere gli ingegneri a pensare a ciò che stanno pubblicamente esponendo
Migliorare il diagramma di Google aggiungendo interfacce

Abbiamo stabilito che lutilizzo di interfacce tra questi livelli aiuterà gli ingegneri di tutti i livelli ad attenersi a buoni principi di codifica. Questo aiuta a garantire che i nostri test unitari stiano veramente testando solo un livello alla volta, riducendo il sovraccarico di scrittura e mantenendo una vasta suite di test. Inoltre, aiuta a definire chiaramente le API esterne e offusca i dettagli di implementazione dei diversi livelli. Ci spinge a valutare ciò che stiamo dicendo al mondo esterno sulla funzionalità di questo oggetto e ci offre unopportunità integrata per garantire che il nostro codice sia pulito.

Ci offre anche la possibilità di refactoring di diversi livelli nella nostra base di codice in modo più efficace. Finché linterfaccia non cambia, possiamo lasciare intatte le aree della nostra base di codice che le utilizzano. Ad esempio, se volessimo migrare la nostra libreria di rete da Volley a Retrofit, potremmo semplicemente cambiare i metodi nelle nostre classi Dagger che produrre e fornire linterfaccia sopra lorigine dati remota e non dover apportare modifiche in ogni repository che utilizza tale endpoint. Ciò riduce notevolmente lambito di tali modifiche e diminuisce la possibilità di introdurre bug nel prodotto finale.

Qui abbiamo un esempio di come lasciare che un ViewModel mantenga la classe concreta di un repository può portare a comportamenti imprevisti. Lesempio è un po artificioso e può essere corretto semplicemente contrassegnando fooPublishSubject in FooRepository come private, ma quella soluzione è più fragile. FooRepository potrebbe devono essere utilizzati in un ambito diverso che richiede laccesso a quel parametro e lapertura dellaccesso per le istanze ora confonde wh it è opportuno utilizzare direttamente la variabile membro.

Fornire queste dipendenze

Man mano che la complessità del tuo progetto cresce, più complicate diventano le tue relazioni di dipendenza. Ciò significa che le persone generalmente si rivolgono a una sorta di libreria di inserimento delle dipendenze (come Dagger o Koin ) .

Non solo una libreria DI fornisce un modo semplice e pulito per recuperare le dipendenze richieste, ma ti permette anche di pensare a quanti di questi oggetti avrai bisogno nellapplicazione.

Questo processo di pensiero ci ha portato a stabilire la migliore pratica di quali oggetti appartengono al grafico Dagger. Tutto ciò di cui vogliamo solo una singola istanza, dovrebbe risiedere nel grafo radice / globale / con ambito dellapplicazione. Tutto ciò di cui potrebbero esserci molte istanze, dovrebbe essere creato su richiesta e mantenuto in modo appropriato.

Ciò significa che i nostri nuovi oggetti del repository appartengono al grafico Dagger poiché vogliamo che più ViewModel siano in grado di accedere ai modelli tramite una singola istanza delle sorgenti Room o Retrofit sottostanti. I ViewModels daltra parte devono essere creati nuovi per ogni View che ne ha bisogno. Pensa a una pila di attività come una pila di carte da gioco e ViewModel guida il seme e il valore. Non vorremmo che latto di aggiungere un 3 di fiori in cima cambiasse anche tutte le carte sottostanti con un 3 di fiori, quindi ogni vista necessita della propria istanza di un ViewModel per preservare e isolare i propri dati.

Abbiamo ora definito cosa conterrà la nostra soluzione DI.
Mostra quali oggetti dovrebbero essere conservati da il grafico Dagger

Abbiamo deciso di mantenere i nostri ViewModel fuori dal nostro grafico Dagger. Storicamente, eravamo stati meno espliciti su questa scelta, ma abbiamo ritenuto che fosse giusta direzione data lo schema ViewModelProvider presente in androidx.lifecycle e come ci aiuta a consolidare la relazione tra lattività / frammento / XML e il ViewModel come “uno a uno”. Nel frattempo, la relazione ViewModel al repository può essere “molti a molti”. In pratica, questo significa che per ogni attività / frammento / XML abbiamo un singolo ViewModel che gestisce l ogic, ma può raggiungere molti repository per reperire i dati richiesti. Poiché i dati vengono generalmente riutilizzati e visualizzati nellapplicazione, molti ViewModel diversi possono utilizzare la stessa istanza del repository dal Dagger Graph in modo semplice ed efficiente.

Contenente lAPI

In qualsiasi azienda su larga scala, sono necessari molti ingegneri, team e persino divisioni per portare un progetto dalla lavagna alle mani del cliente. Qui a DraftKings, non è diverso. Per ottenere dati nellapplicazione Android, è necessario collaborare con alcuni team diversi sul back-end per trasferire i dati dal database allAPI al client Android. Dato che questo codice è spesso di proprietà di un altro team, significa che il backend generalmente “si sposta” a un ritmo diverso rispetto al client.

Ciò è particolarmente vero allinizio di un progetto che non disporre di unAPI in uno stato che possiamo utilizzare per il nostro sviluppo. Vorremmo prendere decisioni di progettazione e implementazione sugli oggetti di dati che vengono passati internamente al cliente senza preoccuparci troppo delle decisioni contrastanti prese dagli ingegneri che lavorano sul backend.

Oltre a ciò, abbiamo un pochi servizi che restituiscono gli stessi dati aziendali al cliente ma poiché sono di proprietà di team diversi e stanno cercando di risolvere problemi diversi, si sono allontanati luno dallaltro nella struttura e nei tipi di dati restituiti tramite le API.Interne per il client, in realtà rappresentano la stessa cosa, quindi essere in grado di tradurre e combinare queste risposte in qualcosa di universale per il client ha molto senso.

Qui puoi vedere che ci sono due endpoint sullAPI che restituiscono varianti di un “utente;” gli endpoint di accesso e profilo. Per unirli in un unico tipo di dati, creiamo semplicemente un costruttore per ogni variazione che vogliamo gestire e ora lApplicazione può limitare la conoscenza dei due diversi tipi di utenti che lAPI fornisce a un unico posto. Ciò semplifica notevolmente la condivisione dei dati (tramite il livello del modello) tra le funzionalità, consentendo comunque di limitare le modifiche alla struttura e ai tipi di dati dellAPI a un unico endpoint.

Stiamo facendo una distinzione tra gli oggetti Network Response e oggetti del modello di business nella nostra architettura. Questo aiuta anche a definire il ruolo dellorigine dati remota per prendere le risposte di rete e trasformarle in modelli di business per il resto della nostra applicazione.

La definizione dei tipi di dati prodotti da questi livelli aiuta anche a definire il ruolo dellorigine dati remota
Chiarire il tipo di dati inviati tra i livelli, consentendo un maggiore riutilizzo

Un esempio nel codice

Ora ci addentreremo nellesempio UserRepository di Google e creeremo la nostra versione attenendoci alle modifiche che abbiamo apportato sopra.

Per prima cosa, diamo unocchiata alla versione finale di Google del UserRepository.

Puoi vedere che stanno usando Dagger (o Hilt ) , Kotlin Coroutines , Room e, molto probabilmente, Retrofit. Fornire il servizio Retrofit, Coroutine executor e Dao (Data Access Object) al repository.

Il flusso di base di questo è fare una richiesta di rete per i dati e restituire i dati (o qualcosa che sta guardando per i dati) da Room immediatamente. Una volta completata la richiesta di rete, fai tutto ciò che devi fare con i dati e inserisci il nuovo oggetto nella tabella. Linserimento notifica automaticamente ai dati restituiti in precedenza che sono stati modificati, richiedendo un nuovo recupero e infine un aggiornamento della vista.

Alcune impostazioni

Prima di iniziare a creare il UserRepository, dovremmo prima affrontare alcune cose di cui avremo bisogno, come un modo utile per inserire i thread su cui vogliamo eseguire.

Questo ci aiuta con i test in seguito. Impostalo nel grafico Dagger e ora puoi inserire facilmente i thread corretti nellintera codebase pur essendo in grado di scambiarli con un TestScheduler nella tua unità test (stai scrivendo unit test … giusto?)

Ecco le nostre classi utente, UserResponse quella restituita dalla nostra API tramite Retrofit e User, il nostro modello di business lo trasmettiamo internamente. Puoi vedere che possiamo rendere semplice loggetto di rete e persino utilizzare un generatore di codice per crearlo mentre i nostri oggetti di business possono essere più in linea con ciò di cui abbiamo bisogno.

Qui stiamo definendo il nostro servizio di retrofit. Avremmo potuto farli restituire un Observable invece di un Single, che avrebbe reso la logica a valle un po più semplice, ma ci sono piaciuti i paralleli di come funzionano le richieste di rete e Single, sia asincrone che riuscite o non riuscite. Portiamo questa logica anche attraverso il livello Remote Data Source.

La prossima è la nostra stanza Dao. Poiché Room funziona già con interfacce e annotazioni, non abbiamo sentito la necessità di creare unaltra interfaccia, classe e oggetto per offuscare il modo in cui gestiamo i dati persistenti.

Stiamo utilizzando un Observable per reagire alle emissioni di User oggetti e facendo in modo che la nostra azione di inserimento restituisca un Completable per aiutarci a gestire gli eventuali errori di sala che potrebbero verificarsi.

Infine, ecco la nostra ultima interfaccia per lo stesso UserRepository. È molto semplice e pulito. Lunica parte aggiuntiva oltre a ciò che è richiesto è il metodo onCleared() che ci aiuterà a ripulire tutti gli elementi usa e getta esistenti nei nostri livelli inferiori come ViewModel viene cancellato.

Implementazioni

Noterai che la firma del costruttore è molto simile allesempio di Google sopra.Stiamo fornendo un oggetto che può recuperare dati sulla rete, un Dao e un oggetto che ci dice su quali thread eseguire.

Anche il metodo getUser(userId: Int) è simile nel modo in cui funziona nellesempio sopra. Crea una richiesta di rete asincrona e restituisci immediatamente un oggetto che sta guardando i dati dal database. Quando la richiesta di rete viene completata, inserisci i dati nella tabella, che attiverà un aggiornamento dellinterfaccia utente.

Noterai che non stiamo facendo nulla di troppo stravagante con RxJava qui. Creiamo un nuovo PublishSubject attraverso il quale incanaliamo i nostri dati. Lo uniamo alla selezione del livello persistente, che restituisce un Observable e inviamo anche errori dal livello di rete anche lì. Siamo andati avanti e indietro su come gestire il trasferimento solo degli errori desiderati da ogni livello al livello superiore, e questa è stata la soluzione più semplice e personalizzabile a cui siamo arrivati. Sì, crea osservabili extra, ma ci offre un controllo più preciso sulla gestione degli errori.

Lultima pezzo del puzzle è lorigine dati remota. Qui puoi vedere come stiamo convertendo il UserResponse in un User, consentendo al resto della base di codice di muoversi a un ritmo diverso rispetto al dati provenienti dallAPI.

Conclusione del tutto

A questo punto, dovresti aggiungere questi oggetti dallalto al grafico di Dagger. Ricorda che i metodi forniti dovrebbero restituire il tipo di interfaccia e non le implementazioni. In questo modo puoi costruire ogni oggetto definito sopra nel grafico Dagger e questo rende molto facile ottenere dati da un UserRepository ovunque nel codebase.

Infine, poiché stiamo utilizzando RxJava, dovremmo assicurarci di chiamare clear() su qualsiasi Disposable oggetti nel nostro ViewModel e chiama anche onCleared() sul nostro UserRepository per ripulire qualsiasi oggetto Disposable interno.

Ora abbiamo unimplementazione MVVM abbastanza semplice con un repository che ci viene fornito tramite il nostro grafico Dagger. I livelli che abbiamo creato hanno ciascuno uno scopo chiaro e sono trattenuti da interfacce che ci aiutano a filtrare ciò che esponiamo al mondo esterno. Questo ci consente anche di testare questi livelli indipendentemente luno dallaltro in isolamento utilizzando un RxJava TestScheduler che ci consente di avere il controllo completo sullesecuzione del test.

Lascia un commento

Il tuo indirizzo email non sarà pubblicato. I campi obbligatori sono contrassegnati *