Android, MVVM i repozytoria w świecie rzeczywistym

(Mike DeMaso) (2 listopada 2020 r.)

Jak uzyskać responsywny interfejs użytkownika, zachowując jednocześnie czysty, precyzyjny, czytelny i łatwy w utrzymaniu kod? To jest pytanie, które pojawia się w mózgach programistów Androida od 2008 roku.

Po wielu latach pozwalania programistom Androida na samodzielne rozwiązywanie tego problemu Google niedawno udzieliło kilku wskazówek na ten temat w ramach Przewodnik po architekturze aplikacji , który promuje wariant MVVM . Chociaż jest to świetny początek, pozostawia wiele pytań, na które zespoły muszą odpowiedzieć samodzielnie. Google udostępnia również biblioteki, takie jak LiveData , Room i DataBinding , aby pomóc nam zredukować schematy i oddzielić problemy związane z kodem. Nawet firmy zewnętrzne pomogły przy RxJava i modernizacji , co daje nam łatwy sposób obsługi pracy asynchronicznej i pobierania rzeczy przez sieć. Skoro wszystkie te rzeczy krążą wokół rozwiązywania małych fragmentów większego pytania, jak możemy połączyć je wszystkie, aby zapewnić płynny interfejs użytkownika, który jest prosty we wdrożeniu i utrzymaniu?

W tym artykule udostępnimy czego nauczyliśmy się z naszych wcześniejszych prób stworzenia architektury MVVM i pokażemy prosty przykład, jak połączyć wszystkie te elementy układanki w jedną całość.

Krótki opis MVVM

Aby lepiej zrozumieć, co ma rozwiązać repozytorium, przejdźmy najpierw do podstawowego wzorca MVVM.

Podstawowy Wzorzec MVVM
Podstawowy diagram MVVM

Widok

To jest przedstawienie graficznego interfejsu użytkownika dla Twój kod. Mogą być reprezentowane przez XML (pliki układu) lub kod (Jetpack Compose). Zwykle istnieje pewna forma powiązania danych, która łączy widok z ViewModel. W zależności od tego, gdzie narysujesz linię między widokiem a segregatorem, obiekt Activity i Fragment można uznać za jeden lub oba.

ViewModel

Odpowiada za przekształcanie danych z Model do formatu, który ma sens w wyświetlaniu Widoku. Nie pozwól, aby to połączenie zakończone podwójną strzałką między widokiem a modelem widoku zmyliło Cię. Główna różnica między ViewModel i Presenter w MVP polega na tym, że ViewModel nie zawiera odniesienia do widoku. Jest to relacja jednokierunkowa, co oznacza, że ​​coś innego musi zarządzać obiektem ViewModel.

Model

Odnosi się do danych (lub modelu domeny), które są używane jako źródło informacji dostarczanych przez ViewModel do widoku. W tej warstwie rzeczy stają się nieco niewyraźne, ponieważ niektóre artykuły odnoszą się do jest to same dane, podczas gdy inni nazywają je warstwą dostępu do danych. W tym miejscu pojawia się wzorzec repozytorium Google, aby wyjaśnić sytuację.

Wprowadź repozytorium

Google został wspominając o tym wzorcu od jakiegoś czasu . Ich przykłady są świetnym przewodnikiem do zrozumienia podstawowej zasady używania MVVM z repozytorium, ale okazuje się, że brakuje im niektórych małych ( i ważne) wskazówki, które pomogą ludziom przetłumaczyć te fragmenty na większy, bardziej złożony projekt.

Diagram MVVM Google

Wzorzec repozytorium ma na celu„ zapewnienie czystego interfejsu że reszta aplikacji może łatwo pobrać te dane ”. Niestety, samo dodanie repozytorium do architektury nie wymusza czystości kodu. Nadal możesz stworzyć zagmatwany bałagan bez prawidłowego oddzielania i zapewniania jasnego kontraktu między warstwami.

W DraftKings skupiliśmy się na kilku dodatkowych wskazówkach, które pomogą naszym programistom konsekwentnie tworzyć czysty kod.

Oddziel warstwy za pomocą interfejsów

Tutaj dodajemy interfejsy, aby zachęcić inżynierów do przemyślenia tego, co publicznie ujawniają
Ulepszanie diagramu Google przez dodanie interfejsów

