Android, MVVM och arkiv i den verkliga världen

Publicerad

(Mike DeMaso) (2 nov 2020)

Hur uppnår du ett lyhörd användargränssnitt samtidigt som du håller koden ren, exakt, läsbar och underhållbar? Detta är en fråga som har studsat runt hjärnan hos Android-utvecklare sedan 2008.

Efter många år med att låta Android-utvecklare räkna ut det på egen hand har Google nyligen gett lite vägledning om ämnet med sina Guide till apparkitektur som främjar en variant av MVVM . Även om detta är en bra start, lämnar det många frågor kvar för team att besvara på egen hand. Google tillhandahåller också bibliotek som LiveData , Rum och DataBinding för att hjälpa oss att minska pannplattorna och separera vår kod. Även tredje part har lånat en hjälpande hand med RxJava och Eftermontering , vilket ger oss en enkel sätt att hantera asynkront arbete och hämta saker via nätverket. Med alla dessa saker som flyter runt och löser små bitar av den större frågan, hur kan vi föra dem alla tillsammans för att ge en flytande användargränssnittsupplevelse som är enkel att implementera och underhålla?

I den här artikeln kommer vi att dela vad vi har lärt oss från våra tidigare försök att skapa en MVVM-arkitektur och visa ett enkelt exempel på hur man sammanför alla dessa pusselbitar.

En snabb beskrivning av MVVM

För att ge dig en förståelse för vad ett förråd kommer att lösa, låt oss först dyka in i det grundläggande MVVM-mönstret.

Grundläggande MVVM-mönster
Grundläggande MVVM-diagram

Vyn

Detta representerar det grafiska användargränssnittet för din kod. Dessa kan representeras av XML (layoutfiler) eller kod (Jetpack Compose). Vanligtvis finns det någon form av databindning som länkar vyn till ViewModel. Beroende på var du drar linjen mellan en vy och ett bindemedel kan Aktivitets- och fragmentobjektet betraktas som endera eller båda.

ViewModel

Detta ansvarar för att omvandla data från modellen till ett format som är vettigt för att Visa ska visas. Låt inte den dubbla pilavslutningen mellan View och ViewModel lura dig. Den största skillnaden mellan en ViewModel och presentatören i MVP är att ViewModel inte innehåller en hänvisning till vyn. Det är en enkelriktad relation, vilket innebär att något annat behöver hantera ViewModel-objektet.

Modell

Detta hänvisar till den data (eller domänmodell) som används som källa för den information som ViewModel tillhandahåller till vyn. Detta lager är där saker blir lite suddiga som vissa artiklar hänvisar till det som själva data medan andra hänvisar till det som ett dataåtkomstlager. Det är här Googles förvarsmönster går in för att rensa upp saker.

Ange förvaret

Google har varit nämner detta mönster under en tid nu. Deras exempel är en bra guide för att förstå den grundläggande principen för att använda MVVM med ett förvar, men vi tycker att de saknar några små ( och viktigt) vägledning för att hjälpa människor att översätta dessa utdrag till ett större, mer komplext projekt.

Googles MVVM-diagram

Förvarets mönster är utformat för att” ge ett rent API så att resten av appen enkelt kan hämta dessa data. ” Tyvärr, bara att lägga till ett arkiv i din arkitektur tvingar inte din kod att vara ren. Du kan fortfarande skapa en trasslig röra utan att ordentligt separera och ge ett tydligt kontrakt mellan lagren.

På DraftKings har vi fokuserat på några ytterligare riktlinjer för att hjälpa våra utvecklare att producera ren kod konsekvent.

Koppla av lager med gränssnitt

Här lägger vi till i gränssnitt för att uppmana ingenjörer att tänka på vad de offentligt exponerar
Förbättring av Googles diagram genom att lägga till gränssnitt

