Android, MVVM en repositories in de echte wereld

(Mike DeMaso) (2 november 2020)

Hoe bereik je een responsieve gebruikersinterface terwijl je je code schoon, nauwkeurig, leesbaar en onderhoudbaar houdt? Dit is een vraag die sinds 2008 door de hersenen van Android-ontwikkelaars stuitert.

Na vele jaren Android-ontwikkelaars het zelf te hebben laten uitzoeken, heeft Google onlangs enige begeleiding over het onderwerp gegeven met hun Gids voor app-architectuur die een variant van MVVM promoot. Hoewel dit een goed begin is, blijven er veel vragen voor teams om zelf te beantwoorden. Google biedt ook bibliotheken zoals LiveData , Room en Gegevensbinding om ons te helpen de standaardlimieten te verminderen en de zorgen over onze code te scheiden. Zelfs derden hebben een helpende hand geboden met RxJava en Retrofit , waardoor we een gemakkelijke manier om asynchroon werk af te handelen en dingen op te halen via het netwerk. Met al deze dingen die rondzweven en kleine stukjes van de grotere vraag oplossen, hoe kunnen we ze allemaal samenbrengen om een ​​vloeiende gebruikersinterface-ervaring te bieden die eenvoudig te implementeren en te onderhouden is?

In dit artikel zullen we het delen wat we hebben geleerd van onze eerdere pogingen om een ​​MVVM-architectuur te maken en laten een eenvoudig voorbeeld zien van hoe al deze stukjes van de puzzel samen kunnen worden gebracht.

Een korte beschrijving van MVVM

Om u een idee te geven van wat een repository gaat oplossen, laten we eerst het standaard MVVM-patroon bekijken.

De basis MVVM-patroon
Basis MVVM-diagram

De weergave

Dit is de weergave van de grafische gebruikersinterface voor jouw code. Deze kunnen worden weergegeven door XML (layoutbestanden) of code (Jetpack Compose). Gewoonlijk is er een vorm van gegevensbinding die de weergave aan het ViewModel koppelt. Afhankelijk van waar u de grens trekt tussen een weergave en een map, kan het Activity and Fragment-object als een van beide of beide worden beschouwd.

The ViewModel

Dit is verantwoordelijk voor het transformeren van gegevens van het model in een indeling die voor de weergave logisch is. Laat u niet voor de gek houden door die dubbele pijl-eindigende verbinding tussen de View en het ViewModel. Het belangrijkste verschil tussen een ViewModel en de Presenter in MVP is dat een ViewModel geen verwijzing naar de weergave bevat. Het is een eenrichtingsrelatie, wat betekent dat iemand anders het ViewModel-object moet beheren.

De Model

Dit verwijst naar de gegevens (of het domeinmodel) die worden gebruikt als de bron van de informatie die het ViewModel aan de weergave levert. In deze laag worden dingen een beetje wazig, zoals in sommige artikelen wordt verwezen het als de gegevens zelf, terwijl anderen ernaar verwijzen als een gegevenstoegangslaag. Dit is waar het repository-patroon van Google tussenkomt om dingen op te helderen.

Ga naar de repository

Google is noemen dit patroon nu al een tijdje . Hun voorbeelden zijn een geweldige gids om het basisprincipe van het gebruik van MVVM met een repository te begrijpen, maar we vinden dat ze enkele kleine ( en belangrijk) begeleiding om mensen te helpen deze fragmenten te vertalen naar een groter, complexer project.

Googles MVVM-diagram

Het repository-patroon is ontworpen om” een schone API te bieden, zodat dat de rest van de app deze gegevens gemakkelijk kan ophalen. ” Helaas, alleen het toevoegen van een repository aan uw architectuur dwingt uw code niet om schoon te zijn. Je kunt nog steeds een warboel creëren zonder de lagen goed te scheiden en een duidelijk contract tussen de lagen te bieden.

Bij DraftKings hebben we ons gericht op een paar aanvullende richtlijnen om onze ontwikkelaars te helpen consistent schone code te produceren.

Lagen ontkoppelen met interfaces

Hier voegen we interfaces toe om technici te vragen na te denken over wat ze publiekelijk laten zien
Verbetering van Googles diagram door interfaces toe te voegen