Ustaliliśmy, że używanie interfejsów między tymi warstwami pomoże inżynierom na wszystkich poziomach trzymać się dobrych zasad kodowania. Dzięki temu nasze testy jednostkowe naprawdę testują tylko jedną warstwę na raz, zmniejszając nakład pracy związany z pisaniem i utrzymaniem dużego zestawu testów. Ponadto pomaga jasno zdefiniować zewnętrzne interfejsy API i zaciemnia szczegóły implementacji różnych warstw. Skłania nas do oceny tego, co mówimy światu zewnętrznemu o funkcjonalności tego obiektu i daje nam wbudowaną możliwość upewnienia się, że nasz kod jest czysty.

Daje nam również możliwość skuteczniejszej refaktoryzacji różnych warstw w naszej bazie kodu. Dopóki interfejs się nie zmieni, możemy pozostawić obszary naszej bazy kodu, które ich używają, nietknięte. Na przykład, gdybyśmy chcieli przeprowadzić migrację naszej biblioteki sieciowej z Volley do Retrofit, moglibyśmy po prostu zmienić metody w naszych klasach Dagger, które tworzyć i udostępniać interfejs powyżej zdalnego źródła danych i nie trzeba wprowadzać zmian w każdym repozytorium korzystającym z tego punktu końcowego. To znacznie ogranicza zakres takich zmian i zmniejsza szansę wprowadzenia błędów w produkcie końcowym.

Tutaj mamy przykład tego, jak pozwolenie ViewModelowi na utrzymanie konkretnej klasy repozytorium może prowadzić do niezamierzonych zachowań. Przykład jest nieco wymyślony i można go poprawić, po prostu zaznaczając fooPublishSubject w FooRepository jako private, ale to rozwiązanie jest bardziej kruche. FooRepository może muszą być używane w innym zakresie wymagającym dostępu do tego parametru, a otwarcie dostępu dla instancji powoduje teraz zamieszanie pl należy bezpośrednio użyć tej zmiennej składowej.

Dostarczanie tych zależności

Wraz ze wzrostem złożoności projektu, tym bardziej skomplikowane stają się relacje zależności. Oznacza to, że ludzie zazwyczaj korzystają z jakiejś biblioteki Dependency Injection (np. Dagger lub Koin ) .

Biblioteka DI nie tylko zapewnia czysty i łatwy sposób pobierania wymaganych zależności, ale także pozwala zastanowić się, ile z tych obiektów będziesz potrzebować w aplikacji.

Ten proces myślowy doprowadził nas do ustalenia najlepszej praktyki określania, jakie obiekty należą do wykresu Dagger. Wszystko, czego chcemy tylko jako jedno wystąpienie, powinno znajdować się na wykresie głównym / globalnym / o zasięgu aplikacji. Wszystko, czego mogłoby istnieć wiele instancji, powinno być tworzone na żądanie i odpowiednio przechowywane.

Oznaczało to, że nasze nowe obiekty repozytorium należą do wykresu Dagger, ponieważ chcemy, aby wiele ViewModels miało dostęp do modeli za pośrednictwem jednej instancji bazowego źródła Room lub Retrofit. Z drugiej strony modele ViewModels muszą być tworzone nowe dla każdego widoku, który ich potrzebuje. Pomyśl o stosie działań jak o stosie kart do gry, a ViewModel decyduje o kolorze i wartości. Nie chcielibyśmy, aby akt dodania 3 trefl na górze zmienił wszystkie poniższe karty na 3 trefl, więc każdy widok potrzebuje własnej instancji ViewModel, aby zachować i izolować swoje dane.

Zdefiniowaliśmy teraz, co będzie teraz zawierać nasze rozwiązanie DI.
Pokaż, jakie obiekty mają być przechowywane przez wykres sztyletu

Postanowiliśmy nie uwzględniać naszych ViewModels na naszym wykresie Dagger. W przeszłości byliśmy mniej jednoznaczni co do tego wyboru, ale uważaliśmy, że jest to we właściwym kierunku, biorąc pod uwagę wzorzec ViewModelProvider , który występuje w androidx.lifecycle i jak pomaga nam to wzmocnić związek między działaniem / fragmentem / XML i ViewModel jako „jeden do jednego”. Tymczasem relacja ViewModel do repozytorium może być „wiele do wielu”. W praktyce oznacza to, że dla każdego działania / fragmentu / XML mamy jeden ViewModel, który obsługuje l tego widoku. ogic, ale może sięgać do wielu repozytoriów w celu uzyskania wymaganych danych. Ponieważ dane są generalnie ponownie wykorzystywane i wyświetlane w aplikacji, wiele różnych modeli ViewModels może łatwo i efektywnie korzystać z tej samej instancji repozytorium z Dagger Graph.

