Architettura pulita reattiva con componenti di architettura Android

Cosa succede se prendiamo l'architettura reattiva e pulita e i componenti dell'architettura Android rilasciati di recente e li mettiamo insieme? Questo è ciò che abbiamo fatto in N26 e il risultato funziona abbastanza bene:

  • Vi è una netta separazione delle preoccupazioni, che semplifica la navigazione nel codice delle funzionalità.
  • Fornisce una comprensione comune su come creare funzionalità, il che semplifica la revisione del codice reciproco.
  • Quando applichiamo questa architettura, creiamo più contenuti che possono essere riutilizzati e lo sviluppo diventa più veloce.
  • La programmazione reattiva porta un livello superiore di astrazione nel modo in cui codifichiamo, eliminando parte del peso dalla nostra parte.

In questo post iniziale spiegheremo le idee principali dietro l'architettura e come funziona, sarà più teorico che pratico, ecco perché abbiamo deciso di creare un secondo post che spieghi passo dopo passo come applicare questa architettura in un caso pratico:

Abbiamo anche creato una piccola app per mostrare l'architettura che si trova in questo repository:

Cosa intendiamo esattamente per reattivo?

Ci siamo resi conto che esistono diversi modi per utilizzare RxJava. Il più semplice è per le chiamate asincrone, in cui ti iscrivi a un osservabile che alla fine emetterà il successivo o il successivo errore e completerà in seguito, un tipico esempio di ciò sono gli osservabili delle chiamate di retrofit. È come una richiamata sugli steroidi, puoi controllare i thread in cui le cose vengono eseguite molto facilmente e ci sono un sacco di operatori molto potenti che ti consentono di combinare / trasformare il risultato. Questo è un ottimo modo per usare RxJava ma c'è di più.

Nella maggior parte delle app esiste una serie di dati che rappresentano le informazioni principali dell'app, su cui si basano le funzionalità. Ad esempio, in N26 abbiamo diverse schede nella schermata principale che mostrano credito, assicurazioni, tutti i movimenti nell'account, ecc. Tutte queste visualizzazioni mostrano le informazioni dell'utente che otteniamo dal back-end. Questi dati sono il cuore dell'app e tutto si basa su di esso.

Funzione assicurativa N26
E se potessimo osservare questi dati per sempre e riceverne una nuova versione ogni volta che cambia?

La differenza con lo scenario precedente è che i flussi non finiscono mai, quindi è possibile iscriversi a loro una volta e continuare a ricevere aggiornamenti dei dati osservati fino a quando il consumatore non decide di annullare l'iscrizione. In altre parole, il produttore non ha il compito di decidere quando termina il flusso, il consumatore deciderà quando interrompere la ricezione degli eventi annullando l'iscrizione. Questo è ciò che intendiamo per reattivo e l'idea alla base dell'architettura presentata in questa serie.

Architettura pulita reattiva

La maggior parte degli sviluppatori ha familiarità con l'architettura pulita e aiuta molto quando si tratta di separare le preoccupazioni nell'app. Abbiamo seguito le sue linee guida per separare le nostre funzionalità in tre livelli.

Livelli di architettura pulita

Dati

La responsabilità di questo livello è manipolare e coordinare le diverse fonti di dati. Ha tre componenti principali: il repository, i servizi di rete e l'archivio reattivo.

L'idea principale è quella di ottenere i dati dall'origine esterna attraverso i servizi di rete, archiviare questi dati all'interno dell'archivio reattivo, che può essere visto come l'origine dati interna ed esporre i dati attraverso il repository al livello superiore.

Componenti principali del livello dati

Il repository

È il punto di accesso e l'interfaccia verso l'esterno. Queste sono le quattro operazioni principali che sono esposte in questo livello:

  • Ottieni: restituisce un flusso infinito che emetterà aggiornamenti del tipo di dati specificato ma non garantisce che all'interno ci sia un valore. Lo stream viene creato in modo che venga emesso subito dopo l'abbonamento e NESSUNO quando il negozio è vuoto. Questo flusso è progettato per non completare o errore.
  • Recupera: recupera i dati da origini esterne, in genere dall'API, e archivia i dati una volta ricevuti. Questa è l'azione che alimenta il "Get". Restituisce un Completabile che segnala se il recupero è stato completato o si è verificato un errore. Si noti che non esiste alcun evento onNext in Completabile, ciò significa che i dati recuperati non verranno restituiti in alcun modo tramite il metodo fetch.
  • Richiesta: questa è un'alternativa all'operazione di recupero quando non abbiamo bisogno della controparte del flusso infinito. Restituisce un singolo che restituirà i dati nell'evento onNext e completerà o si verificherà un errore in caso di problemi.
  • Push, Delete: invia o elimina i dati da e verso fonti esterne. Fornisce un singolo o un completabile in base alla presenza di dati provenienti da queste operazioni.

