Typowe wzorce modularyzacji

Nie ma jednej strategii modularyzacji, która sprawdzi się we wszystkich projektach. Ze względu na elastyczny charakter narzędzia Gradle ma niewiele ograniczeń dotyczących sposobu organizacji projektów. Ta strona zawiera przegląd ogólnych zasad i typowych wzorców, których możesz użyć podczas tworzenia wielomodułowych aplikacji na Androida.

Zasada wysokiej spójności i niskiego sprzężenia

Jednym ze sposobów scharakteryzowania modułowej bazy kodu jest użycie właściwości coupling i cohesion. Połączenie określa stopień, w jakim moduły są od siebie zależne. Spójność określa, w jaki sposób są one ze sobą funkcjonalnie powiązane. Zasadniczo staraj się dążyć do niskiego poziomu sprzężenia i dużej spójności:

  • Niskie łączenie oznacza, że moduły powinny być jak najbardziej niezależne od siebie, tak by zmiany w jednym module miały zerowy lub minimalny wpływ na inne. Moduły nie powinny zawierać informacji o działaniu innych modułów.
  • Wysoka spójność oznacza, że moduły powinny stanowić zbiór kodu, który działa jak system. Powinni mieć jasno określone obowiązki i zachowywać się w granicach określonej wiedzy z zakresu dziedziny. Rozważmy przykładową aplikację e-booków. Łączenie kodu książki i kodu związanego z płatnościami w tym samym module może być nieodpowiednie, ponieważ są to dwie różne domeny funkcjonalne.

Typy modułów

Sposób porządkowania modułów zależy głównie od architektury aplikacji. Poniżej znajdziesz kilka typowych typów modułów, które możesz wprowadzić do swojej aplikacji, zachowując przy tym zgodność z naszą zalecaną architekturą aplikacji.

Moduły danych

Moduł danych zwykle zawiera repozytorium, źródła danych i klasy modelu. 3 główne zadania modułu danych to:

  1. Przedstaw wszystkie dane i logikę biznesową z określonej domeny: każdy moduł danych powinien odpowiadać za obsługę danych z określonej domeny. Może obsługiwać wiele typów danych, o ile są ze sobą powiązane.
  2. Ujawnij repozytorium jako zewnętrzny interfejs API: publiczny interfejs API modułu danych powinien być repozytorium, ponieważ jest on odpowiedzialny za udostępnienie danych reszcie aplikacji.
  3. Ukryj wszystkie szczegóły implementacji i źródła danych z zewnątrz: źródła danych powinny być dostępne tylko dla repozytoriów z tego samego modułu. Pozostają ukryte na zewnątrz. Możesz to egzekwować, używając słowa kluczowego określającego widoczność private lub internal firmy Kotlin.
Rysunek 1. Przykładowe moduły danych i ich treść.

Moduły funkcji

Funkcja to osobny element funkcji aplikacji, który zwykle odpowiada ekranowi lub serii blisko powiązanych ekranów, np. rejestracji lub płatności. Jeśli Twoja aplikacja ma pasek nawigacyjny u dołu, prawdopodobnie każde miejsce docelowe jest funkcją.

Rysunek 2. Każda karta w tej aplikacji może być zdefiniowana jako funkcja.

Funkcje są powiązane z ekranami lub miejscami docelowymi w aplikacji. Dlatego prawdopodobnie będą mieć powiązany interfejs i element ViewModel do obsługi ich logiki i stanu. Pojedyncza funkcja nie musi być ograniczona do jednego widoku czy miejsca docelowego nawigacji. Moduły funkcji są oparte na modułach danych.

Rysunek 3. Przykładowe moduły funkcji i ich treść.

Moduły aplikacji

Moduły aplikacji są punktem wejścia do aplikacji. Zależą one od modułów funkcji i zwykle zapewniają nawigację główną. Dzięki wariantom kompilacji pojedynczy moduł aplikacji można skompilować do kilku różnych plików binarnych.

Rysunek 4. *Przedstawienie* i *pełny* wykres zależności modułów smakowych.

Jeśli Twoja aplikacja jest kierowana na wiele typów urządzeń, takich jak automatyczne, Wear lub TV, zdefiniuj dla każdego z nich osobny moduł aplikacji. Pomaga to oddzielić zależności od platformy.