Zawierające API

W każdej firmie na dużą skalę potrzeba wielu inżynierów, zespołów, a nawet działów, aby projekt z tablicy trafił do rąk klienta. Nie inaczej jest w DraftKings. Aby pobrać dane do aplikacji na Androida, musimy współpracować z kilkoma różnymi zespołami na zapleczu, aby pobrać dane z bazy danych do API do klienta Android. Biorąc pod uwagę, że ten kod jest często własnością innego zespołu, oznacza to, że backend na ogół „porusza się” w innym tempie niż klient.

Jest to szczególnie prawdziwe na początku projektu, który nie mieć API w stanie, którego możemy użyć do naszego rozwoju. Chcielibyśmy podejmować decyzje projektowe i wdrożeniowe dotyczące obiektów danych przekazywanych wewnętrznie do klienta, nie martwiąc się zbytnio o sprzeczne decyzje podejmowane przez inżynierów pracujących na zapleczu.

Poza tym mamy niewiele usług, które zwracają te same dane biznesowe do klienta, ale ponieważ są własnością różnych zespołów i próbują rozwiązać różne problemy, oddalili się od siebie w strukturze i typach danych zwracanych za pośrednictwem interfejsów API.Wewnątrz klienta reprezentują one w rzeczywistości to samo, więc możliwość przetłumaczenia i połączenia tych odpowiedzi w coś uniwersalnego dla klienta ma wiele sensu.

Tutaj widać, że istnieją dwa punkty końcowe interfejsu API, które zwracają odmiany „użytkownika”; punkty końcowe logowania i profilu. Aby połączyć je w jeden typ danych, po prostu tworzymy konstruktor dla każdej odmiany, którą chcemy obsługiwać, a teraz aplikacja może ograniczyć wiedzę o dwóch różnych typach użytkowników, których API dostarcza do jednego miejsca. To sprawia, że ​​udostępnianie danych (za pośrednictwem warstwy modelu) między funkcjami jest znacznie łatwiejsze, a jednocześnie pozwala na ograniczenie zmian w strukturze i typach danych API do jednego punktu końcowego.

Rozróżniamy obiekty Network Response i obiektów modelu biznesowego w naszej architekturze. Pomaga to również zdefiniować rolę zdalnego źródła danych, które przyjmuje odpowiedzi sieci i przekształca je w modele biznesowe dla pozostałej części naszej aplikacji.

Definiowanie typów danych tworzonych przez te warstwy pomaga również w zdefiniowaniu roli zdalnego źródła danych
Wyjaśnianie typu danych przesyłanych między warstwami, umożliwiając ich ponowne wykorzystanie w większym stopniu

Przykład w kodzie

Teraz zagłębimy się w przykład Google UserRepository i stworzymy naszą własną wersję trzymając się zmian, które wprowadziliśmy powyżej.

Najpierw przyjrzyjmy się ostatecznej wersji Google UserRepository.

Widać, że używają sztyletu (lub rękojeści ) , Kotlin Coroutines , Room i najprawdopodobniej Retrofit. Dostarczanie usługi Retrofit, modułu wykonawczego Coroutine i Dao (obiekt dostępu do danych) do repozytorium.

Podstawowym przepływem tego jest wysłanie żądania sieciowego dla danych i zwrócenie danych (lub czegoś, co obserwuje danych) z Pokoju niezwłocznie. Po zakończeniu żądania sieciowego zrób wszystko, co musisz zrobić z danymi i wstaw nowy obiekt do tabeli. Wstawienie automatycznie powiadamia poprzednio zwrócone dane, że uległy zmianie, monitując o nowe pobranie i wreszcie aktualizację widoku.

Trochę konfiguracji

Zanim przejdziemy do tworzenia UserRepository, powinniśmy najpierw zająć się niektórymi rzeczami, których będziemy potrzebować, np. pomocnym sposobem wstrzyknięcia wątków, na których chcemy pracować.

Ma to nam pomóc w późniejszym testowaniu. Skonfiguruj to na wykresie Dagger, a teraz możesz łatwo wstrzyknąć poprawne wątki w całej bazie kodu, nadal mając możliwość ich zamiany na TestScheduler w swojej jednostce testy (piszesz testy jednostkowe… prawda?)