Il negozio reattivo

Questo è il componente dietro Get. Contiene i dati e fornisce flussi infiniti ad esso. È la parte più complessa dell'intera architettura e deve essere progettata con cura: questo livello conterrà i dati e sarà accessibile in modo asincrono, deve essere solido contro la concorrenza.

Esistono molti modi per implementare questo componente a seconda della natura delle funzionalità nell'app. Perché l'architettura funzioni, ecco come dovrebbe apparire l'interfaccia:

interfaccia pubblica ReactiveStore  {
    Fluido > getSingular (chiave chiave finale @NonNull);
    Fluido  >> getAll ();
    void storeSingular (modello di valore finale @NonNull);
    void storeAll (@NonNull final List  modelList);
}
  • Fornisce due tipi di metodi "get": getSingular (id), per ottenere uno stream che emetterà aggiornamenti dell'elemento singolare con l'id passato, e getAll (), per ottenere uno stream che emetterà aggiornamenti quando ci sono cambiamenti in uno degli elementi all'interno del negozio.
  • Fornisce due tipi di metodi "store": storeSingular (object) e storeAll ().
  • Quando viene chiamato storeSingular (oggetto), dovrebbero essere emessi sia getSingular (id) che getAll ().
  • Quando viene chiamato storeAll (), devono emettere getAll () e tutti gli getSingular (id) esistenti.
  • Sia getAll () che getSingular (id) emettono subito dopo l'abbonamento.
  • Sia getAll () che getSingular (id) emettono NONE quando il negozio è vuoto.

I flussi vengono creati su richiesta, in questo modo sappiamo quando vengono osservati i dati e in questo caso aggiorniamo il flusso.

Alcune altre operazioni potrebbero essere aggiunte, come replAll () o clear (), purché gli stream vengano aggiornati in modo coerente.

Con questi componenti possiamo coordinare il processo di archiviazione dei dati in una o più forme di archiviazione. Il più semplice sarebbe avere una cache di memoria, ma potrebbe anche essere un database, il file system, le preferenze condivise o qualsiasi altra cosa che possa essere considerata un metodo di persistenza. È anche possibile avere una combinazione di uno o più di questi metodi, ad esempio la combinazione di cache di memoria nella parte superiore del database. Le possibilità sono molte, dobbiamo solo coordinare il processo di memorizzazione degli oggetti e alimentare i flussi di conseguenza.

Vogliamo anche condividere con voi alcune delle soluzioni che abbiamo trovato in N26 per il negozio reattivo. Presto arriveranno in diversi post del blog, rimanete sintonizzati!

Mappatura prima della memorizzazione

Il piano è quello di mettere nel negozio gli oggetti che verranno consumati dagli strati in alto. Questo livello creerà casi d'uso da questi oggetti, quindi assicuriamoci che i dati che memorizziamo siano in buone condizioni.

La maggior parte delle app ottiene i dati da un'API, che ha un contratto che descrive quali dati e in quale formato questa API dovrebbe servire i dati. Nel 99% dei casi questo contratto è soddisfatto ma ... cosa succede quando non lo è? Potremmo passare ore a eseguire il debug, pensando che ci sia un bug nel codice dell'app quando il problema reale è un campo mancante nella risposta API.

Cosa succede se progettiamo il punto di ingresso dell'app per verificare una serie di vincoli o condizioni e se non vengono rispettati, viene visualizzato un messaggio di errore che descrive esattamente cosa non va?

Ciò può essere ottenuto definendo due POJO, uno che rappresenta l'oggetto che proviene da una fonte esterna, ci riferiamo a questo come entità "grezza" e un altro che è l'oggetto mappato, quello che è stato verificato ed è sicuro per memorizzare, chiamiamo questa entità "sicura". Mappando l'entità grezza sull'entità sicura prima di memorizzarla, stiamo creando una prima linea di difesa.

Mappatura entità da grezza a sicura

Una delle cose che facciamo nell'app N26 è quella di sbarazzarci del nullo nel nostro codice il più possibile, usiamo invece le Opzioni per rappresentare la possibilità dell'assenza di un oggetto. Più precisamente usiamo la libreria Opzioni di Tomek Polański.

Il posto perfetto per sbarazzarsi di null è in questo mapper, dove ogni parametro può essere controllato e mappato su Opzione nel caso vada bene quando manca, o, al contrario, genera un'eccezione se è un parametro obbligatorio per il funzione per operare.

Dominio

Questo livello si trova in cima ai dati ed è responsabile del coordinamento delle azioni al repository. Può anche eseguire alcune mappature per preparare gli oggetti provenienti dal livello dati, in questo modo il livello di presentazione può consumarli facilmente. Chiamiamo il componente principale in questo strato l'Interattore Reactive. Come puoi vedere nello snippet di seguito, abbiamo definito diversi tipi per descrivere la natura delle diverse operazioni.

interfaccia SendInteractor  {

    Singolo  getSingle (opzione  parametri);
}
interfaccia DeleteInteractor  {
    Singolo  getSingle (opzione  parametri);
}
interfaccia RetrieveInteractor  {

    Fluable  getBehaviorStream (opzione  params);
}

interfaccia RefreshInteractor  {

    GetSingle completa (opzione  parametri);
}
Interfaccia RequestInteractor  {

    Singolo  getSingle (opzione  parametri);
}

Tutti definiscono un'opzione come input. In questo modo possiamo trasmettere qualsiasi input, se necessario, ma possiamo anche passare NESSUNO nel caso in cui l'operazione specifica non richieda nulla.

Restituiscono diversi tipi di oggetti reattivi a seconda della natura dell'operazione:

  • SendInteractor, DeleteInteractor e RequestInteractor, restituiscono tutti un singolo poiché la natura di tutte queste operazioni è di fornire un risultato completo.
  • RetrieveInteractor restituisce un Flowable progettato per non essere mai completato. Questa può essere vista come la versione di dominio dell'operazione Get del livello dati, sebbene ci siano alcune differenze importanti. Get del livello dati può essere visto come una "pipe" verso una qualche forma di archiviazione. Non garantisce che ci sarà un valore effettivo nel negozio, nel qual caso lo notificherà emettendo NESSUNO come visto in precedenza. RetrieveInteractor è diverso poiché deve garantire il valore. Ciò significa che deve fare tutto il possibile per ottenere questo valore o errore nel caso in cui non fosse possibile. Questo di solito significa innescare un recupero se il Flowable dal Get del livello dati emette NESSUNO.
  • RefreshInteractor è incaricato di eseguire le azioni necessarie per aggiornare il livello dati. Questa operazione provoca l'emissione del Flowable del RetrieveInteractor a causa della natura del livello dati.

Gli Interattatori reattivi possono essere combinati e nidificati. Può essere visto come un diverso livello di interattori. L'interattatore di livello più basso è quello che accede direttamente al repository. Un interattore di livello superiore potrebbe utilizzare uno o più di questi interattori di livello inferiore e mappare i risultati. L'idea è che ogni interattore fa solo una cosa e una cosa, in questo modo è come avere pezzi che puoi mettere insieme per costruire qualcosa di più grande.

Il dominio è solo Java al 100%, il che significa che qui non ci sono oggetti correlati al framework Android.

Presentazione

Questo è l'ultimo livello, responsabile della costruzione degli oggetti che le viste consumeranno e dell'elaborazione delle azioni eseguite in queste viste. Questo è anche il livello in cui vengono utilizzati i componenti dell'architettura Android, in particolare LiveData e ViewModel.

Il modello per comunicare con le viste è MVVM. L'idea è che ViewModel fornisce alla vista un oggetto di LiveData da utilizzare. L'entità vista deve essere progettata per rappresentare il più vicino possibile lo stato di una vista specifica. Qualche tempo fa ho scritto un post sul blog su come progettare queste entità in modo sicuro, cose che devono essere prese in considerazione quando si ha a che fare con RxJava + MVVM.

