Descoperiți beneficiile arhitecturii curate Android

Publicat: 2022-03-11

Ce ați prefera: adăugarea unei noi funcții la o aplicație care funcționează foarte bine, cu o arhitectură îngrozitoare sau remediați o eroare în aplicația Android bine arhitecturată, dar cu erori? Personal, cu siguranță aș alege a doua variantă. Adăugarea unei noi funcții, chiar și a uneia simple, poate deveni foarte laborioasă într-o aplicație, ținând cont de toate dependențele din orice din fiecare clasă. Îmi amintesc de unul dintre proiectele mele Android, în care un manager de proiect mi-a cerut să adaug o mică caracteristică – ceva de genul descărcarea datelor și afișarea lor pe un nou ecran. A fost o aplicație scrisă de unul dintre colegii mei care și-a găsit un nou loc de muncă. Funcția nu ar trebui să dureze mai mult de jumătate de zi lucrătoare. am fost foarte optimist...

După șapte ore de investigare a modului în care funcționează aplicația, ce module există și modul în care acestea comunică între ele, am realizat câteva implementări de probă ale funcției. A fost iadul. O mică modificare a modelului de date a forțat o schimbare mare în ecranul de conectare. Adăugarea unei solicitări de rețea a necesitat modificări de implementare a aproape tuturor ecranelor și a clasei GodOnlyKnowsWhatThisClassDoes . Schimbările de culoare ale butoanelor au cauzat un comportament ciudat la salvarea datelor în baza de date sau o blocare totală a aplicației. La jumătatea zilei următoare, i-am spus managerului meu de proiect: „Avem două moduri de a implementa caracteristica. În primul rând, pot petrece încă trei zile pe el și, în cele din urmă, îl voi implementa într-un mod foarte murdar, iar timpul de implementare a fiecărei caracteristici sau remedieri de erori va crește exponențial. Sau pot rescrie aplicația. Acest lucru îmi va lua două sau trei săptămâni, dar vom economisi timp pentru viitoarele modificări ale aplicației.” Din fericire, a fost de acord cu a doua opțiune. Dacă am avut vreodată îndoieli de ce este importantă o arhitectură software bună într-o aplicație (chiar și una foarte mică), această aplicație le-a spulberat total. Dar ce model de arhitectură Android ar trebui să folosim pentru a evita astfel de probleme?

În acest articol, aș dori să vă arăt un exemplu de arhitectură curată într-o aplicație Android. Ideile principale ale acestui tipar, totuși, pot fi adaptate fiecărei platforme și limbi. Arhitectura bună ar trebui să fie independentă de detalii precum platforma, limbajul, sistemul de baze de date, intrarea sau ieșirea.

Exemplu de aplicație

Vom crea o aplicație Android simplă pentru a ne înregistra locația cu următoarele caracteristici:

  • Utilizatorul poate crea un cont cu un nume.
  • Utilizatorul poate edita numele contului.
  • Utilizatorul poate șterge contul.
  • Utilizatorul poate selecta contul activ.
  • Utilizatorul poate salva locația.
  • Utilizatorul poate vedea lista de locații pentru un utilizator.
  • Utilizatorul poate vedea o listă de utilizatori.

Arhitectură curată

Straturile sunt nucleul principal al unei arhitecturi curate. În aplicația noastră, vom folosi trei straturi: prezentare, domeniu și model. Fiecare strat ar trebui să fie separat și nu ar trebui să știți despre alte straturi. Ar trebui să existe în propria sa lume și, cel mult, să împartă o interfață mică pentru a comunica.

Responsabilități ale stratului:

  • Domeniu: Conține regulile de afaceri ale aplicației noastre. Ar trebui să ofere cazuri de utilizare care reflectă caracteristicile aplicației noastre.
  • Prezentare: Prezintă date utilizatorului și, de asemenea, colectează datele necesare, cum ar fi numele de utilizator. Acesta este un fel de intrare/ieșire.
  • Model: oferă date pentru aplicația noastră. Este responsabil pentru obținerea datelor din surse externe și salvarea lor în baza de date, server cloud etc.

