Android, MVVM og Repositories in the Real World

(Mike DeMaso) (2. nov. 2020)

Hvordan oppnår du et responsivt brukergrensesnitt mens du holder koden ren, presis, lesbar og vedlikeholdbar? Dette er et spørsmål som har spratt rundt hjernen til Android-utviklere siden 2008.

Etter mange år med å la Android-utviklere finne ut av det på egenhånd, har Google nylig gitt noen veiledning om emnet med sine Guide til apparkitektur som promoterer en variant av MVVM . Selv om dette er en god start, etterlater det mange spørsmål som lagene kan svare på egen hånd. Google tilbyr også biblioteker som LiveData , Rom og DataBinding for å hjelpe oss med å redusere kokeplater og skille kodenes bekymringer. Selv tredjeparter har lånt ut en hjelpende hånd med RxJava og Ettermontering , noe som gir oss en enkel måte å håndtere asynkront arbeid på og hente ting over nettverket. Når alle disse tingene flyter rundt og løser små biter av det større spørsmålet, hvordan kan vi bringe dem alle sammen for å gi en flytende brukergrensesnittopplevelse som er enkel å implementere og vedlikeholde?

I denne artikkelen vil vi dele det vi har lært av våre tidligere forsøk på å lage en MVVM-arkitektur og viser et enkelt eksempel på hvordan vi kan bringe alle disse brikkene i puslespillet sammen.

En rask beskrivelse av MVVM

For å gi deg en forståelse av hva et depot skal løse, la oss først dykke ned i det grunnleggende MVVM-mønsteret.

Det grunnleggende MVVM-mønster
Grunnleggende MVVM-diagram

Visningen

Dette er representasjonen av det grafiske brukergrensesnittet for koden din. Disse kan representeres av XML (layoutfiler) eller kode (Jetpack Compose). Vanligvis er det noen form for databinding som knytter View til ViewModel. Avhengig av hvor du trekker linjen mellom en visning og et bindemiddel, kan objektet Aktivitet og Fragment betraktes som en eller begge.

ViewModel

Dette er ansvarlig for å transformere data fra modellen til et format som gir mening for visningen å vises. Ikke la den doble forbindelsen mellom visningen og ViewModel lure deg. Den største forskjellen mellom en ViewModel og presentatøren i MVP er at ViewModel ikke inneholder en referanse til visningen. Det er en enveis forhold, noe som betyr at noe annet trenger å administrere ViewModel-objektet.

Modell

Dette refererer til dataene (eller domenemodellen) som brukes som kilde til informasjonen ViewModel gir til visningen. Dette laget er der ting blir uskarpe som noen artikler refererer til det som selve dataene mens andre omtaler det som et datatilgangslag. Dette er hvor Googles depotmønster trer inn for å rydde opp i ting.

Skriv inn depotet

Google har vært å nevne dette mønsteret i noen tid nå. Eksemplene deres er en god guide for å forstå det grunnleggende prinsippet om å bruke MVVM med et arkiv, men vi synes de mangler noen små ( og viktig) veiledning for å hjelpe folk med å oversette disse kodebitene til et større, mer komplekst prosjekt.

Googles MVVM-diagram

Depotmønsteret er designet for å» gi et rent API så at resten av appen enkelt kan hente disse dataene. ” Dessverre, bare å legge til et arkiv i arkitekturen din, tvinger ikke koden din til å være ren. Du kan fortsatt lage et sammenfiltret rot uten å skille ordentlig og gi en klar kontrakt mellom lagene.

På DraftKings har vi fokusert på noen få ytterligere retningslinjer for å hjelpe utviklerne våre med å produsere ren kode konsekvent.

Koble lag med grensesnitt

Her legger vi til i grensesnitt for å be ingeniører om å tenke på hva de offentlig eksponerer
Forbedring av Googles diagram ved å legge til grensesnitt