We hebben vastgesteld dat het gebruik van interfaces tussen deze lagen ingenieurs van alle niveaus zal helpen om zich aan goede coderingsprincipes te houden. Dit helpt ervoor te zorgen dat onze unit-tests echt slechts één laag per keer testen, waardoor de overhead van het schrijven wordt verminderd en een grote testsuite wordt onderhouden. Het helpt ook om naar buiten gerichte APIs duidelijk te definiëren en verdoezelt de implementatiedetails van de verschillende lagen. Het vraagt ​​ons om te evalueren wat we de buitenwereld vertellen over de functionaliteit van dit object en geeft ons een ingebouwde mogelijkheid om ervoor te zorgen dat onze code schoon is.

Het biedt ons ook de mogelijkheid om verschillende lagen in onze codebase effectiever te refactoren. Zolang de interface niet verandert, kunnen we de gebieden van onze codebase die ze gebruiken onaangeroerd laten. Als we bijvoorbeeld onze netwerkbibliotheek willen migreren van Volley naar Retrofit, kunnen we eenvoudig de methoden in onze Dagger-klassen wijzigen die produceren en bieden de interface boven de externe gegevensbron en hoeven geen wijzigingen aan te brengen in elke opslagplaats die dat eindpunt gebruikt. Dit vermindert de reikwijdte van dergelijke wijzigingen aanzienlijk en verkleint de kans dat we bugs in het eindproduct introduceren.

Hier hebben we een voorbeeld van hoe een ViewModel vasthouden aan de concrete klasse van een repository kan leiden tot onbedoeld gedrag. Het voorbeeld is een beetje gekunsteld en kan worden gecorrigeerd door eenvoudig de fooPublishSubject in FooRepository als private, maar die oplossing is brozer. FooRepository misschien moeten worden gebruikt in een ander bereik waarvoor toegang tot die parameter vereist is, en het openen van toegang voor instanties is nu een warboel en het is gepast om die lidvariabele rechtstreeks te gebruiken.

Leveren van deze afhankelijkheden

Naarmate de complexiteit van uw project toeneemt, worden uw afhankelijkheidsrelaties ingewikkelder. Dit betekent dat mensen over het algemeen naar een soort van Dependency Injection-bibliotheek gaan (zoals Dagger of Koin ) .

Een DI-bibliotheek biedt niet alleen een schone en gemakkelijke manier om uw vereiste afhankelijkheden op te halen, het stelt u ook in staat na te denken over hoeveel van deze objecten u nodig heeft in de toepassing.

Dit denkproces heeft ons ertoe gebracht om de best practice vast te stellen van welke objecten in de Dagger-grafiek thuishoren. Alles waarvan we maar één exemplaar willen, zou in de root / global / application-scoped-grafiek moeten staan. Alles waarvan er veel gevallen kunnen zijn, moet op aanvraag worden gemaakt en op de juiste manier worden bewaard.

Dit betekende dat onze nieuwe repository-objecten in de Dagger-grafiek thuishoren, omdat we willen dat meerdere ViewModels toegang hebben tot modellen via een enkele instantie van de onderliggende Room- of Retrofit-bronnen. ViewModels daarentegen moeten nieuw worden gemaakt voor elke View die ze nodig heeft. Denk aan een stapel activiteiten als een stapel speelkaarten en het ViewModel bepaalt de kleur en waarde. We zouden niet willen dat de handeling van het toevoegen van een klaveren 3 aan de top alle onderstaande kaarten verandert in een klaveren 3, dus elke weergave heeft zijn eigen exemplaar van een ViewModel nodig om zijn gegevens te behouden en te isoleren.

We hebben nu gedefinieerd wat onze DI-oplossing nu zal bevatten.
Laat zien welke objecten naar verwachting worden vastgehouden door the Dagger Graph

We hebben besloten om onze ViewModels uit onze Dagger-grafiek te houden. Historisch gezien waren we minder expliciet over deze keuze, maar we voelden dat dit de juiste richting gezien het ViewModelProvider -patroon dat voorkomt in androidx.lifecycle en hoe het ons helpt de relatie tussen de activiteit / het fragment te verstevigen / XML en het ViewModel als één op één. Ondertussen kan de relatie tussen ViewModel en repository veel op veel zijn. In de praktijk betekent dit dat we voor elke Activiteit / Fragment / XML een enkel ViewModel hebben dat de l ogic, maar het kan veel repositories bereiken om de vereiste gegevens te verzamelen. Aangezien gegevens over het algemeen hergebruikt en weergegeven worden in de applicatie, kunnen veel verschillende ViewModels dezelfde instantie van de repository uit de Dagger Graph gemakkelijk en efficiënt gebruiken.