Rysunek 5. Wykres zależności aplikacji na Wear.

Popularne moduły

Popularne moduły, zwane też modułami podstawowymi, zawierają kod, którego często używają inne moduły. Zmniejszają one nadmiarowość i nie reprezentują żadnej konkretnej warstwy architektury aplikacji. Oto przykłady popularnych modułów:

  • Moduł interfejsu: jeśli w aplikacji używasz niestandardowych elementów interfejsu lub używasz wyszukanych elementów marki, zastanów się nad umieszczeniem kolekcji widżetów w module, aby wszystkie funkcje były wykorzystywane wielokrotnie. Dzięki temu interfejs użytkownika będzie spójny między różnymi funkcjami. Jeśli na przykład tematyka jest scentralizowana, można uniknąć kłopotliwej refaktoryzacji przy zmianie nazwy marki.
  • Moduł Analytics: śledzenie często zależy od wymagań biznesowych, ale z niewielkim uwzględnieniem architektury oprogramowania. Moduły śledzące Analytics są często używane w wielu niepowiązanych ze sobą elementach. W takim przypadku warto utworzyć osobny moduł analityczny.
  • Moduł sieci: jeśli wiele modułów wymaga połączenia sieciowego, zastanów się nad utworzeniem modułu przeznaczonego do obsługi klienta HTTP. Jest to szczególnie przydatne, gdy klient wymaga niestandardowej konfiguracji.
  • Moduł narzędzi: narzędzia, nazywane też pomocnikami, to zwykle niewielkie fragmenty kodu używane ponownie w danej aplikacji. Przykładami narzędzi są narzędzia pomocnicze do testowania, funkcja formatowania walut, walidator poczty e-mail czy operator niestandardowy.

Moduły testowe

Moduły testowe to moduły Androida służące tylko do testowania. Moduły te zawierają kod testowy, zasoby testowe i zależności testowe, które są wymagane tylko do uruchamiania testów i nie są potrzebne podczas działania aplikacji. Moduły testowe są tworzone w celu oddzielenia kodu przeznaczonego do testu od aplikacji głównej, co ułatwia zarządzanie kodem modułu i jego obsługę.

Przypadki użycia modułów testowych

Poniższe przykłady pokazują sytuacje, w których wdrożenie modułów testowych może być szczególnie korzystne:

  • Wspólny kod testowy: jeśli w projekcie masz wiele modułów, a niektóre z nich mają zastosowanie do więcej niż 1 modułu, możesz utworzyć moduł testowy, aby udostępnić kod. Pomaga to ograniczyć duplikowanie i ułatwia obsługę kodu testowego. Udostępniany kod testowy może zawierać klasy lub funkcje narzędzi, takie jak asercje niestandardowe lub dopasowania, a także dane testowe, takie jak symulowane odpowiedzi JSON.

  • Konfiguracje kompilacji „czystsze”: moduły testowe pozwalają uzyskać bardziej przejrzyste konfiguracje kompilacji, ponieważ mogą mieć własny plik build.gradle. Nie musisz zapełniać pliku build.gradle modułu aplikacji konfiguracjami, które mają zastosowanie tylko do testów.

  • Testy integracji: moduły testowe służą do przechowywania testów integracji używanych do testowania interakcji między różnymi częściami aplikacji, w tym między interfejsami użytkownika, logiką biznesową, żądaniami sieciowymi i zapytaniami do bazy danych.

  • Aplikacje na dużą skalę: moduły testowe są szczególnie przydatne w przypadku aplikacji na dużą skalę ze złożonymi bazami kodu i wieloma modułami. W takich przypadkach moduły testowe mogą pomóc w ulepszeniu organizacji kodu i łatwości jego obsługi.

Rysunek 6. Moduły testowe pozwalają wyizolować moduły, które w przeciwnym razie byłyby od siebie zależne.

Komunikacja między modułami

Moduły rzadko są oddzielone i często polegają na innych modułach i komunikują się z nimi. Poziom połączenia jest ważny, nawet jeśli moduły ze sobą współpracują i często wymieniają się informacjami. Czasami bezpośrednia komunikacja między 2 modułami jest niepożądana ze względu na ograniczenia architektury. Może też być niemożliwe, na przykład w przypadku zależności cyklicznych.