Oto nasze klasy użytkowników, UserResponse to klasa zwrócona przez nasz interfejs API w ramach modernizacji i User, nasz model biznesowy, który przekazujemy wewnętrznie. Jak widać, możemy uprościć obiekt sieciowy, a nawet użyć generatora kodu do jego utworzenia, podczas gdy nasze obiekty biznesowe mogą być bardziej zgodne z tym, czego potrzebujemy.

Tutaj definiujemy naszą usługę modernizacji. Moglibyśmy sprawić, by zwracały one Observable zamiast Single, co uprościłoby nieco logikę niższego szczebla, ale spodobałybyśmy się analogie jak działają żądania sieciowe i Single, zarówno asynchroniczne, jak i z powodzeniem lub niepowodzeniem. Przenosimy tę logikę również przez warstwę zdalnego źródła danych.

Następny jest nasz pokój Dao. Ponieważ Room już działa bez interfejsów i adnotacji, nie czuliśmy potrzeby tworzenia innego interfejsu, klasy i obiektu, aby zaciemnić sposób obsługi trwałych danych.

Używamy Observable do reagowania na emisje User obiektów i polecenie, aby nasza akcja wstawiania zwracała Completable, aby pomóc nam w rozwiązaniu ewentualnych błędów dotyczących pokoju.

Wreszcie, oto nasz ostatni interfejs dla samego UserRepository. Jest to bardzo proste i przejrzyste. Jedyną dodatkową częścią wykraczającą poza to, co jest wymagane, jest metoda onCleared(), która pomoże nam wyczyścić wszelkie istniejące produkty jednorazowego użytku w naszych niższych warstwach, ponieważ również zostanie wyczyszczony.

Implementacje

Zauważysz, że podpis konstruktora jest bardzo podobny do powyższego przykładu Google.Dostarczamy obiekt, który może pobierać dane przez sieć, Dao i obiekt, który mówi nam, na jakich wątkach mamy działać.

Nawet metoda getUser(userId: Int) działa podobnie w powyższym przykładzie. Utwórz asynchroniczne żądanie sieciowe i natychmiast zwróć obiekt, który oczekuje na dane z bazy danych. Kiedy to żądanie sieciowe zakończy się, wstaw te dane do tabeli, co spowoduje aktualizację interfejsu użytkownika.

Zauważysz, że nie robimy tu nic nadzwyczajnego z RxJava. Tworzymy nowy PublishSubject, przez który kierujemy nasze dane. Łączymy go z zaznaczeniem warstwy trwałej, co zwraca Observable i tam również wysyłamy błędy z warstwy sieciowej. Wciąż zastanawialiśmy się, jak radzić sobie z pobieraniem tylko żądanych błędów z każdej warstwy do warstwy powyżej i było to najprostsze i najbardziej konfigurowalne rozwiązanie, do którego doszliśmy. Tak, tworzy dodatkowe obserwowalne, ale daje nam lepszą kontrolę nad obsługą błędów.

Ostatni częścią układanki jest zdalne źródło danych. Tutaj możesz zobaczyć, jak konwertujemy UserResponse na User, pozwalając pozostałej części kodu na poruszanie się w innym tempie niż dane przychodzące z API.

Wrapping It All Up

W tym miejscu powinieneś dodać te obiekty z góry do wykresu Dagger. Pamiętaj, że podane metody powinny zwracać typ interfejsu, a nie implementacje. W ten sposób możesz zbudować każdy obiekt zdefiniowany powyżej na wykresie Dagger, co bardzo ułatwia pobieranie danych z UserRepository dowolnego miejsca w bazie kodu.

Wreszcie, ponieważ używamy RxJava, powinniśmy upewnić się, że wywołujemy clear() na dowolnym Disposable obiekty w naszym ViewModel, a także zadzwoń pod numer onCleared() z naszego UserRepository w celu wyczyszczenia wszelkich wewnętrznych Disposable obiektów.

Teraz mamy dość prostą implementację MVVM z repozytorium, które jest nam dostarczane przez nasz wykres sztyletu. Warstwy, które stworzyliśmy, służą jasnemu celowi i są utrzymywane przez interfejsy, które pomagają nam filtrować to, co wystawiamy na świat zewnętrzny. Umożliwia nam to również testowanie tych warstw niezależnie od siebie w izolacji za pomocą RxJava TestScheduler, który daje nam pełną kontrolę nad wykonywaniem testu.

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *