Scopri i vantaggi di Android Clean Architecture
Pubblicato: 2022-03-11Cosa preferiresti: aggiungere una nuova funzionalità a un'app molto ben funzionante con un'architettura orribile o correggere un bug nell'applicazione Android ben progettata, ma piena di bug? Personalmente, sceglierei sicuramente la seconda opzione. L'aggiunta di una nuova funzionalità, anche semplice, può diventare molto laboriosa in un'app, considerando tutte le dipendenze da tutto in ogni classe. Ricordo uno dei miei progetti Android, in cui un project manager mi chiese di aggiungere una piccola funzionalità, qualcosa come scaricare i dati e visualizzarli su un nuovo schermo. Era un'app scritta da un mio collega che ha trovato un nuovo lavoro. La funzione non dovrebbe richiedere più di mezza giornata lavorativa. ero molto ottimista...
Dopo sette ore di indagine su come funziona l'app, quali moduli ci sono e come comunicano tra loro, ho realizzato alcune implementazioni di prova della funzionalità. Era l'inferno. Un piccolo cambiamento nel modello di dati ha forzato un grande cambiamento nella schermata di accesso. L'aggiunta di una richiesta di rete richiedeva modifiche all'implementazione di quasi tutte le schermate e della classe GodOnlyKnowsWhatThisClassDoes
. Le modifiche al colore dei pulsanti hanno causato un comportamento strano durante il salvataggio dei dati nel database o un arresto anomalo totale dell'app. A metà del giorno successivo, ho detto al mio project manager: "Abbiamo due modi per implementare la funzione. Innanzitutto, posso dedicarci altri tre giorni e alla fine lo implementerò in un modo molto sporco, e il tempo di implementazione di ogni successiva funzionalità o correzione di bug aumenterà in modo esponenziale. Oppure posso riscrivere l'app. Mi ci vorranno due o tre settimane, ma risparmieremo tempo per le future modifiche all'app.”” Fortunatamente, ha accettato la seconda opzione. Se ho mai avuto dubbi sul perché una buona architettura software in un'app (anche molto piccola) sia importante, questa app li ha dissipati totalmente. Ma quale modello di architettura Android dovremmo usare per evitare tali problemi?
In questo articolo, vorrei mostrarti un esempio di architettura pulita in un'app Android. Le idee principali di questo modello, tuttavia, possono essere adattate a ogni piattaforma e linguaggio. Una buona architettura dovrebbe essere indipendente da dettagli come piattaforma, lingua, sistema di database, input o output.
Esempio di app
Creeremo una semplice app Android per registrare la nostra posizione con le seguenti caratteristiche:
- L'utente può creare un account con un nome.
- L'utente può modificare il nome dell'account.
- L'utente può eliminare l'account.
- L'utente può selezionare l'account attivo.
- L'utente può salvare la posizione.
- L'utente può vedere l'elenco delle posizioni per un utente.
- L'utente può visualizzare un elenco di utenti.
Architettura pulita
I livelli sono il nucleo principale di un'architettura pulita. Nella nostra app utilizzeremo tre livelli: presentazione, dominio e modello. Ogni livello dovrebbe essere separato e non dovrebbe essere necessario conoscere altri livelli. Dovrebbe esistere nel suo mondo e, al massimo, condividere una piccola interfaccia per comunicare.
Responsabilità del livello:
- Dominio: contiene le regole aziendali della nostra app. Dovrebbe fornire casi d'uso che riflettono le funzionalità della nostra app.
- Presentazione: presenta i dati all'utente e raccoglie anche i dati necessari come il nome utente. Questo è un tipo di input/output.
- Modello: fornisce i dati per la nostra app. È responsabile dell'ottenimento dei dati da fonti esterne e del loro salvataggio nel database, nel server cloud, ecc.
Quali livelli dovrebbero sapere degli altri? Il modo più semplice per ottenere la risposta è pensare ai cambiamenti. Prendiamo il livello di presentazione: presenteremo qualcosa all'utente. Se cambiamo qualcosa nella presentazione, dovremmo apportare una modifica anche a un livello del modello? Immagina di avere una schermata "Utente" con il nome e il cognome dell'utente. Se vogliamo presentare le ultime due posizioni dell'utente invece di una sola, il nostro modello non dovrebbe risentirne. Quindi, abbiamo il primo principio: il livello di presentazione non conosce il livello del modello.
E, il contrario: il livello del modello dovrebbe sapere del livello di presentazione? Di nuovo, no, perché se cambiamo, ad esempio, l'origine dei dati da un database a una rete, non dovrebbe cambiare nulla nell'interfaccia utente (se hai pensato di aggiungere un caricatore qui, sì, ma possiamo anche avere un caricatore dell'interfaccia utente quando si utilizza un database). Quindi i due strati sono completamente separati. Grande!
E il livello di dominio? È il più importante perché contiene tutte le principali logiche di business. È qui che vogliamo elaborare i nostri dati prima di passarli al livello del modello o presentarli all'utente. Dovrebbe essere indipendente da qualsiasi altro livello: non sa nulla del database, della rete o dell'interfaccia utente. Poiché questo è il nucleo, gli altri livelli comunicheranno solo con questo. Perché vogliamo che questo sia completamente indipendente? Le regole aziendali probabilmente cambieranno meno spesso rispetto alle progettazioni dell'interfaccia utente o qualcosa nel database o nell'archiviazione di rete. Comunicheremo con questo livello tramite alcune interfacce fornite. Non utilizza alcun modello concreto o implementazione dell'interfaccia utente. Questi sono dettagli e ricorda: i dettagli cambiano. Una buona architettura non è legata ai dettagli.
Per ora basta teoria. Iniziamo a codificare! Questo articolo ruota attorno al codice, quindi, per una migliore comprensione, dovresti scaricare il codice da GitHub e controllare cosa contiene. Sono stati creati tre tag Git: architettura_v1, architettura_v2 e architettura_v3, che corrispondono alle parti dell'articolo.
Tecnologia dell'app
Nell'app, utilizzo Kotlin e Dagger 2 per l'iniezione delle dipendenze. Né Kotlin né Dagger 2 sono necessari qui, ma rende le cose molto più facili. Potresti essere sorpreso dal fatto che io non usi RxJava (né RxKotlin), ma non l'ho trovato utilizzabile qui e non mi piace usare nessuna libreria solo perché è in cima e qualcuno dice che è d'obbligo. Come ho detto, la lingua e le librerie sono dettagli, quindi puoi usare quello che vuoi. Vengono utilizzate anche alcune librerie di unit test Android: JUnit, Robolectric e Mockito.
Dominio
Il livello più importante nella progettazione dell'architettura dell'applicazione Android è il livello di dominio. Cominciamo con esso. È qui che si troveranno la nostra logica di business e le interfacce per comunicare con altri livelli. Il core principale è UseCase
s, che riflette ciò che l'utente può fare con la nostra app. Prepariamo un'astrazione per loro:
abstract class UseCase<out Type, in Params> { private var job: Deferred<OneOf<Failure, Type>>? = null abstract suspend fun run(params: Params): OneOf<Failure, Type> fun execute(params: Params, onResult: (OneOf<Failure, Type>) -> Unit) { job?.cancel() job = async(CommonPool) { run(params) } launch(UI) { val result = job!!.await() onResult(result) } } open fun cancel() { job?.cancel() } open class NoParams }
Ho deciso di usare le coroutine di Kotlin qui. Ogni UseCase
deve implementare un metodo run per fornire i dati. Questo metodo viene chiamato in un thread in background e, dopo la ricezione di un risultato, viene recapitato nel thread dell'interfaccia utente. Il tipo restituito è OneOf<F, T>
— possiamo restituire un errore o un successo con i dati:
sealed class OneOf<out E, out S> { data class Error<out E>(val error: E) : OneOf<E, Nothing>() data class Success<out S>(val data: S) : OneOf<Nothing, S>() val isSuccess get() = this is Success<S> val isError get() = this is Error<E> fun <E> error(error: E) = Error(error) fun <S> success(data: S) = Success(data) fun oneOf(onError: (E) -> Any, onSuccess: (S) -> Any): Any = when (this) { is Error -> onError(error) is Success -> onSuccess(data) } }
Il livello di dominio ha bisogno delle proprie entità, quindi il passaggio successivo è definirle. Per ora abbiamo due entità: User
e UserLocation
:
data class User(var id: Int? = null, val name: String, var isActive: Boolean = false) data class UserLocation(var id: Int? = null, val latitude: Double, val longitude: Double, val time: Long, val userId: Int)
Ora che sappiamo quali dati restituire, dobbiamo dichiarare le interfacce dei nostri fornitori di dati. Questi saranno IUsersRepository
e ILocationsRepository
. Devono essere implementati nel livello del modello:
interface IUsersRepository { fun setActiveUser(userId: Int): OneOf<Failure, User> fun getActiveUser(): OneOf<Failure, User?> fun createUser(user: User): OneOf<Failure, User> fun removeUser(userId: Int): OneOf<Failure, User?> fun editUser(user: User): OneOf<Failure, User> fun users(): OneOf<Failure, List<User>> } interface ILocationsRepository { fun locations(userId: Int): OneOf<Failure, List<UserLocation>> fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> }
Questo insieme di azioni dovrebbe essere sufficiente per fornire i dati necessari per l'app. In questa fase, non decidiamo come verranno archiviati i dati: questo è un dettaglio da cui vogliamo essere indipendenti. Per ora, il nostro livello di dominio non sa nemmeno di essere su Android. Cercheremo di mantenere questo stato (più o meno. Ti spiegherò più avanti).
L'ultimo (o quasi) passaggio consiste nel definire le implementazioni per i nostri UseCase
, che verranno utilizzati dai dati di presentazione. Sono tutti molto semplici (proprio come la nostra app e i dati sono semplici): le loro operazioni si limitano a chiamare un metodo appropriato dal repository, ad esempio:
class GetLocations @Inject constructor(private val repository: ILocationsRepository) : UseCase<List<UserLocation>, UserIdParams>() { override suspend fun run(params: UserIdParams): OneOf<Failure, List<UserLocation>> = repository.locations(params.userId) }
L'astrazione del Repository
rende i nostri UseCases
molto facili da testare: non dobbiamo preoccuparci di una rete o di un database. Può essere preso in giro in qualsiasi modo, quindi i nostri unit test testeranno casi d'uso reali e non altre classi non correlate. Questo renderà i nostri unit test semplici e veloci:
@RunWith(MockitoJUnitRunner::class) class GetLocationsTests { private lateinit var getLocations: GetLocations private val locations = listOf(UserLocation(1, 1.0, 1.0, 1L, 1)) @Mock private lateinit var locationsRepository: ILocationsRepository @Before fun setUp() { getLocations = GetLocations(locationsRepository) } @Test fun `should call getLocations locations`() { runBlocking { getLocations.run(UserIdParams(1)) } verify(locationsRepository, times(1)).locations(1) } @Test fun `should return locations obtained from locationsRepository`() { given { locationsRepository.locations(1) }.willReturn(OneOf.Success(locations)) val returnedLocations = runBlocking { getLocations.run(UserIdParams(1)) } returnedLocations shouldEqual OneOf.Success(locations) } }
Per ora, il livello di dominio è terminato.
Modello
Come sviluppatore Android, probabilmente sceglierai Room, la nuova libreria Android per l'archiviazione dei dati. Ma immaginiamo che il project manager abbia chiesto se è possibile rimandare la decisione sul database perché la direzione sta cercando di decidere tra Room, Realm e una nuova libreria di archiviazione super veloce. Abbiamo bisogno di alcuni dati per iniziare a lavorare con l'interfaccia utente, quindi per ora li terremo in memoria:
class MemoryLocationsRepository @Inject constructor(): ILocationsRepository { private val locations = mutableListOf<UserLocation>() override fun locations(userId: Int): OneOf<Failure, List<UserLocation>> = OneOf.Success(locations.filter { it.userId == userId }) override fun addLocation(location: UserLocation): OneOf<Failure, UserLocation> { val addedLocation = location.copy(id = locations.size + 1) locations.add(addedLocation) return OneOf.Success(addedLocation) } }
Presentazione
Due anni fa, ho scritto un articolo su MVP come un'ottima struttura di app per Android. Quando Google ha annunciato i grandi componenti dell'architettura, che hanno reso molto più semplice lo sviluppo di applicazioni Android, MVP non è più necessario e può essere sostituito da MVVM; tuttavia, alcune idee di questo modello sono ancora molto utili, come quella sulle viste stupide. Dovrebbero preoccuparsi solo della visualizzazione dei dati. Per raggiungere questo obiettivo, utilizzeremo ViewModel e LiveData.
Il design della nostra app è molto semplice: un'attività con navigazione in basso, in cui due voci di menu mostrano le locations
frammentate o gli users
frammentati. In queste viste utilizziamo ViewModels, che a loro volta utilizzano UseCase
s dal livello di dominio, mantenendo la comunicazione ordinata e semplice. Ad esempio, ecco LocationsViewModel
:
class LocationsViewModel @Inject constructor(private val getLocations: GetLocations, private val saveLocation: SaveLocation) : BaseViewModel() { var locations = MutableLiveData<List<UserLocation>>() fun loadLocations(userId: Int) { getLocations.execute(UserIdParams(userId)) { it.oneOf(::handleError, ::handleLocationsChange) } } fun saveLocation(location: UserLocation, onSaved: (UserLocation) -> Unit) { saveLocation.execute(UserLocationParams(location)) { it.oneOf(::handleError) { location -> handleLocationSave(location, onSaved) } } } private fun handleLocationSave(location: UserLocation, onSaved: (UserLocation) -> Unit) { val currentLocations = locations.value?.toMutableList() ?: mutableListOf() currentLocations.add(location) this.locations.value = currentLocations onSaved(location) } private fun handleLocationsChange(locations: List<UserLocation>) { this.locations.value = locations } }
Una piccola spiegazione per coloro che non hanno familiarità con ViewModels: i nostri dati sono archiviati nella variabile locations. Quando otteniamo i dati dal caso d'uso getLocations
, vengono passati al valore LiveData
. Questa modifica avviserà gli osservatori in modo che possano reagire e aggiornare i propri dati. Aggiungiamo un osservatore per i dati in un frammento:
class LocationsFragment : BaseFragment() { ... private fun initLocationsViewModel() { locationsViewModel = ViewModelProviders.of(activity!!, viewModelFactory)[LocationsViewModel::class.java] locationsViewModel.locations.observe(this, Observer<List<UserLocation>> { showLocations(it ?: emptyList()) }) locationsViewModel.error.observe(this, Observer<Failure> { handleError(it) }) } private fun showLocations(locations: List<UserLocation>) { locationsAdapter.locations = locations } private fun handleError(error: Failure?) { toast(R.string.user_fetch_error).show() } }
Ad ogni cambio di posizione, passiamo semplicemente i nuovi dati a un adattatore assegnato a una vista riciclatore, ed è qui che va il normale flusso Android per mostrare i dati in una vista riciclatore.
Poiché utilizziamo ViewModel nelle nostre viste, anche il loro comportamento è facile da testare: possiamo semplicemente prendere in giro i ViewModel e non preoccuparci dell'origine dati, della rete o di altri fattori:

@RunWith(RobolectricTestRunner::class) @Config(application = TestRegistryRobolectricApplication::class) class LocationsFragmentTests { private var usersViewModel = mock(UsersViewModel::class.java) private var locationsViewModel = mock(LocationsViewModel::class.java) lateinit var fragment: LocationsFragment @Before fun setUp() { UsersViewModelMock.intializeMock(usersViewModel) LocationsViewModelMock.intializeMock(locationsViewModel) fragment = LocationsFragment() fragment.viewModelFactory = ViewModelUtils.createFactoryForViewModels(usersViewModel, locationsViewModel) startFragment(fragment) } @Test fun `should getActiveUser on start`() { Mockito.verify(usersViewModel).getActiveUser() } @Test fun `should load locations from active user`() { usersViewModel.activeUserId.value = 1 Mockito.verify(locationsViewModel).loadLocations(1) } @Test fun `should display locations`() { val date = Date(1362919080000)//10-03-2013 13:38 locationsViewModel.locations.value = listOf(UserLocation(1, 1.0, 2.0, date.time, 1)) val recyclerView = fragment.find<RecyclerView>(R.id.locationsRecyclerView) recyclerView.measure(100, 100) recyclerView.layout(0,0, 100, 100) val adapter = recyclerView.adapter as LocationsListAdapter adapter.itemCount `should be` 1 val viewHolder = recyclerView.findViewHolderForAdapterPosition(0) as LocationsListAdapter.LocationViewHolder viewHolder.latitude.text `should equal` "Lat: 1.0" viewHolder.longitude.text `should equal` "Lng: 2.0" viewHolder.locationDate.text `should equal` "10-03-2013 13:38" } }
Potresti notare che anche il livello di presentazione è separato in livelli più piccoli con bordi chiari. Visualizzazioni come activities
, fragments
, ViewHolders
, ecc. sono responsabili solo della visualizzazione dei dati. Sono a conoscenza solo del livello ViewModel e usano solo quello per ottenere o inviare utenti e posizioni. È un ViewModel che comunica con il dominio. Le implementazioni di ViewModel sono le stesse per la vista come lo sono gli UseCase per il dominio. Per parafrasare, l'architettura pulita è come una cipolla: ha livelli e anche i livelli possono avere livelli.
Iniezione di dipendenza
Abbiamo creato tutte le classi per la nostra architettura, ma c'è un'altra cosa da fare: abbiamo bisogno di qualcosa che colleghi tutto insieme. I livelli di presentazione, dominio e modello sono mantenuti puliti, ma abbiamo bisogno di un modulo che sarà quello sporco e saprà tutto di tutto: grazie a questa conoscenza, sarà in grado di connettere i nostri livelli. Il modo migliore per farlo è utilizzare uno dei modelli di progettazione comuni (uno dei principi del codice pulito definiti in SOLID): l'iniezione di dipendenza, che crea oggetti appropriati per noi e li inserisce nelle dipendenze desiderate. Ho usato Dagger 2 qui (nel bel mezzo del progetto, ho cambiato la versione in 2.16, che ha meno standard), ma puoi usare qualsiasi meccanismo tu voglia. Di recente, ho giocato un po' con la libreria Koin e penso che valga anche la pena provare. Volevo usarlo qui, ma ho avuto molti problemi con la presa in giro dei ViewModel durante il test. Spero di trovare un modo per risolverli rapidamente, in tal caso, posso presentare differenze per questa app quando utilizzo Koin e Dagger 2.
Puoi controllare l'app per questa fase su GitHub con il tag architecture_v1.
I cambiamenti
Abbiamo finito i nostri livelli, testato l'app: tutto funziona! Tranne una cosa: dobbiamo ancora sapere quale database vuole utilizzare il nostro PM. Supponiamo che siano venuti da te e ti abbiano detto che la direzione ha accettato di utilizzare Room, ma vogliono comunque avere la possibilità di utilizzare la libreria più recente e superveloce in futuro, quindi è necessario tenere a mente potenziali cambiamenti. Inoltre, una delle parti interessate ha chiesto se i dati possono essere archiviati in un cloud e desidera conoscere il costo di tale modifica. Quindi, questo è il momento di verificare se la nostra architettura è buona e se possiamo modificare il sistema di archiviazione dei dati senza alcuna modifica nella presentazione o nel livello del dominio.
Cambia 1
La prima cosa quando si usa Room è definire le entità per un database. Ne abbiamo già alcuni: User
e UserLocation
. Tutto quello che dobbiamo fare è aggiungere annotazioni come @Entity
e @PrimaryKey
, quindi possiamo usarlo nel nostro livello di modello con un database. Grande! Questo è un modo eccellente per infrangere tutte le regole dell'architettura che volevamo mantenere. In realtà, l'entità di dominio non può essere convertita in un'entità di database in questo modo. Immagina di voler anche scaricare i dati da una rete. Potremmo utilizzare alcune classi in più per gestire le risposte di rete, convertendo le nostre entità semplici per farle funzionare con un database e una rete. Questo è il percorso più breve verso una futura catastrofe (e gridare: "Chi diavolo ha scritto questo codice?"). Abbiamo bisogno di classi di entità separate per ogni tipo di archiviazione dati che utilizziamo. Non costa molto, quindi definiamo correttamente le entità Room:
@Entity data class UserEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "name") var name: String, @ColumnInfo(name = "isActive") var isActive: Boolean = false ) @Entity(foreignKeys = [ ForeignKey(entity = UserEntity::class, parentColumns = [ "id" ], childColumns = [ "userId" ], onDelete = CASCADE) ]) data class UserLocationEntity( @PrimaryKey(autoGenerate = true) var id: Long?, @ColumnInfo(name = "latitude") var latitude: Double, @ColumnInfo(name = "longitude") var longitude: Double, @ColumnInfo(name = "time") var time: Long, @ColumnInfo(name = "userId") var userId: Long )
Come puoi vedere, sono quasi le stesse entità di dominio, quindi c'è una grande tentazione di unirle. Questo è solo un incidente: con dati più complicati, la somiglianza sarà minore.
Successivamente, dobbiamo implementare UserDAO
e UserLocationsDAO
, il nostro AppDatabase
e, infine, le implementazioni per IUsersRepository
e ILocationsRepository
. C'è un piccolo problema qui ILocationsRepository
dovrebbe restituire un UserLocation
, ma riceve un UserLocationEntity
dal database. Lo stesso vale per le classi relative User
. Nella direzione opposta, passiamo UserLocation
quando il database richiede UserLocationEntity
. Per risolvere questo problema, abbiamo bisogno Mappers
tra il nostro dominio e le entità di dati. Ho usato una delle mie funzionalità preferite di Kotlin: le estensioni. Ho creato un file chiamato Mapper.kt
e ho inserito tutti i metodi per la mappatura tra le classi (ovviamente, è nel livello del modello, il dominio non ne ha bisogno):
fun User.toEntity() = UserEntity(id?.toLong(), name, isActive) fun UserEntity.toUser() = User(this.id?.toInt(), name, isActive) fun UserLocation.toEntity() = UserLocationEntity(id?.toLong(), latitude, longitude, time, userId.toLong()) fun UserLocationEntity.toUserLocation() = UserLocation(id?.toInt(), latitude, longitude, time, userId.toInt())
La piccola bugia che ho menzionato prima riguarda le entità di dominio. Ho scritto che non sanno nulla di Android, ma questo non è del tutto vero. Ho aggiunto l'annotazione @Parcelize
all'entità User
e vi ho esteso Parcelable
, consentendo di passare l'entità a un frammento. Per strutture più complicate, dovremmo fornire le classi di dati del livello di visualizzazione e creare mappatori come tra i modelli di dati e di dominio. L'aggiunta di Parcelable
del dominio è un piccolo rischio che ho osato correre: ne sono consapevole e, in caso di modifiche all'entità User
, creerò classi di dati separate per la presentazione e rimuoverò Parcelable
dal livello del dominio.
L'ultima cosa da fare è modificare il nostro modulo di iniezione delle dipendenze per fornire l'implementazione Repository
appena creata invece del precedente MemoryRepository
. Dopo aver creato ed eseguito l'app, possiamo andare al PM per mostrare l'app funzionante con un database Room. Possiamo anche informare il PM che l'aggiunta di una rete non richiederà troppo tempo e che siamo aperti a qualsiasi libreria di archiviazione richiesta dalla direzione. Puoi controllare quali file sono stati modificati, solo quelli nel livello del modello. La nostra architettura è davvero ordinata! Ogni tipo di archiviazione successivo può essere costruito allo stesso modo, semplicemente estendendo i nostri repository e fornendo le implementazioni appropriate. Naturalmente, potrebbe risultare che abbiamo bisogno di più origini dati, come un database e una rete. Cosa poi? Niente da fare, dovremmo solo creare tre implementazioni di repository: una per la rete, una per il database e una principale, in cui verrebbe selezionata l'origine dati corretta (ad esempio, se disponiamo di una rete, caricare dal rete e, in caso contrario, caricare da un database).
Puoi controllare l'app per questa fase su GitHub con il tag architecture_v2.
Quindi, la giornata è quasi finita: sei seduto davanti al tuo computer con una tazza di caffè, l'app è pronta per essere inviata a Google Play, quando all'improvviso il project manager viene da te e ti chiede "Potresti aggiungere una funzionalità che può salvare la posizione attuale dell'utente dal GPS?"
Cambia 2
Tutto cambia... soprattutto il software. Questo è il motivo per cui abbiamo bisogno di codice pulito e architettura pulita. Tuttavia, anche le cose più pulite possono essere sporche se stiamo codificando senza pensare. Il primo pensiero quando si implementa l'acquisizione di una posizione dal GPS sarebbe aggiungere tutto il codice sensibile alla posizione nell'attività, eseguirlo nel nostro SaveLocationDialogFragment
e creare una nuova UserLocation
con i dati corrispondenti. Questo potrebbe essere il modo più veloce. Ma cosa succede se il nostro pazzo PM viene da noi e ci chiede di cambiare la posizione da GPS a qualche altro provider (ad esempio qualcosa come Bluetooth o rete)? I cambiamenti sarebbero presto sfuggiti di mano. Come possiamo farlo in modo pulito?
La posizione dell'utente è data. E ottenere una posizione è un UseCase
, quindi penso che anche il nostro dominio e i livelli di modello dovrebbero essere coinvolti qui. Quindi, abbiamo un altro UseCase
da implementare: GetCurrentLocation
. Abbiamo anche bisogno di qualcosa che ci fornisca una posizione: un'interfaccia ILocationProvider
, per rendere UseCase
indipendente da dettagli come il sensore GPS:
interface ILocationProvider { fun getLocation(): OneOf<Failure, SimpleLocation> fun cancel() } class GetCurrentLocation @Inject constructor(private val locationProvider: ILocationProvider) : UseCase<SimpleLocation, UseCase.NoParams>() { override suspend fun run(params: NoParams): OneOf<Failure, SimpleLocation> = locationProvider.getLocation() override fun cancel() { super.cancel() locationProvider.cancel() } }
Puoi vedere che abbiamo un metodo aggiuntivo qui: annulla. Questo perché abbiamo bisogno di un modo per annullare gli aggiornamenti della posizione GPS. La nostra implementazione del Provider
, definita nel livello del modello, va qui:
class GPSLocationProvider constructor(var activity: Activity) : ILocationProvider { private var locationManager: LocationManager? = null private var locationListener: GPSLocationListener? = null override fun getLocation(): OneOf<Failure, SimpleLocation> = runBlocking { val grantedResult = getLocationPermissions() if (grantedResult.isError) { val error = (grantedResult as OneOf.Error<Failure>).error OneOf.Error(error) } else { getLocationFromGPS() } } private suspend fun getLocationPermissions(): OneOf<Failure, Boolean> = suspendCoroutine { Dexter.withActivity(activity) .withPermission(Manifest.permission.ACCESS_FINE_LOCATION) .withListener(PermissionsCallback(it)) .check() } private suspend fun getLocationFromGPS(): OneOf<Failure, SimpleLocation> = suspendCoroutine { locationListener?.unsubscribe() locationManager = activity.getSystemService(Context.LOCATION_SERVICE) as LocationManager locationManager?.let { manager -> locationListener = GPSLocationListener(manager, it) launch(UI) { manager.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0L, 0.0f, locationListener) } } } override fun cancel() { locationListener?.unsubscribe() locationListener = null locationManager = null } }
Questo fornitore è pronto a lavorare con le coroutine Kotlin. Se ricordi, il metodo run di UseCase
viene chiamato su un thread in background, quindi dobbiamo assicurarci e contrassegnare correttamente i nostri thread. Come puoi vedere, dobbiamo passare un'attività qui: è di fondamentale importanza annullare gli aggiornamenti e annullare la registrazione dagli ascoltatori quando non ne abbiamo più bisogno per evitare perdite di memoria. Poiché implementa ILocationProvider
, possiamo facilmente modificarlo in futuro in qualche altro provider. Possiamo anche testare facilmente la gestione della posizione corrente (automaticamente o manualmente), anche senza abilitare il GPS nel nostro telefono: tutto ciò che dobbiamo fare è sostituire l'implementazione per restituire una posizione costruita in modo casuale. Per farlo funzionare, dobbiamo aggiungere UseCase
appena creato a LocationsViewModel
. Il ViewModel, a sua volta, deve avere un nuovo metodo, getCurrentLocation
, che chiamerà effettivamente il caso d'uso. Con solo alcune piccole modifiche all'interfaccia utente per chiamarlo e registrare GPSProvider in Dagger e voilà, la nostra app è finita!
Sommario
Stavo cercando di mostrarti come possiamo sviluppare un'app Android facile da mantenere, testare e modificare. Dovrebbe anche essere facile da capire: se qualcuno di nuovo arriva al tuo lavoro, non dovrebbe avere problemi a comprendere il flusso di dati o la struttura. Se sono consapevoli che l'architettura è pulita, possono essere sicuri che le modifiche nell'interfaccia utente non influiranno su nulla nel modello e l'aggiunta di una nuova funzionalità non richiederà più del previsto. Ma questa non è la fine del viaggio. Anche se disponiamo di un'app ben strutturata, è molto facile romperla con modifiche disordinate al codice "solo per un momento, solo per funzionare". Ricorda: non esiste un codice "solo per ora". Ogni codice che infrange le nostre regole può persistere nella base di codice e può essere una fonte di future interruzioni più grandi. Se arrivi a quel codice solo una settimana dopo, sembrerà che qualcuno abbia implementato alcune forti dipendenze in quel codice e, per risolverlo, dovrai scavare in molte altre parti dell'app. Una buona architettura di codice è una sfida non solo all'inizio del progetto, ma è una sfida per qualsiasi parte della vita di un'app Android. Pensare e controllare il codice dovrebbe essere preso in considerazione ogni volta che qualcosa cambierà. Per ricordarlo, puoi, ad esempio, stampare e appendere il diagramma dell'architettura Android. Puoi anche forzare un po' l'indipendenza dei livelli separandoli in tre moduli Gradle, dove il modulo di dominio non è a conoscenza degli altri e i moduli di presentazione e modello non si usano a vicenda. Ma nemmeno questo può sostituire la consapevolezza che il pasticcio nel codice dell'app si vendicherà di noi quando meno ce lo aspettiamo.