Rysunek 7. Bezpośrednia, dwukierunkowa komunikacja między modułami jest niemożliwa z powodu cyklicznych zależności. Moduł zapośredniczenia jest niezbędny do koordynowania przepływu danych między dwoma innymi niezależnymi modułami.

Aby rozwiązać ten problem, możesz wykorzystać trzeci moduł pośredniczący między dwoma innymi modułami. Moduł mediacji może nasłuchiwać wiadomości z obu modułów i przekazywać je dalej w razie potrzeby. W naszej przykładowej aplikacji ekran płatności musi wiedzieć, którą książkę kupić, mimo że zdarzenie zainicjowano na innym ekranie będącym częścią innej funkcji. W tym przypadku pośrednikiem jest moduł, do którego należy wykres nawigacyjny (zwykle moduł aplikacji). W tym przykładzie używamy nawigacji, aby przekazywać dane z funkcji strony głównej do funkcji płatności za pomocą komponentu Nawigacja.

navController.navigate("checkout/$bookId")

Miejsce docelowe płatności otrzymuje identyfikator książki jako argument, którego używa do pobrania informacji o książce. Za pomocą uchwytu zapisanego stanu możesz pobrać argumenty nawigacji w funkcji ViewModel funkcji docelowej.

class CheckoutViewModel(savedStateHandle: SavedStateHandle, …) : ViewModel() {

   val uiState: StateFlow<CheckoutUiState> =
      savedStateHandle.getStateFlow<String>("bookId", "").map { bookId ->
          // produce UI state calling bookRepository.getBook(bookId)
      }
      …
}

Nie przekazuj obiektów jako argumentów nawigacyjnych. Zamiast tego używaj prostych identyfikatorów, za pomocą których funkcje mogą uzyskiwać dostęp do odpowiednich zasobów z warstwy danych i je wczytywać. W ten sposób utrzymasz niski poziom połączenia i nie naruszysz zasady dotyczącej jednego źródła prawdy.

W poniższym przykładzie oba moduły funkcji bazują na tym samym module danych. Pozwala to zminimalizować ilość danych, które musi przekazywać moduł mediatora, i zmniejsza powiązanie między modułami. Zamiast przekazywać obiekty, moduły powinny wymieniać się podstawowe identyfikatory i wczytywać zasoby z udostępnianego modułu danych.

Rysunek 8. 2 moduły funkcji bazujące na module udostępnianych danych.

Odwrócenie zależności

Odwrócenie zależności polega na uporządkowaniu kodu w taki sposób, aby abstrakcja była oddzielona od konkretnej implementacji.

  • Abstrakcja: umowa określająca, w jaki sposób komponenty lub moduły aplikacji wchodzą ze sobą w interakcje. Moduły abstrakcyjne definiują interfejs API systemu i zawierają interfejsy oraz modele.
  • Konkretna implementacja: moduły zależne od modułu abstrakcji, które implementują sposób działania abstrakcji.

Moduły, które opierają się na zachowaniu zdefiniowanego w module abstrakcji, powinny zależać tylko od samej abstrakcji, a nie od konkretnych implementacji.

Rysunek 9. Zamiast modułów wysokiego poziomu, które bezpośrednio bazują na modułach niskiego poziomu, moduły wysokiego poziomu i implementacji zależą od modułu abstrakcji.

Przykład

Wyobraź sobie moduł funkcji, który do działania wymaga bazy danych. Moduł funkcji nie dotyczy sposobu implementacji bazy danych – lokalnej bazy danych pomieszczenia czy zdalnej instancji Firestore. Musi tylko zapisywać i odczytywać dane aplikacji.

Aby to osiągnąć, moduł funkcji zależy od modułu abstrakcji, a nie od konkretnej implementacji bazy danych. Ta abstrakcja określa interfejs API bazy danych aplikacji. Inaczej mówiąc, wyznacza on reguły korzystania z bazy danych. Dzięki temu moduł funkcji może korzystać z dowolnej bazy danych bez konieczności poznania podstawowych szczegółów implementacji.

Konkretny moduł implementacji zawiera faktyczną implementację interfejsów API zdefiniowanych w module abstrakcji. W tym celu moduł implementacji zależy też od modułu abstrakcji.

Wstrzykiwanie zależności

Zastanawiasz się już teraz, jak moduł funkcji jest połączony z modułem implementacji. Odpowiedź to Wstrzykiwanie zależności. Moduł funkcji nie tworzy bezpośrednio wymaganej instancji bazy danych. Zamiast tego określa potrzebne zależności. Zależności te są następnie dostarczane zewnętrznie, zwykle w module aplikacji.

releaseImplementation(project(":database:impl:firestore"))

debugImplementation(project(":database:impl:room"))

androidTestImplementation(project(":database:impl:mock"))

Korzyści

Korzyści z oddzielenia interfejsów API i ich implementacji są następujące:

  • Wymienność: dzięki wyraźnemu odseparowaniu modułów interfejsu API i implementacji możesz opracować wiele implementacji tego samego interfejsu API i przełączać się między nimi bez zmiany kodu, który z niego korzysta. Może to być szczególnie przydatne w sytuacjach, gdy trzeba stworzyć różne możliwości i zachowania w różnych kontekstach. Może to być na przykład przykładowa implementacja do testowania i rzeczywista implementacja w środowisku produkcyjnym.
  • Rozłączanie: ten rozdział oznacza, że moduły korzystające z abstrakcji nie zależą od żadnej konkretnej technologii. Jeśli zdecydujesz się później zmienić bazę danych z Room na Firestore, będzie Ci łatwiej, ponieważ zmiany będą miały miejsce tylko w konkretnym module wykonującym zadanie (moduł implementacji) i nie będą miały wpływu na inne moduły korzystające z interfejsu API bazy danych.
  • Testowanie: oddzielenie interfejsów API od ich implementacji może znacznie ułatwić testowanie. Możesz tworzyć przypadki testowe związane z umowami dotyczącymi interfejsów API. Możesz też używać różnych implementacji do testowania różnych scenariuszy i przypadków brzegowych, w tym implementacji próbnych.
  • Zwiększona wydajność kompilacji: gdy podzielisz interfejs API i jego implementację na różne moduły, zmiany w module implementacji nie wymuszają ponownego skompilowania modułów w zależności od danego modułu. Przyspiesza to tworzenie i zwiększa produktywność, zwłaszcza w przypadku dużych projektów, w których czas ten może być istotny.

Kiedy rozdzielać

Warto oddzielić interfejsy API od ich implementacji w tych przypadkach:

  • Zróżnicowane funkcje: jeśli możesz wdrażać części swojego systemu na wiele sposobów, przejrzysty interfejs API umożliwia wymianę różnych implementacji. Może to być na przykład system renderowania korzystający z OpenGL lub Vulkan, system rozliczeniowy współpracujący z Google Play lub wewnętrzny interfejs API rozliczeń.
  • Wiele aplikacji: jeśli tworzysz wiele aplikacji mających wspólne funkcje na różne platformy, możesz zdefiniować wspólne interfejsy API i opracować określone implementacje dla każdej platformy.
  • Niezależne zespoły: podział ten umożliwia różnym programistom i zespołom współpracę na różnych częściach bazy kodu jednocześnie. Deweloperzy powinni skupić się na zrozumieniu umów dotyczących interfejsów API i prawidłowym korzystaniu z nich. Nie muszą zajmować się szczegółami implementacji innych modułów.
  • Duża baza kodu: gdy baza kodu jest duża lub złożona, oddzielenie interfejsu API od implementacji ułatwia zarządzanie kodem. Pozwala dzielić bazę kodu na bardziej szczegółowe, zrozumiałe i łatwe w użyciu jednostki.

Jak to zrobić?