Vi har konstaterat att användning av gränssnitt mellan dessa lager hjälper ingenjörer på alla nivåer att hålla sig till goda kodningsprinciper. Detta hjälper till att se till att våra enhetstest verkligen bara testar ett lager åt gången, vilket minskar kostnaden för skrivning och upprätthåller en stor testsvit. Det hjälper också till att tydligt definiera API: er som vetter mot externa platser och fördunklar implementeringsdetaljerna för de olika lagren. Det uppmanar oss att utvärdera vad vi säger omvärlden om objektets funktionalitet och ger oss en inbyggd möjlighet att se till att vår kod är ren.

Det ger oss också möjligheten att omforma olika lager i vår kodbas mer effektivt. Så länge gränssnittet inte ändras kan vi lämna de områden i vår kodbas som använder dem orörda. Om vi ​​till exempel vill migrera vårt nätverksbibliotek från Volley till Retrofit kan vi helt enkelt ändra metoderna i våra Dagger-klasser som producera och tillhandahålla gränssnittet ovanför fjärrdatakällan och behöver inte göra ändringar i varje förvar som använder den slutpunkten. Detta minskar omfattningen av sådana förändringar avsevärt och minskar chansen att vi skulle införa buggar i den slutliga produkten.

Här har vi ett exempel på hur man låter en ViewModel hålla fast vid en klass i ett förvar kan leda till oavsiktligt beteende. Exemplet är lite konstruerat och kan åtgärdas genom att helt enkelt markera fooPublishSubject i FooRepository som private, men den lösningen är sprödare. FooRepository måste användas i ett annat omfång som kräver åtkomst till den parametern, och öppna åtkomst för instanser som nu rör sig wh sv Det är lämpligt att använda medlemsvariabeln direkt.

Leverera dessa beroende

När komplexiteten i ditt projekt växer, desto mer komplicerat blir dina beroendeförhållanden. Detta innebär att människor i allmänhet vänder sig till något slags Dependency Injection-bibliotek (som Dagger eller Koin ) .

Inte bara ger ett DI-bibliotek ett enkelt och enkelt sätt att hämta dina beroenden, det låter dig också tänka på hur många av dessa objekt du behöver i applikationen.

Denna tankeprocess ledde oss till att fastställa bästa praxis för vilka objekt tillhör i Dagger-grafen. Allt som vi bara vill ha en enstaka instans av, ska leva i rot / global / applikationsomfattande graf. Allt som det kan finnas många förekomster av ska skapas på begäran och hållas på lämpligt sätt.

Detta innebar att våra nya förvarobjekt hör hemma i Dagger-grafen eftersom vi vill att flera ViewModels ska kunna komma åt modeller via en enda instans av de underliggande rums- eller eftermonteringskällorna. ViewModels å andra sidan måste skapas nya för varje vy som behöver dem. Tänk på en stack med aktiviteter som en stack med spelkort och ViewModel driver färg och värde. Vi vill inte att en 3 klubbar ska läggas till överst för att ändra alla kort nedan till en 3 av klubbar, så varje vy behöver sin egen instans av en ViewModel för att bevara och isolera sina data.

Vi har nu definierat vad vår DI-lösning nu kommer att innehålla.
Visa vilka objekt som förväntas hållas av Dagger Graph

Vi bestämde oss för att hålla våra ViewModels utanför vår Dagger-graf. Historiskt sett hade vi varit mindre tydliga om detta val men vi ansåg att detta är rätt riktning med tanke på ViewModelProvider -mönstret som finns i androidx.lifecycle och hur det hjälper oss att stärka förhållandet mellan aktiviteten / fragmentet / XML och ViewModel som ”en mot en”. Under tiden kan förhållandet mellan ViewModel och förvar vara ”många till många”. I praktiken betyder det att för varje aktivitet / fragment / XML har vi en enda ViewModel som hanterar vyn l ogic, men det kan nå ut till många arkiv för att hämta den information som krävs. Eftersom data vanligtvis återanvänds och visas i hela applikationen kan många olika ViewModels använda samma instans av förvaret från Dagger-grafen enkelt och effektivt.