Nel modello MVVM, ViewModel è il componente che interagisce con le viste, questo porta ad avere spesso modelli di viste grandi, specialmente quando lo schermo è complesso. Abbiamo cercato di semplificare i nostri modelli di vista delegando alcune responsabilità ad altri componenti:

  • Mapper e trasformatori: trasformano gli oggetti provenienti dal livello del dominio per visualizzare le entità.
  • Fornitori: a volte abbiamo bisogno di qualcosa dal framework per costruire la nostra entità di visualizzazione, ad esempio, potremmo aver bisogno di una stringa specifica. Creiamo StringProvider per astrarre l'accesso a questa risorsa.
  • Utilità: non c'è molto da dire qui, queste sono le classiche classi di utilità che contengono funzioni di supporto.

Il modello di visualizzazione non dovrebbe fare nessuna delle cose che possono fare i componenti sopra. Il modello vista dovrebbe coordinare il processo di creazione delle entità vista e mettere insieme tutti i pezzi. Dopo aver fatto questo, abbiamo notato diversi vantaggi: il più ovvio è che i modelli di vista hanno dimensioni molto più ridotte, tutto è stato più facile da testare poiché ogni classe ha una responsabilità più specifica e ristretta, è più chiaro quali sono gli input / output e cosa ci si aspetta da ogni classe. Naturalmente questo rende anche l'intero livello di presentazione più comprensibile e leggibile.

L'importanza di un'entità vista ben progettata

Un errore comune è passare alla vista un oggetto che non è stato progettato per questo, di solito perché non vogliamo creare un altro oggetto specifico per la vista, quindi preferiamo semplicemente passare quello che proviene dai nostri dati o livello di dominio. Questo dovrebbe essere evitato perché significa che la vista deve fare alcune trasformazioni finali per poterlo consumare, lasciando il codice che contiene una logica non testata.

Nella nostra esperienza, il passaggio più critico nel livello di presentazione è la progettazione delle entità di visualizzazione. La creazione di questi POJO è in un certo senso l'obiettivo principale del livello di presentazione e definisce quanto sia leggibile, comprensibile e verificabile l'intero livello.

Prenditi il ​​tuo tempo a progettare le entità di visualizzazione. Non vuoi avere a che fare con uno mal progettato ed è doloroso modificarli quando l'implementazione del livello di presentazione è avanzata.

Natura asincrona

Quando si ha a che fare con questo tipo di architettura è importante presentarne la natura asincrona. L'architettura utilizza ampiamente flussi, questo significa che stiamo abbracciando la programmazione asincrona. RxJava ci consente di farlo in un modo molto semplice, è molto potente, ma i grandi poteri hanno sempre grandi responsabilità.

L'architettura si basa sull'idea della programmazione basata su eventi: ottieni eventi che puoi combinare, trasformare o fare altre cose attraverso i flussi, ma gli eventi sono pensati per essere transitori e immutabili, esistono in un certo momento all'interno di un funzione nella catena, fino a quando non vengono trasformati in qualche altro oggetto e tramandati. Quindi NON:

  • Muta l'oggetto nell'evento, creane sempre uno nuovo. Ciò può essere ottenuto applicando l'immutabilità in tutti gli oggetti, che è una buona pratica generale.
  • Memorizza l'oggetto dall'evento in una variabile locale / globale. L'ambito di questo oggetto è nella funzione e non dovrebbe esistere all'esterno.

L'ultimo punto mira a eliminare tutti gli stati intermedi interni non necessari che a volte creiamo pensando che renderanno le nostre vite più facili. Ad esempio, una variabile nella classe per memorizzare l'ultimo risultato di un'operazione che vogliamo confrontare con la prossima volta o un flag interno per sapere se un'operazione è già stata eseguita. Quando creiamo questo tipo di stati che non abbiamo il controllo, ignoriamo la natura asincrona del nostro codice, ma ignorarlo non lo fa scomparire, stiamo creando una trappola per noi stessi, questo è lo scenario perfetto per gli stati corrotti e condizioni di gara. RxJava fornisce all'operatore memoria, quindi usali, sono stati progettati per funzionare in un ambiente asincrono reattivo.

Questa è la fine di questo primo post, se sei pronto a vedere questa architettura in azione con un esempio pratico dai un'occhiata al post di follow-up!

Ti interessa unirti a uno dei nostri team mentre cresciamo?

Se desideri unirti a noi nel viaggio di costruzione della banca mobile che il mondo ama usare, dai un'occhiata ad alcuni dei ruoli tecnici che stiamo cercando qui.