Vi har slått fast at bruk av grensesnitt mellom disse lagene vil hjelpe ingeniører på alle nivåer å holde seg til gode kodingsprinsipper. Dette bidrar til å sikre at enhetstestene våre egentlig bare tester ett lag om gangen, noe som reduserer kostnadene ved å skrive og opprettholder en stor testpakke. Det hjelper også med å tydelig definere API-er som vender utover, og forvirrer implementeringsdetaljene til de forskjellige lagene. Det ber oss om å evaluere hva vi forteller omverdenen om funksjonen til dette objektet, og gir oss en innebygd mulighet til å sikre at koden vår er ren.

Det gir oss også muligheten til å omformere forskjellige lag i kodebasen vår mer effektivt. Så lenge grensesnittet ikke endres, kan vi la områdene i kodebasen vår bruke dem uberørt. Hvis vi for eksempel ønsket å migrere nettverksbiblioteket vårt fra Volley til Retrofit, kunne vi ganske enkelt endre metodene i våre Dagger-klasser som produsere og gi grensesnittet over den eksterne datakilden og ikke trenger å gjøre endringer i hvert depot som bruker dette endepunktet. Dette reduserer omfanget av slike endringer sterkt og reduserer sjansen for at vi ville introdusere feil i det endelige produktet.

Her har vi et eksempel på hvordan å la en ViewModel holde fast i den konkrete klassen i et depot kan føre til utilsiktet oppførsel. Eksemplet er litt konstruert og kan rettes opp ved å merke fooPublishSubject i FooRepository som private, men den løsningen er mer sprø. FooRepository må brukes i et annet omfang som krever tilgang til parameteren, og å åpne tilgang for forekomster som nå forvirrer wh no er det hensiktsmessig å bruke denne medlemsvariabelen direkte.

Leverer disse avhengighetene

Når kompleksiteten i prosjektet vokser, jo mer kompliserte blir avhengighetsforholdene dine. Dette betyr at folk vanligvis henvender seg til et slags Dependency Injection-bibliotek (som Dagger eller Koin ) .

Ikke bare gir et DI-bibliotek en ren og enkel måte å hente de nødvendige avhengighetene på, det lar deg også tenke på hvor mange av disse objektene du trenger i applikasjonen.

Denne tankeprosessen fikk oss til å etablere den beste fremgangsmåten for hvilke gjenstander som hører hjemme i Dagger-grafen. Alt vi bare vil ha en enkelt forekomst av, skal leve i rot / global / applikasjonsomfattet graf. Alt som det kan være mange forekomster av, bør opprettes på forespørsel og holdes på riktig måte.

Dette betydde at de nye depotobjektene våre hører hjemme i Dagger-grafen, ettersom vi vil at flere ViewModels skal kunne få tilgang til modeller. via en enkelt forekomst av de underliggende rom- eller ettermonteringskildene. ViewModels derimot må opprettes nytt for hver View som trenger dem. Tenk på en bunke aktiviteter som en bunke med spillkort, og ViewModel driver dressen og verdien. Vi ønsker ikke å legge til en tre klubber øverst for å endre alle kortene nedenfor til en tre av klubber, så hver visning trenger sin egen forekomst av en ViewModel for å bevare og isolere dataene.

Vi har nå definert hva DI-løsningen vår nå vil inneholde.
Vis hvilke objekter som forventes å bli holdt av Dagger Graph

Vi bestemte oss for å holde våre ViewModels utenfor Dagger-grafen. Historisk sett hadde vi vært mindre eksplisitte om dette valget, men vi følte at dette er riktig retning gitt ViewModelProvider -mønsteret som kommer i androidx.lifecycle og hvordan det hjelper oss med å styrke forholdet mellom aktiviteten / fragmentet / XML og ViewModel som «en mot en». Imens kan ViewModel til repository-forholdet være «mange for mange». I praksis betyr dette at for hver aktivitet / fragment / XML har vi en enkelt ViewModel som håndterer visningens l ogisk, men det kan nå ut til mange arkiver for å hente de nødvendige dataene. Ettersom data vanligvis blir brukt på nytt og vises i hele applikasjonen, kan mange forskjellige ViewModels bruke samme forekomst av depotet fra Dagger Graph enkelt og effektivt.