Ce straturi ar trebui să știe despre celelalte? Cel mai simplu mod de a obține răspunsul este să te gândești la schimbări. Să luăm stratul de prezentare — vom prezenta ceva utilizatorului. Dacă schimbăm ceva în prezentare, ar trebui să facem și o schimbare într-un strat de model? Imaginați-vă că avem un ecran „Utilizator” cu numele utilizatorului și ultima locație. Dacă vrem să prezentăm ultimele două locații ale utilizatorului în loc de doar una, modelul nostru nu ar trebui să fie afectat. Deci, avem primul principiu: stratul de prezentare nu știe despre stratul model.

Și, invers, ar trebui stratul model să cunoască despre stratul de prezentare? Din nou, nu, pentru că dacă schimbăm, de exemplu, sursa datelor dintr-o bază de date într-o rețea, nu ar trebui să schimbe nimic în interfața de utilizare (dacă v-ați gândit să adăugați un încărcător aici - da, dar putem avea și un încărcător de interfață de utilizare) când se utilizează o bază de date). Deci cele două straturi sunt complet separate. Grozav!

Dar stratul de domeniu? Este cea mai importantă deoarece conține toată logica principală a afacerii. Aici dorim să ne procesăm datele înainte de a le trece la stratul de model sau de a le prezenta utilizatorului. Ar trebui să fie independent de orice alt nivel – nu știe nimic despre baza de date, rețea sau interfața cu utilizatorul. Deoarece acesta este nucleul, alte straturi vor comunica doar cu acesta. De ce vrem să avem acest lucru complet independent? Regulile de afaceri se vor schimba probabil mai rar decât designul UI sau ceva din baza de date sau stocarea în rețea. Vom comunica cu acest strat prin intermediul unor interfețe furnizate. Nu folosește niciun model concret sau implementare UI. Acestea sunt detalii și rețineți - detaliile se schimbă. O arhitectură bună nu este legată de detalii.

Suficientă teorie pentru moment. Să începem să codificăm! Acest articol se învârte în jurul codului, așa că, pentru o mai bună înțelegere, ar trebui să descărcați codul de pe GitHub și să verificați ce se află în interior. Sunt create trei etichete Git—architecture_v1, architecture_v2 și architecture_v3, care corespund părților articolului.

Tehnologia aplicației

În aplicație, folosesc Kotlin și Dagger 2 pentru injectarea dependenței. Nici Kotlin, nici Dagger 2 nu sunt necesare aici, dar face lucrurile mult mai ușoare. S-ar putea să fii surprins că nu folosesc RxJava (și nici RxKotlin), dar nu l-am găsit utilizabil aici și nu-mi place să folosesc nicio bibliotecă doar pentru că este deasupra și cineva spune că este o necesitate. După cum am spus, limba și bibliotecile sunt detalii, așa că puteți folosi ceea ce doriți. Sunt folosite și unele biblioteci de testare unitară Android: JUnit, Robolectric și Mockito.

Domeniu

Cel mai important strat din designul arhitecturii aplicației noastre Android este stratul de domeniu. Să începem cu el. Aici va fi logica noastră de afaceri și interfețele pentru a comunica cu alte straturi. Nucleul principal este UseCase -urile, care reflectă ceea ce utilizatorul poate face cu aplicația noastră. Să le pregătim o abstractizare:

 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 }

Am decis să folosesc coroutinele lui Kotlin aici. Fiecare UseCase trebuie să implementeze o metodă de rulare pentru a furniza datele. Această metodă este apelată pe un fir de execuție de fundal și, după ce este primit un rezultat, este livrat pe firul de execuție UI. Tipul returnat este OneOf<F, T> — putem returna o eroare sau succes cu datele:

 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) } }