Innehåller API

I vilket företag som helst i skala krävs många ingenjörer, team och till och med divisioner för att få ett projekt från whiteboardtavlan till kundens händer. Här på DraftKings är det inte annorlunda. För att få in data i Android-applikationen måste vi arbeta med några olika team på backend för att få data från databasen till API: t till Android-klienten. Med tanke på att den här koden ofta ägs av ett annat team betyder det att backend generellt kommer att ”röra sig” i en annan takt än klienten.

Detta gäller särskilt i början av ett projekt som inte ha ett API i ett tillstånd som vi kan använda för vår utveckling. Vi vill fatta design- och implementeringsbeslut om dataobjekten som skickas internt till klienten utan att oroa oss för mycket för motstridiga beslut som ingenjörerna som arbetar på backend tar.

Utöver det har vi en få tjänster som returnerar samma affärsdata till klienten men eftersom de ägs av olika team och försöker lösa olika problem har de drivit ifrån varandra i strukturen och typerna av data som returneras via API: erna.Internt för klienten representerar dessa faktiskt samma sak, så att kunna översätta och kombinera dessa svar till något universellt för klienten är mycket meningsfullt.

Här kan du se att det finns två slutpunkter på API: et som returnerar varianter av en “användare;” inloggnings- och profiländpunkterna. För att slå samman dem i en datatyp skapar vi helt enkelt en konstruktör för varje variant vi vill hantera och nu kan applikationen begränsa kunskapen om de två olika typerna av användare som API levererar till en enda plats. Detta gör delning av data (via modellskiktet) mellan funktioner mycket enklare samtidigt som ändringar i API: s datastruktur och typer begränsas till den enda slutpunkten.

Vi skiljer mellan Network Response-objekt och affärsmodellobjekt i vår arkitektur. Detta hjälper också till att definiera fjärrdatakällans roll för att ta nätverkssvar och förvandla dem till affärsmodeller för resten av vår applikation.

Definiera datatyper som dessa lager producerar, detta hjälper också till att definiera rollen som fjärrdatakällan = ” src=”https://miro.medium.com/max/1104/1*Q8wtqEovXwx8PoPZPoeSpg.png” width=”590″ height=”920″ srcSet=”https://miro.medium.com/max/552/1*Q8wtqEovXwx8PoPZPoeSpg.png 276w, https://miro.medium.com/max/1104/1*Q8wtqEovXwx8PoPZPoeSpg.png 552w” sizes=”590px”/>

Ett exempel i kod

Nu ska vi dyka in i Google UserRepository och skapa vår egen version håller fast vid de ändringar som vi har gjort ovan.

Låt oss först titta på Googles slutliga version av UserRepository.

Du kan se att de använder Dagger (eller Hilt ) , Kotlin Coroutines , rum och troligtvis eftermontering. Tillhandahåller Retrofit-tjänsten, Coroutine-exekutören och Dao (Data Access Object) till förvaret.

Det grundläggande flödet av detta är att göra en nätverksbegäran för data och returnera data (eller något som tittar på för data) från rummet omedelbart. När nätverksförfrågan är klar gör du allt du behöver göra för data och infogar det nya objektet i tabellen. Införandet meddelar automatiskt de tidigare returnerade uppgifterna att de har ändrats, vilket kräver en ny hämtning och slutligen en uppdatering av vyn.

Någon installation

Innan vi börjar skapa UserRepository, bör vi först ta itu med några saker vi kommer att behöva, som ett användbart sätt att injicera vilka trådar vi vill köra på.

Detta är för att hjälpa oss att testa senare. Ställ in detta i Dagger-grafen och nu kan du enkelt injicera rätt trådar över hela kodbasen medan du fortfarande kan byta ut dem mot en TestScheduler i din enhet tester (du skriver enhetstester … eller hur?)