Bevat de API

In elk bedrijf op grote schaal zijn er veel ingenieurs, teams en zelfs afdelingen nodig om een ​​project van het bord in de handen van de klant te krijgen. Hier bij DraftKings is dat niet anders. Om gegevens in de Android-applicatie te krijgen, moeten we met een paar verschillende teams aan de backend werken om de gegevens van de database naar de API naar de Android-client te krijgen. Aangezien deze code vaak eigendom is van een ander team, betekent dit dat de backend over het algemeen in een ander tempo zal bewegen dan de klant.

Dit geldt met name aan het begin van een project dat niet hebben een API in een staat die we kunnen gebruiken voor onze ontwikkeling. We willen graag ontwerp- en implementatiebeslissingen nemen over de data-objecten die intern naar de klant worden doorgegeven zonder ons al te veel zorgen te maken over tegenstrijdige beslissingen die de ingenieurs die aan de backend werken, nemen.

Verder hebben we een weinig diensten die dezelfde bedrijfsgegevens aan de klant retourneren, maar omdat ze eigendom zijn van verschillende teams en verschillende problemen proberen op te lossen, zijn ze uit elkaar gedreven in de structuur en soorten gegevens die via de APIs worden geretourneerd.Intern voor de klant vertegenwoordigen deze eigenlijk hetzelfde, dus het is logisch om deze antwoorden te vertalen en te combineren in iets universeels voor de klant.

Hier kunt u zien dat er twee eindpunten op de API zijn die variaties van een “gebruiker” retourneren; de aanmeldings- en profieleindpunten. Om ze samen te voegen tot één gegevenstype, maken we eenvoudig een constructor voor elke variant die we willen afhandelen en nu kan de applicatie de kennis van de twee verschillende soorten gebruikers die de API levert op één plek beperken. Dit maakt het delen van gegevens (via de modellaag) tussen functies veel gemakkelijker, terwijl wijzigingen in de datastructuur en -typen van de API nog steeds beperkt blijven tot het ene eindpunt.

We maken een onderscheid tussen Network Response-objecten en Business Model-objecten in onze architectuur. Dit helpt ook bij het definiëren van de rol van de externe gegevensbron om netwerkreacties op te nemen en deze om te zetten in bedrijfsmodellen voor de rest van onze applicatie.

Door de gegevenstypen te definiëren die deze lagen produceren, helpt dit ook bij het definiëren van de rol van de externe gegevensbron
Het verduidelijken van het type gegevens dat tussen de lagen wordt verzonden, waardoor meer hergebruik mogelijk wordt

Een voorbeeld in code

Nu gaan we in het Google UserRepository -voorbeeld duiken en onze eigen versie maken vasthouden aan de wijzigingen die we hierboven hebben aangebracht.

Laten we eerst eens kijken naar Googles definitieve versie van de UserRepository.

Je kunt zien dat ze Dagger gebruiken (of Hilt ) , Kotlin Coroutines , Room en hoogstwaarschijnlijk Retrofit. Leveren van de Retrofit-service, Coroutine-uitvoerder en Dao (Data Access Object) aan de repository.

De basisstroom hiervan is om een ​​netwerkverzoek in te dienen voor de gegevens en de gegevens (of iets dat voor gegevens) uit Room onmiddellijk. Zodra het netwerkverzoek is voltooid, doet u alles wat u met de gegevens moet doen en voegt u het nieuwe object in de tabel in. De invoeging stelt de eerder geretourneerde gegevens automatisch op de hoogte dat deze zijn gewijzigd, waardoor een nieuwe opvraging wordt gevraagd en ten slotte een update van de weergave.

Enige instellingen

Voordat we beginnen met het maken van de UserRepository, we moeten eerst een aantal dingen bespreken die we nodig zullen hebben, zoals een handige manier om te injecteren op welke threads we willen draaien.

Dit is om ons te helpen met testen later. Stel dit in de Dagger-grafiek in en nu kunt u de juiste threads gemakkelijk over de hele codebase heen injecteren, terwijl u ze nog steeds kunt omwisselen voor een TestScheduler in uw unit tests (je schrijft unit-tests … toch?)