Stratul de domeniu are nevoie de propriile entități, așa că următorul pas este definirea acestora. Avem două entități pentru moment: User și 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)

Acum că știm ce date să returnăm, trebuie să declarăm interfețele furnizorilor noștri de date. Acestea vor fi IUsersRepository și ILocationsRepository . Acestea trebuie implementate în stratul model:

 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> }

Acest set de acțiuni ar trebui să fie suficient pentru a furniza datele necesare pentru aplicație. În această etapă, nu decidem cum vor fi stocate datele - acesta este un detaliu de care dorim să fim independenți. Deocamdată, nivelul nostru de domeniu nici măcar nu știe că este pe Android. Vom încerca să păstrăm această stare (un fel de. Voi explica mai târziu).

Ultimul (sau aproape ultimul) pas este definirea implementărilor pentru UseCase -urile noastre, care vor fi folosite de datele de prezentare. Toate sunt foarte simple (la fel cum aplicația și datele noastre sunt simple) - operațiunile lor sunt limitate la apelarea unei metode adecvate din depozit, de exemplu:

 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) }

Abstracția Repository face ca UseCases noastre de utilizare să fie foarte ușor de testat - nu trebuie să ne pese de o rețea sau o bază de date. Poate fi batjocorit în orice fel, astfel încât testele noastre unitare vor testa cazurile reale de utilizare și nu alte clase, fără legătură. Acest lucru va face testele noastre unitare simple și rapide:

 @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) } }

Deocamdată, stratul de domeniu este terminat.

Model

În calitate de dezvoltator Android, probabil vei alege Room, noua bibliotecă Android pentru stocarea datelor. Dar să ne imaginăm că managerul de proiect a întrebat dacă puteți amâna decizia cu privire la baza de date, deoarece conducerea încearcă să decidă între Room, Realm și o nouă bibliotecă de stocare super rapidă. Avem nevoie de câteva date pentru a începe să lucrăm cu UI, așa că le vom păstra în memorie pentru moment:

 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) } }

Prezentare

Acum doi ani, am scris un articol despre MVP ca o structură de aplicație foarte bună pentru Android. Când Google a anunțat marile componente de arhitectură, care au făcut dezvoltarea aplicațiilor Android mult mai ușoară, MVP nu mai este necesar și poate fi înlocuit cu MVVM; cu toate acestea, unele idei din acest tipar sunt încă foarte utile - cum ar fi cea despre vederile proaste. Ar trebui să le pese doar de afișarea datelor. Pentru a realiza acest lucru, vom folosi ViewModel și LiveData.

Designul aplicației noastre este foarte simplu - o activitate cu navigare în jos, în care două intrări de meniu arată fragmentul locations sau fragmentul users . În aceste vizualizări folosim ViewModels, care la rândul lor folosesc UseCase uri din stratul de domeniu, păstrând comunicarea ordonată și simplă. De exemplu, aici este 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 } }

O mică explicație pentru cei care nu sunt familiarizați cu ViewModels - datele noastre sunt stocate în variabila locații. Când obținem date din cazul de utilizare getLocations , acestea sunt transmise valorii LiveData . Această modificare va anunța observatorii pentru ca aceștia să poată reacționa și să își actualizeze datele. Adăugăm un observator pentru datele dintr-un fragment:

 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() } }

La fiecare schimbare de locație, pur și simplu transmitem noile date unui adaptor alocat unei vizualizări de reciclare - și acolo merge fluxul normal Android pentru afișarea datelor într-o vizualizare de reciclare.

Deoarece folosim ViewModel în vizualizările noastre, comportamentul lor este, de asemenea, ușor de testat - putem doar să ne batem joc de ViewModels și să nu ne pese de sursa de date, rețea sau alți factori:

 @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" } }

Este posibil să observați că stratul de prezentare este, de asemenea, separat în straturi mai mici, cu margini clare. Vizualizări precum activities , fragments , ViewHolders etc. sunt responsabile doar pentru afișarea datelor. Ei sunt conștienți doar de stratul ViewModel și îl folosesc doar pentru a obține sau pentru a trimite utilizatori și locații. Este un ViewModel care comunică cu domeniul. Implementările ViewModel sunt aceleași pentru vizualizare ca și UseCase pentru domeniu. Pentru a parafraza, arhitectura curată este ca o ceapă - are straturi, iar straturile pot avea și straturi.

Injecție de dependență

Am creat toate clasele pentru arhitectura noastră, dar mai este un lucru de făcut: avem nevoie de ceva care să conecteze totul. Straturile de prezentare, domeniul și modelul sunt păstrate curate, dar avem nevoie de un modul care va fi cel murdar și va ști totul despre toate - prin această cunoaștere, va putea să ne conecteze straturile. Cel mai bun mod de a-l realiza este folosirea unuia dintre modelele obișnuite de design (unul dintre principiile de cod curat definite în SOLID) - injecția de dependență, care creează obiecte adecvate pentru noi și le injectează la dependențele dorite. Am folosit Dagger 2 aici (la mijlocul proiectului, am schimbat versiunea la 2.16, care are mai puțin boilerplate), dar puteți folosi orice mecanism doriți. Recent, m-am jucat puțin cu biblioteca Koin și cred că merită și el încercat. Am vrut să-l folosesc aici, dar am avut o mulțime de probleme cu baterea în joc de ViewModels la testare. Sper să găsesc o modalitate de a le rezolva rapid; dacă da, pot prezenta diferențe pentru această aplicație când folosesc Koin și Dagger 2.

Puteți verifica aplicația pentru această etapă pe GitHub cu eticheta architecture_v1.

Schimbări

Ne-am terminat straturile, am testat aplicația – totul funcționează! Cu excepția unui singur lucru – încă trebuie să știm ce bază de date vrea să utilizeze PM. Să presupunem că au venit la tine și au spus că conducerea a fost de acord să folosească Room, dar totuși doresc să aibă posibilitatea de a folosi cea mai nouă bibliotecă superrapidă în viitor, așa că trebuie să ții cont de potențialele schimbări. De asemenea, unul dintre părțile interesate a întrebat dacă datele pot fi stocate într-un cloud și dorește să cunoască costul unei astfel de modificări. Deci, acesta este momentul să verificăm dacă arhitectura noastră este bună și dacă putem schimba sistemul de stocare a datelor fără nicio modificare a prezentării sau a stratului de domeniu.

Schimbarea 1

Primul lucru atunci când utilizați Room este definirea entităților pentru o bază de date. Avem deja câteva: User și UserLocation . Tot ce trebuie să facem este să adăugăm adnotări precum @Entity și @PrimaryKey și apoi le putem folosi în stratul nostru de model cu o bază de date. Grozav! Aceasta este o modalitate excelentă de a încălca toate regulile de arhitectură pe care am vrut să le păstrăm. De fapt, entitatea de domeniu nu poate fi convertită într-o entitate de bază de date în acest fel. Imaginează-ți doar că vrem să descarcăm și datele dintr-o rețea. Am putea folosi mai multe clase pentru a gestiona răspunsurile rețelei - conversia entităților noastre simple pentru a le face să funcționeze cu o bază de date și o rețea. Aceasta este calea cea mai scurtă către o viitoare catastrofă (și strigătul: „Cine naiba a scris acest cod?”). Avem nevoie de clase de entități separate pentru fiecare tip de stocare de date pe care îl folosim. Nu costă mult, așa că haideți să definim corect entitățile 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 )

După cum puteți vedea, sunt aproape la fel ca entitățile de domeniu, așa că există o mare tentație de a le îmbina. Acesta este doar un accident - cu date mai complicate, asemănarea va fi mai mică.

În continuare, trebuie să implementăm UserDAO și UserLocationsDAO , AppDatabase și, în sfârșit, implementările pentru IUsersRepository și ILocationsRepository . Există o mică problemă aici ILocationsRepository ar trebui să returneze un UserLocation , dar primește o UserLocationEntity din baza de date. Același lucru este valabil și pentru clasele legate de User . În direcția opusă, trecem UserLocation atunci când baza de date necesită UserLocationEntity . Pentru a rezolva acest lucru, avem nevoie de Mappers între domeniul nostru și entitățile de date. Am folosit una dintre funcțiile mele preferate Kotlin — extensiile. Am creat un fișier numit Mapper.kt și am pus acolo toate metodele de mapare între clase (desigur, este în stratul model - domeniul nu are nevoie de el):

 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())

Mica minciună pe care am menționat-o mai înainte este despre entitățile de domeniu. Am scris că nu știu nimic despre Android, dar acest lucru nu este în întregime adevărat. Am adăugat adnotarea @Parcelize la entitatea User și am extins Parcelable acolo, făcând posibilă trecerea entității la un fragment. Pentru structuri mai complicate, ar trebui să furnizăm propriile clase de date ale stratului de vizualizare și să creăm mapatori, cum ar fi între modele de domeniu și de date. Adăugarea Parcelable la entitatea de domeniu este un risc mic pe care am îndrăznit să mi-l asum - sunt conștient de asta și, în cazul oricărei modificări a entității User , voi crea clase de date separate pentru prezentare și voi elimina Parcelable din stratul de domeniu.

Ultimul lucru de făcut este să ne schimbăm modulul de injectare a dependențelor pentru a furniza implementarea Repository nou creată în loc de MemoryRepository anterior. După ce construim și rulăm aplicația, putem merge la PM pentru a afișa aplicația de lucru cu o bază de date Room. De asemenea, putem informa PM că adăugarea unei rețele nu va dura prea mult timp și că suntem deschiși oricărei biblioteci de stocare pe care o dorește managementul. Puteți verifica ce fișiere au fost modificate — doar cele din stratul model. Arhitectura noastră este cu adevărat îngrijită! Fiecare tip de stocare următor poate fi construit în același mod, doar prin extinderea depozitelor noastre și furnizarea implementărilor adecvate. Desigur, s-ar putea dovedi că avem nevoie de mai multe surse de date, cum ar fi o bază de date și o rețea. Ce atunci? Nimic, ar trebui doar să creăm trei implementări de depozit - una pentru rețea, una pentru baza de date și una principală, unde ar fi selectată sursa corectă de date (de exemplu, dacă avem o rețea, încărcați din rețea și, dacă nu, încărcați dintr-o bază de date).

Puteți verifica aplicația pentru această etapă pe GitHub cu eticheta architecture_v2.

Așadar, ziua aproape s-a terminat – stai în fața computerului cu o ceașcă de cafea, aplicația este gata să fie trimisă pe Google Play, când deodată managerul de proiect vine la tine și te întreabă „Ați putea adăuga o funcție care să poate salva locația curentă a utilizatorului de pe GPS?”

Schimbarea 2

Totul se schimbă... în special software-ul. Acesta este motivul pentru care avem nevoie de cod curat și arhitectură curată. Cu toate acestea, chiar și cele mai curate lucruri pot fi murdare dacă codificăm fără să ne gândim. Primul gând la implementarea obținerii unei locații de la GPS ar fi să adăugați tot codul conștient de locație în activitate, să îl rulați în SaveLocationDialogFragment și să creați un nou UserLocation cu datele corespunzătoare. Aceasta ar putea fi cea mai rapidă cale. Dar ce se întâmplă dacă PM-ul nostru nebun vine la noi și ne cere să schimbăm locația de la GPS la alt furnizor (de exemplu, ceva de genul Bluetooth sau rețea)? Schimbările aveau să scape în curând de sub control. Cum o putem face într-un mod curat?