Inneholder API

I alle selskaper i målestokk tar det mange ingeniører, team og til og med divisjoner å få et prosjekt fra tavlen til kundens hender. Her på DraftKings er det ikke annerledes. For å få data inn i Android-applikasjonen, må vi samarbeide med noen forskjellige team på backend for å få dataene fra databasen til API til Android-klienten. Gitt at denne koden ofte eies av et annet team, betyr det at backend generelt vil «bevege seg» i et annet tempo enn klienten.

Dette gjelder spesielt i starten av et prosjekt som ikke ha en API i en tilstand som vi kan bruke til vår utvikling. Vi vil ta beslutninger om design og implementering om dataobjektene som blir sendt internt til klienten uten å bekymre deg for mye om motstridende beslutninger som ingeniørene som jobber på backend tar.

Utover det har vi en få tjenester som returnerer de samme forretningsdataene til klienten, men fordi de eies av forskjellige team og prøver å løse forskjellige problemer, har de gledet seg fra hverandre i strukturen og typene data som returneres via API-ene.Internt for klienten representerer disse faktisk det samme, så det er veldig fornuftig å kunne oversette og kombinere disse svarene til noe universelt for klienten.

Her kan du se at det er to sluttpunkter på API-en som returnerer varianter av en “bruker;” påloggings- og profilendepunktene. For å slå dem sammen til en datatype, lager vi ganske enkelt en konstruktør for hver variant vi vil håndtere, og nå kan applikasjonen begrense kunnskapen til de to forskjellige typene brukere som API-en leverer til et enkelt sted. Dette gjør deling av data (via modellaget) mellom funksjonene mye enklere, samtidig som endringer i API-datastrukturen og -typene begrenses til det ene endepunktet.

Vi skiller mellom Network Response-objekter og forretningsmodellobjekter i vår arkitektur. Dette hjelper også med å definere rollen til den eksterne datakilden for å ta nettverksresponser og transformere dem til forretningsmodeller for resten av applikasjonen vår.

Definerer datatypene som disse lagene produserer, dette hjelper også med å definere rollen som den eksterne datakilden
Å avklare typen data som sendes mellom lagene, noe som muliggjør mer gjenbruk

Et eksempel på kode

Nå skal vi dykke ned i Google UserRepository eksempel og lage vår egen versjon holder oss til endringene vi har gjort ovenfor.

La oss først se på Googles endelige versjon av UserRepository.

Du kan se at de bruker Dagger (eller Hilt ) , Kotlin Coroutines , rom, og mest sannsynlig, ettermontering. Å tilby Retrofit-tjenesten, Coroutine-utføreren og Dao (Data Access Object) til depotet.

Den grunnleggende strømmen av dette er å lage en nettverksforespørsel om dataene og returnere dataene (eller noe som ser på for data) fra Room umiddelbart. Når nettverksforespørselen er fullført, gjør du alt du trenger å gjøre med dataene og setter inn det nye objektet i tabellen. Innsettingen varsler automatisk de tidligere returnerte dataene om at de har endret seg, og ber om en ny henting, og til slutt en oppdatering av visningen.

Noen oppsett

Før vi begynner å lage UserRepository, bør vi først ta for oss noen ting vi kommer til å trenge, som en nyttig måte å injisere hvilke tråder vi vil kjøre på.

Dette er for å hjelpe oss med testing senere. Sett dette opp i Dagger-grafen, og nå kan du enkelt injisere de riktige trådene over hele kodebasen mens du fremdeles kan bytte dem ut til en TestScheduler i enheten din. tester (du skriver enhetstester … ikke sant?)