Aby wdrożyć odwrócenie zależności, wykonaj te czynności:

  1. Utwórz moduł abstrakcji: ten moduł powinien zawierać interfejsy API (interfejsy i modele), które definiują działanie funkcji.
  2. Utwórz moduły implementacji: moduły implementacji powinny korzystać z modułu interfejsu API i wdrażać zachowanie abstrakcji.
    Zamiast modułów wysokiego poziomu, które bezpośrednio bazują na modułach niskiego poziomu, moduły wysokiego poziomu i implementacji zależą od modułu abstrakcji.
    Rysunek 10. Moduły implementacji opierają się na module abstrakcji.
  3. Zadbaj o to, aby moduły wysokiego poziomu były zależne od modułów abstrakcji: zamiast bezpośrednio polegać na konkretnej implementacji, uzależnij je od modułów abstrakcji. Moduły wysokiego poziomu nie muszą znać szczegółów implementacji – potrzebują jedynie umowy (API).
    Moduły wysokiego poziomu zależą od abstrakcji, a nie od implementacji.
    Rysunek 11. Moduły wysokiego poziomu zależą od abstrakcji, a nie od implementacji.
  4. Udostępnij moduł implementacji: na koniec musisz podać faktyczną implementację na potrzeby zależności. Konkretna implementacja zależy od konfiguracji projektu, ale zwykle dobrym miejscem do tego jest moduł aplikacji. Aby podać implementację, określ ją jako zależność wybranego przez Ciebie wariantu kompilacji lub testowego zestawu źródeł.
    Moduł aplikacji zawiera faktyczną implementację.
    Rysunek 12. Moduł aplikacji zawiera faktyczną implementację.

Ogólne sprawdzone metody

Jak wspomnieliśmy na początku, nie ma jednego sposobu na opracowanie aplikacji wielomodułowej. Podobnie jak wiele architektur oprogramowania, istnieje wiele sposobów jej modułowania. Jednak poniższe ogólne zalecenia mogą pomóc Ci zwiększyć czytelność, łatwość obsługi i testowalność kodu.

Zadbaj o spójność konfiguracji

Każdy moduł wprowadza narzut związany z konfiguracją. Jeśli liczba Twoich modułów osiągnie określony próg, zarządzanie spójną konfiguracją staje się wyzwaniem. Ważne jest na przykład, aby moduły korzystały z zależności tej samej wersji. Jeśli musisz zaktualizować dużą liczbę modułów, aby poprawić wersję zależności, jest to nie tylko wyzwanie, ale także miejsce na potencjalne błędy. Aby rozwiązać ten problem, możesz scentralizować konfigurację za pomocą jednego z narzędzi narzędzia Gradle:

  • Katalogi wersji to bezpieczna lista zależności generowanych przez Gradle podczas synchronizacji. Jest to centralne miejsce do deklarowania wszystkich zależności i dostępne dla wszystkich modułów w projekcie.
  • Użyj wtyczek konwencyjnych, aby współdzielić logikę kompilacji między modułami.

Pokazuj jak najmniej

Publiczny interfejs modułu powinien być ograniczony do minimum i udostępniać tylko najważniejsze elementy. Nie powinny one ujawniać szczegółów implementacji na zewnątrz. Ograniczaj wszystko do najmniejszego zakresu. Aby ustawić moduł jako prywatny, użyj zakresu widoczności private lub internal dostępnego w narzędziu Kotlin. Podczas deklarowania zależności w module wybieraj implementation zamiast api. W tym drugim przypadku uzależnione są przejściowe zależności od użytkowników Twojego modułu. Użycie implementacji może skrócić czas kompilacji, ponieważ zmniejsza liczbę modułów do odbudowania.

Preferuj moduły Kotlin i Java

Android Studio obsługuje 3 podstawowe typy modułów:

  • Moduły aplikacji to punkty wejścia do aplikacji. Mogą zawierać kod źródłowy, zasoby i zasoby oraz element AndroidManifest.xml. Wynikiem modułu aplikacji jest pakiet Android App Bundle (AAB) lub Android Application Package (APK).
  • Moduły biblioteki mają tę samą zawartość co moduły aplikacji. Są one używane przez inne moduły Androida jako zależność. Dane wyjściowe modułu biblioteki są takie same jak dane wyjściowe w pliku Android Archive (AAR) i są identyczne pod względem struktury z modułami aplikacji, ale są skompilowane do pliku Android Archive (AAR), który może później zostać wykorzystany przez inne moduły jako zależność. Moduł biblioteki pozwala hermetyzować i wykorzystywać tę samą logikę i zasoby w wielu modułach aplikacji.
  • Biblioteki Kotlin i Java nie zawierają żadnych zasobów, zasobów ani plików manifestu Androida.

Ponieważ moduły Androida mają duże znaczenie, najlepiej jest używać języka Kotlin lub Javy w miarę możliwości.