Locația utilizatorului este de date. Iar obținerea unei locații este un UseCase – așa că cred că domeniul nostru și straturile de model ar trebui să fie implicate și aici. Astfel, mai avem un UseCase de implementat: GetCurrentLocation . De asemenea, avem nevoie de ceva care să ne ofere o locație – o interfață ILocationProvider , pentru a face UseCase independent de detalii precum senzorul 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() } }

Puteți vedea că avem o metodă suplimentară aici: anulați. Acest lucru se datorează faptului că avem nevoie de o modalitate de a anula actualizările locației GPS. Implementarea Provider nostru, definită în stratul model, merge aici:

 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 } }

Acest furnizor este pregătit să lucreze cu coroutine Kotlin. Dacă vă amintiți, metoda de rulare a UseCase este apelată pe un thread de fundal, așa că trebuie să ne asigurăm și să ne marchem corect firele. După cum puteți vedea, trebuie să trecem o activitate aici - este extrem de important să anulăm actualizările și să anulăm înregistrarea de la ascultători atunci când nu mai avem nevoie de ele pentru a evita scurgerile de memorie. Deoarece implementează ILocationProvider , îl putem modifica cu ușurință în viitor către alt furnizor. De asemenea, putem testa cu ușurință gestionarea locației curente (automat sau manual), chiar și fără a activa GPS-ul în telefonul nostru - tot ce trebuie să facem este să înlocuim implementarea pentru a returna o locație construită aleatoriu. Pentru ca acesta să funcționeze, trebuie să adăugăm UseCase nou creat la LocationsViewModel . ViewModel, la rândul său, trebuie să aibă o nouă metodă, getCurrentLocation , care va apela de fapt cazul de utilizare. Cu doar câteva mici modificări ale interfeței de utilizare pentru a-l apela și a înregistra GPSProvider în Dagger - și voila, aplicația noastră este terminată!

rezumat

Încercam să vă arăt cum putem dezvolta o aplicație Android care este ușor de întreținut, testat și schimbat. Ar trebui să fie, de asemenea, ușor de înțeles - dacă cineva nou vine la munca ta, nu ar trebui să aibă probleme cu înțelegerea fluxului de date sau a structurii. Dacă sunt conștienți de faptul că arhitectura este curată, pot fi siguri că modificările în interfața de utilizare nu vor afecta nimic în model, iar adăugarea unei noi caracteristici nu va dura mai mult decât se prevede. Dar acesta nu este sfârșitul călătoriei. Chiar dacă avem o aplicație bine structurată, este foarte ușor să o spargem prin modificări dezordonate ale codului „doar pentru un moment, doar pentru a funcționa”. Amintiți-vă că nu există niciun cod „doar pentru moment”. Fiecare cod care ne încalcă regulile poate persista în baza de cod și poate fi o sursă de viitoare, mai mari întreruperi. Dacă ajungeți la acel cod doar o săptămână mai târziu, va părea că cineva a implementat niște dependențe puternice în acel cod și, pentru a-l rezolva, va trebui să cercetați multe alte părți ale aplicației. O arhitectură de cod bună este o provocare nu numai la începutul proiectului, ci este o provocare pentru orice parte a vieții unei aplicații Android. Gândirea și verificarea codului ar trebui să fie luate în considerare de fiecare dată când ceva se va schimba. Pentru a vă aminti acest lucru, puteți, de exemplu, să tipăriți și să agățați diagrama arhitecturii Android. De asemenea, puteți forța puțin independența straturilor, separându-le în trei module Gradle, unde modulul de domeniu nu este conștient de celelalte, iar modulele de prezentare și model nu se folosesc unul pe celălalt. Dar nici măcar acest lucru nu poate înlocui conștientizarea că mizeria din codul aplicației se va răzbuna pe noi atunci când ne așteptăm mai puțin.