Her er brukerklassene våre, UserResponse er den som returneres av API-en vår via ettermontering og User, vår forretningsmodell vi passerer internt. Du kan se at vi kan gjøre nettverksobjektet enkelt og til og med bruke en kodegenerator for å lage det mens forretningsobjektene våre kan være mer i tråd med det vi trenger.

Her definerer vi vår ettermonteringstjeneste. Vi kunne ha fått disse til å returnere en Observable i stedet for en Single, noe som ville ha gjort nedstrøms logikk litt enklere, men vi likte parallellene til hvordan nettverksforespørsler og Single fungerer, både asynkrone og enten lykkes eller mislykkes. Vi fører den logikken også opp gjennom laget for ekstern datakilde.

Neste opp er vårt rom Dao. Siden Room allerede fungerer uten grensesnitt og merknader, følte vi ikke behovet for å lage et nytt grensesnitt, klasse og innvendinger for å tilsløre hvordan vi håndterer vedvarende data.

Vi bruker en Observable for å reagere på utslipp av User objekter og la vår innsatshandling returnere Completable for å hjelpe oss med å håndtere eventuelle romfeil som kan oppstå.

Til slutt, her er vårt siste grensesnitt for selve UserRepository. Det er veldig enkelt og rent. Den eneste ekstra delen utover det som kreves er onCleared() -metoden som vil hjelpe oss med å rydde opp i eksisterende disponible engangsartikler i våre nedre lag som ViewModel blir også ryddet.

Implementeringer

Du vil legge merke til at konstruktørens signatur er veldig lik Googles eksempel ovenfor.Vi leverer et objekt som kan hente data over nettverket, en Dao og et objekt som forteller oss hvilke tråder vi skal kjøre på.

Selv getUser(userId: Int) -metoden er lik i hvordan det fungerer i eksemplet ovenfor. Opprett en asynkron nettverksforespørsel og returner umiddelbart et objekt som ser etter data fra databasen. Når nettverksforespørselen er fullført, setter du inn dataene i tabellen, som vil utløse en UI-oppdatering.

Du vil legge merke til at vi ikke gjør noe for fancy med RxJava her. Vi oppretter en ny PublishSubject som vi kanaliserer dataene våre gjennom. Vi fletter den sammen med det vedvarende lagets select, som returnerer en Observable, og vi sender også feil fra nettverkslaget der også. Vi gikk frem og tilbake på hvordan vi skulle håndtere å få bare feilene vi ønsker fra hvert lag til laget over, og dette var den enkleste og mest tilpassbare løsningen vi kom til. Ja, det skaper ekstra observerbare, men det gir oss bedre kontroll over feilhåndtering.

Den siste puslespillet er Remote Data Source. Her kan du se hvordan vi konverterer UserResponse til en User, slik at resten av kodebasen kan bevege seg i et annet tempo enn data som kommer inn fra API.

Innpakning av alt

På dette tidspunktet bør du legge til disse objektene ovenfra i Dagger-grafen. Husk at de angitte metodene skal returnere grensesnitttypen og ikke implementeringene. På denne måten kan du bygge hvert objekt som er definert ovenfor i Dagger-grafen, og det gjør det veldig enkelt å hente data fra et UserRepository hvor som helst i kodebasen.

Til slutt, fordi vi bruker RxJava, bør vi sørge for å ringe clear() på hvilken som helst Disposable objekter i ViewModel og ring også onCleared()UserRepository forekomst for å rydde opp i eventuelle interne Disposable objekter.

Nå har vi en ganske grei MVVM-implementering med et lager som blir gitt til oss via vår Dagger-graf. Lagene vi har laget, tjener hvert et klart formål og holdes fast ved grensesnitt som hjelper oss å filtrere det vi utsetter for omverdenen. Dette gjør det også mulig for oss å teste disse lagene uavhengig av hverandre isolert ved hjelp av en RxJava TestScheduler som lar oss ha full kontroll over testens utførelse.

Legg igjen en kommentar

Din e-postadresse vil ikke bli publisert. Obligatoriske felt er merket med *