Här är våra användarklasser, UserResponse är den som returneras av vårt API via eftermontering och User, vår affärsmodell passerar vi internt. Du kan se att vi kan göra nätverksobjektet enkelt och till och med använda en kodgenerator för att skapa det medan våra affärsobjekt kan vara mer i linje med vad vi behöver.

Här definierar vi vår eftermonteringstjänst. Vi kunde ha fått dessa att returnera en Observable istället för en Single, vilket skulle ha gjort nedströms logik lite enklare men vi gillade parallellerna med hur nätverksförfrågningar och Single fungerar, både asynkrona och antingen lyckas eller misslyckas. Vi bär den logiken upp genom fjärrdatakällans lager också.

Nästa är vårt rum Dao. Eftersom Room redan fungerar med gränssnitt och anteckningar kände vi inte behovet av att skapa ett annat gränssnitt, klass och objekt för att fördunkla hur vi hanterar ihållande data.

Vi använder en Observable för att reagera på utsläpp av User objekt och att ha vår insatsåtgärd returnerar en Completable för att hjälpa oss att hantera eventuella rumsfel som kan uppstå.

Slutligen är här vårt sista gränssnitt för själva UserRepository. Det är väldigt enkelt och rent. Den enda kompletterande delen utöver vad som krävs är onCleared() -metoden som hjälper oss att rensa bort alla befintliga engångsartiklar i våra nedre lager som ViewModel rensas också.

Implementationer

Du kommer att märka att konstruktörens signatur liknar Googles exempel ovan.Vi tillhandahåller ett objekt som kan hämta data via nätverket, en Dao och ett objekt som berättar vilka trådar vi ska köra på.

Även metoden getUser(userId: Int) liknar hur det fungerar i exemplet ovan. Skapa en asynkron nätverksbegäran och returnera omedelbart ett objekt som tittar efter data från databasen. När nätverksförfrågan är klar, infoga dessa data i tabellen, vilket kommer att utlösa en UI-uppdatering.

Du kommer att märka att vi inte gör något för snyggt med RxJava här. Vi skapar en ny PublishSubject som vi kanaliserar våra data genom. Vi sammanfogar det med det ihållande lagrets select, som returnerar en Observable och vi skickar också fel från nätverkslagret dit också. Vi gick fram och tillbaka om hur vi skulle hantera att bara få de fel vi vill ha från varje lager till lagret ovan, och detta var den enklaste och mest anpassningsbara lösningen vi kom till. Ja, det skapar extra observerbara, men det ger oss bättre kontroll över felhantering.

Det sista pusselbiten är Remote Data Source. Här kan du se hur vi konverterar UserResponse till User, så att resten av kodbasen kan röra sig i en annan takt än data som kommer in från API: et.

Wrapping It All Up

Vid denna tidpunkt bör du lägga till dessa objekt ovanifrån till Dagger-grafen. Kom ihåg att de angivna metoderna ska returnera gränssnittstypen och inte implementeringarna. På så sätt kan du bygga alla objekt som definierats ovan i Dagger-grafen och det gör det väldigt enkelt att hämta data från en UserRepository var som helst i kodbasen.

Slutligen, eftersom vi använder RxJava, bör vi se till att ringa clear() på alla Disposable objekt i våra ViewModel och även ringa onCleared() på vår UserRepository instans för att rensa alla interna Disposable -objekt.

Nu har vi en ganska enkel MVVM-implementering med ett arkiv som tillhandahålls till oss via vår Dagger-graf. Skikten som vi har skapat tjänar var och en ett tydligt syfte och hålls fast vid gränssnitt som hjälper oss att filtrera det vi exponerar för omvärlden. Detta gör det också möjligt för oss att testa dessa lager oberoende av varandra isolerat med hjälp av en RxJava TestScheduler som låter oss ha fullständig kontroll över testets utförande.

Lämna ett svar

Din e-postadress kommer inte publiceras. Obligatoriska fält är märkta *