Dit zijn onze gebruikersklassen, UserResponse wordt geretourneerd door onze API via Retrofit en User, ons bedrijfsmodel geven we intern door. U kunt zien dat we het netwerkobject eenvoudig kunnen maken en zelfs een codegenerator kunnen gebruiken om het te maken, terwijl onze bedrijfsobjecten meer in overeenstemming kunnen zijn met wat we nodig hebben.

Hier definiëren we onze Retrofit-service. We hadden deze een Observable kunnen laten retourneren in plaats van een Single, wat de stroomafwaartse logica een beetje eenvoudiger zou hebben gemaakt, maar we vonden de parallellen van hoe netwerkverzoeken en Single werken, zowel asynchroon als slagen of mislukken. We dragen die logica ook over via de Remote Data Source-laag.

De volgende is onze kamer Dao. Omdat Room al werkt met interfaces en annotaties, hadden we geen behoefte om nog een interface, klasse en object te maken om te verhullen hoe we met persistente gegevens omgaan.

We gebruiken een Observable om te reageren op emissies van User objecten en door onze invoegactie een Completable te laten retourneren om ons te helpen eventuele kamerfouten op te lossen.

Eindelijk, hier is onze laatste interface voor de UserRepository zelf. Het is heel eenvoudig en schoon. Het enige extra onderdeel dat verder gaat dan wat nodig is, is de onCleared() -methode die ons zal helpen om bestaande wegwerpartikelen in onze lagere lagen op te ruimen, aangezien de ViewModel wordt ook gewist.

Implementaties

Je zult merken dat de handtekening van de constructeur erg lijkt op het voorbeeld van Google hierboven.We bieden een object dat gegevens via het netwerk kan ophalen, een Dao en een object dat ons vertelt op welke threads we moeten draaien.

Zelfs de getUser(userId: Int) -methode is vergelijkbaar in hoe het werkt in het bovenstaande voorbeeld. Maak een asynchroon netwerkverzoek en retourneer onmiddellijk een object dat op zoek is naar gegevens uit de database. Wanneer dat netwerkverzoek is voltooid, voegt u die gegevens in de tabel in, waardoor een UI-update wordt geactiveerd.

U zult merken dat we hier niets bijzonders aan het doen zijn met RxJava. We maken een nieuwe PublishSubject waar we onze gegevens doorheen leiden. We voegen het samen met de selectie van de persistente laag, die een Observable retourneert en we sturen ook daar ook fouten van de netwerklaag. We gingen heen en weer over hoe we moesten omgaan met het verkrijgen van alleen de fouten die we willen van elke laag naar de laag erboven, en dit was de eenvoudigste en meest aanpasbare oplossing die we tegenkwamen. Ja, het creëert extra observaties, maar het geeft ons een fijnere controle over foutafhandeling.

De laatste stukje van de puzzel is de externe gegevensbron. Hier kunt u zien hoe we de UserResponse converteren naar een User, waardoor de rest van de codebase in een ander tempo kan bewegen dan de gegevens die binnenkomen vanuit de API.

Alles inpakken

Op dit punt zou je deze objecten van bovenaf aan de Dagger-grafiek moeten toevoegen. Onthoud dat de opgegeven methoden het interfacetype moeten retourneren en niet de implementaties. Op deze manier kun je elk hierboven gedefinieerd object in de Dagger-grafiek bouwen en dat maakt het heel gemakkelijk om gegevens te krijgen van een UserRepository waar dan ook in de codebase.

Ten slotte, omdat we RxJava gebruiken, moeten we ervoor zorgen dat we clear() op elke Disposable objecten in onze ViewModel en roep ook onCleared() aan op onze UserRepository instantie om interne Disposable objecten op te schonen.

Nu hebben we een redelijk eenvoudige MVVM-implementatie met een opslagplaats die aan ons wordt geleverd via onze Dagger-grafiek. De lagen die we hebben gemaakt, hebben elk een duidelijk doel en worden vastgehouden door interfaces die ons helpen filteren wat we aan de buitenwereld blootstellen. Dit stelt ons ook in staat om deze lagen onafhankelijk van elkaar afzonderlijk te testen met behulp van een RxJava TestScheduler waarmee we volledige controle hebben over de uitvoering van de test.

Geef een reactie

Het e-mailadres wordt niet gepubliceerd. Vereiste velden zijn gemarkeerd met *