Typowe wzorce modularyzacji

Nie ma jednej strategii modularyzacji, która pasowałaby do wszystkich projektów. Ze względu na elastyczność Gradle istnieje niewiele ograniczeń dotyczących organizacji projektu. Na tej stronie znajdziesz ogólne zasady i wzorce, których możesz używać podczas tworzenia aplikacji na Androida z wieloma modułami.

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

Jednym ze sposobów charakteryzowania modułowej bazy kodu jest użycie właściwości powiązaniespójność. Sprzężenie mierzy stopień, w jakim moduły są od siebie zależne. Spójność w tym kontekście mierzy, w jaki sposób elementy pojedynczego modułu są ze sobą powiązane funkcjonalnie. Ogólnie rzecz biorąc, należy dążyć do niskiego sprzężenia i wysokiej spójności:

  • Niskie powiązanie oznacza, że moduły powinny być od siebie jak najbardziej niezależne, aby zmiany w jednym z nich miały zerowy lub minimalny wpływ na inne moduły. Moduły nie powinny znać wewnętrznych procesów innych modułów.
  • Wysoka spójność oznacza, że moduły powinny zawierać zbiór kodu, który działa jako system. Powinny mieć jasno określone obowiązki i działać w ramach określonej wiedzy. Rozważmy przykładową aplikację do czytania e-booków. Łączenie w tym samym module kodu związanego z rezerwacją i płatnościami może być niewłaściwe, ponieważ są to 2 różne domeny funkcjonalne.

Rodzaje modułów

Sposób organizacji modułów zależy głównie od architektury aplikacji. Poniżej znajdziesz kilka typowych modułów, które możesz wprowadzić w aplikacji, stosując zalecaną architekturę aplikacji.

Moduły danych

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

  1. Obejmuje wszystkie dane i logikę biznesową określonej domeny: każdy moduł danych powinien odpowiadać za obsługę danych reprezentujących określoną domenę. Może obsługiwać wiele typów danych, o ile są one powiązane.
  2. Udostępnij repozytorium jako zewnętrzny interfejs API: publiczny interfejs API modułu danych powinien być repozytorium, ponieważ odpowiada za udostępnianie danych reszcie aplikacji.
  3. Ukryj wszystkie szczegóły implementacji i źródła danych przed zewnętrznymi elementami: źródła danych powinny być dostępne tylko dla repozytoriów z tego samego modułu. Pozostają one ukryte na zewnątrz. Możesz to wymusić, używając słowa kluczowego widoczności private lub internal w języku Kotlin.
Rysunek 1. przykładowe moduły danych i ich zawartość;

Moduły funkcji

Funkcja to wyodrębniona część funkcjonalności aplikacji, która zwykle odpowiada ekranowi lub serii ściśle powiązanych ekranów, np. procesowi rejestracji lub płatności. Jeśli aplikacja ma pasek nawigacyjny u dołu, prawdopodobnie każdy element docelowy jest funkcją.

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

Funkcje są powiązane z ekranami lub miejscami docelowymi w aplikacji. Dlatego prawdopodobnie mają powiązany interfejs i ViewModel do obsługi logiki i stanu. Pojedyncza funkcja nie musi być ograniczona do jednego widoku ani miejsca docelowego nawigacji. Moduły funkcji zależą od modułów danych.

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

Moduły aplikacji

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

Rysunek 4. Wykres zależności modułów wersji produktu *Demo* i *Full*.

Jeśli aplikacja jest przeznaczona na wiele typów urządzeń, np. Androida Auto, Wear lub TV, zdefiniuj moduł aplikacji dla każdego z nich. Pomaga to oddzielić zależności specyficzne dla platformy.

Rysunek 5. Graf zależności aplikacji Android Auto.

Typowe moduły

Moduły wspólne, zwane też modułami podstawowymi, zawierają kod, z którego często korzystają inne moduły. Zmniejszają one nadmiarowość i nie reprezentują żadnej konkretnej warstwy w architekturze aplikacji. Oto przykłady typowych modułów:

  • Moduł interfejsu: jeśli w aplikacji używasz niestandardowych elementów interfejsu lub rozbudowanego brandingu, rozważ umieszczenie kolekcji widżetów w module, aby można było ponownie wykorzystać wszystkie funkcje. Może to pomóc w zapewnieniu spójności interfejsu w różnych funkcjach. Jeśli na przykład motywy są scentralizowane, możesz uniknąć bolesnego refaktoryzowania w przypadku zmiany marki.
  • Moduł Analytics: śledzenie jest często podyktowane wymaganiami biznesowymi, a architektura oprogramowania jest brana pod uwagę w niewielkim stopniu. Śledzenie Analytics jest często używane w wielu niezwiązanych ze sobą komponentach. W takim przypadku warto mieć osobny moduł analityczny.
  • Moduł sieciowy: jeśli wiele modułów wymaga połączenia z siecią, możesz rozważyć użycie modułu przeznaczonego do obsługi klienta HTTP. Jest to szczególnie przydatne, gdy klient wymaga konfiguracji niestandardowej.
  • Moduł narzędziowy: narzędzia, zwane też pomocniczymi, to zwykle małe fragmenty kodu, które są ponownie wykorzystywane w całej aplikacji. Przykłady narzędzi to: narzędzia do testowania, funkcja formatowania waluty, walidator adresu e-mail lub operator niestandardowy.

Testowanie modułów

Moduły testowe to moduły Androida, które służą wyłącznie do testowania. Moduły 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 związanego z testami od głównej aplikacji, 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 kilka modułów, a część kodu testowego można zastosować w więcej niż 1 module, możesz utworzyć moduł testowy, aby udostępnić kod. Może to pomóc w ograniczeniu duplikacji i ułatwić utrzymanie kodu testowego. Wspólny kod testowy może zawierać klasy lub funkcje narzędziowe, takie jak niestandardowe asercje lub dopasowania, a także dane testowe, np. symulowane odpowiedzi JSON.

  • Bardziej przejrzyste konfiguracje kompilacji: moduły testowe umożliwiają tworzenie bardziej przejrzystych konfiguracji kompilacji, ponieważ mogą mieć własny plik build.gradle. Nie musisz zaśmiecać pliku build.gradle modułu aplikacji konfiguracjami, które są istotne tylko w przypadku testów.

  • Testy integracyjne: moduły testowe mogą służyć do przechowywania testów integracyjnych, które są używane do testowania interakcji między różnymi częściami aplikacji, w tym interfejsem 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żoną bazą kodu i wieloma modułami. W takich przypadkach moduły testowe mogą pomóc w lepszym uporządkowaniu kodu i ułatwić jego konserwację.

Rysunek 6. Moduły testowe można wykorzystać do odseparowania modułów, które w innych okolicznościach byłyby od siebie zależne.

Komunikacja między modułami

Moduły rzadko działają w pełnej izolacji i często korzystają z innych modułów oraz komunikują się z nimi. Ważne jest, aby zachować niskie sprzężenie nawet wtedy, gdy moduły współpracują ze sobą i często wymieniają informacje. Czasami bezpośrednia komunikacja między dwoma modułami jest niepożądana, np. ze względu na ograniczenia architektury. Może to być też niemożliwe, np. w przypadku zależności cyklicznych.

Rysunek 7. Bezpośrednia, dwukierunkowa komunikacja między modułami jest niemożliwa ze względu na zależności cykliczne. Moduł pośredniczący jest niezbędny do koordynowania przepływu danych między dwoma innymi niezależnymi modułami.

Aby rozwiązać ten problem, możesz użyć trzeciego modułu zapośredniczenia między dwoma innymi modułami. Moduł pośredniczący może nasłuchiwać wiadomości z obu modułów i w razie potrzeby je przekazywać. W naszej przykładowej aplikacji ekran płatności musi wiedzieć, którą książkę kupić, mimo że zdarzenie pochodzi z osobnego ekranu, który jest częścią innej funkcji. W tym przypadku moduł pośredniczący to moduł, który jest właścicielem wykresu nawigacji (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 Navigation.

navController.navigate("checkout/$bookId")

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

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

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

Nie należy przekazywać obiektów jako argumentów nawigacji. Zamiast tego używaj prostych identyfikatorów, za pomocą których funkcje mogą uzyskiwać dostęp do żądanych zasobów z warstwy danych i je wczytywać. W ten sposób zmniejszysz powiązanie i nie naruszysz zasady jednego źródła danych.

W poniższym przykładzie oba moduły funkcji zależą od tego samego modułu danych. Dzięki temu można zminimalizować ilość danych, które moduł pośredniczący musi przekazywać, i utrzymać niskie powiązanie między modułami. Zamiast przekazywać obiekty, moduły powinny wymieniać się identyfikatorami pierwotnymi i wczytywać zasoby ze wspólnego modułu danych.

Rysunek 8. 2 moduły funkcji korzystające ze wspólnego modułu danych.

Odwrócenie zależności

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

  • Abstrakcja: umowa, która określa, jak komponenty lub moduły w aplikacji wchodzą ze sobą w interakcje. Moduły abstrakcji definiują interfejs API systemu i zawierają interfejsy oraz modele.
  • Konkretna implementacja: moduły, które zależą od modułu abstrakcji i implementują zachowanie abstrakcji.

Moduły, które korzystają z zachowania zdefiniowanego w module abstrakcji, powinny zależeć tylko od samej abstrakcji, a nie od konkretnych implementacji.

Rysunek 9. Zamiast bezpośrednio zależeć od modułów niskiego poziomu, moduły wysokiego poziomu i moduły implementacji zależą od modułu abstrakcji.

Przykład

Wyobraź sobie moduł funkcji, który do działania potrzebuje bazy danych. Moduł funkcji nie zajmuje się implementacją bazy danych, niezależnie od tego, czy jest to lokalna baza danych Room, czy zdalna instancja Firestore. Musi tylko przechowywać 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 definiuje interfejs API bazy danych aplikacji. Innymi słowy, określa reguły interakcji z bazą danych. Umożliwia to modułowi funkcji korzystanie z dowolnej bazy danych bez konieczności poznawania szczegółów jej implementacji.

Moduł konkretnej implementacji zawiera rzeczywistą implementację interfejsów API zdefiniowanych w module abstrakcji. Aby to zrobić, moduł implementacji zależy też od modułu abstrakcji.

Wstrzykiwanie zależności

Być może zastanawiasz się 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, jakich zależności potrzebuje. Zależności te są następnie dostarczane z zewnątrz, zwykle w module aplikacji.

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

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

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

Zalety

Oddzielenie interfejsów API od ich implementacji przynosi te korzyści:

  • Wymienność: dzięki wyraźnemu rozdzieleniu 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 korzysta z interfejsu API. Może to być szczególnie korzystne w sytuacjach, gdy chcesz zapewnić różne możliwości lub zachowania w różnych kontekstach. Może to być np. implementacja testowa lub implementacja produkcyjna.
  • Oddzielenie: oddzielenie oznacza, że moduły korzystające z abstrakcji nie zależą od żadnej konkretnej technologii. Jeśli później zdecydujesz się zmienić bazę danych z Room na Firestore, będzie to łatwiejsze, ponieważ zmiany będą dotyczyć tylko konkretnego modułu wykonującego zadanie (modułu implementacji) i nie wpłyną na inne moduły korzystające z interfejsu API bazy danych.
  • Możliwość testowania: oddzielenie interfejsów API od ich implementacji może znacznie ułatwić testowanie. Możesz tworzyć elementy testowania na podstawie umów interfejsu API. Możesz też używać różnych implementacji do testowania różnych scenariuszy i przypadków brzegowych, w tym implementacji próbnych.
  • Lepsza wydajność kompilacji: gdy rozdzielisz interfejs API i jego implementację na różne moduły, zmiany w module implementacji nie wymuszą ponownej kompilacji modułów zależnych od modułu interfejsu API. Przekłada się to na krótszy czas kompilacji i większą produktywność, szczególnie w przypadku dużych projektów, w których czas kompilacji może być znaczny.

Kiedy rozdzielać

Oddzielenie interfejsów API od ich implementacji jest korzystne w tych przypadkach:

  • Różne możliwości: jeśli możesz wdrożyć części systemu na kilka sposobów, przejrzysty interfejs API umożliwia zamienne stosowanie różnych implementacji. Możesz na przykład mieć system renderowania, który korzysta z interfejsu OpenGL lub Vulkan, albo system rozliczeniowy, który współpracuje z Google Play lub Twoim wewnętrznym interfejsem API do rozliczeń.
  • Wiele aplikacji: jeśli tworzysz wiele aplikacji o wspólnych funkcjach na różne platformy, możesz zdefiniować wspólne interfejsy API i opracować konkretne implementacje dla każdej platformy.
  • Niezależne zespoły: podział umożliwia różnym programistom lub zespołom jednoczesną pracę nad różnymi częściami bazy kodu. Deweloperzy powinni skupić się na zrozumieniu umów dotyczących interfejsu API i prawidłowym ich wykorzystaniu. Nie muszą się martwić 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. Umożliwia to podzielenie bazy kodu na mniejsze, bardziej zrozumiałe i łatwiejsze w utrzymaniu 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ą zachowanie funkcji.
  2. Tworzenie modułów implementacji: moduły implementacji powinny korzystać z modułu API i wdrażać zachowanie abstrakcji.
    Zamiast bezpośrednio zależeć od modułów niskiego poziomu, moduły wysokiego poziomu i moduły implementacji zależą od modułu abstrakcji.
    Rysunek 10. Moduły implementacji zależą od modułu abstrakcji.
  3. Uczyń moduły wysokiego poziomu zależnymi od modułów abstrakcji: zamiast bezpośrednio uzależniać moduły od konkretnej implementacji, uzależnij je od modułów abstrakcji. Moduły wysokiego poziomu nie muszą znać szczegółów implementacji, wystarczy im kontrakt (interfejs API).
    Moduły wyższego poziomu zależą od abstrakcji, a nie od implementacji.
    Rysunek 11. Moduły wysokiego poziomu zależą od abstrakcji, a nie od implementacji.
  4. Podaj moduł implementacji: na koniec musisz podać rzeczywistą implementację zależności. Szczegóły konkretnej implementacji zależą od konfiguracji projektu, ale zwykle dobrym miejscem na to jest moduł aplikacji. Aby udostępnić implementację, określ ją jako zależność wybranego wariantu kompilacji lub zestawu źródeł testowych.
    Moduł aplikacji zapewnia rzeczywistą implementację.
    Rysunek 12. Moduł aplikacji zawiera rzeczywistą implementację.

Ogólne sprawdzone metody

Jak wspomnieliśmy na początku, nie ma jednego właściwego sposobu tworzenia aplikacji wielomodułowej. Podobnie jak istnieje wiele architektur oprogramowania, istnieje wiele sposobów na podzielenie aplikacji na moduły. Niemniej jednak poniższe ogólne zalecenia mogą pomóc Ci zwiększyć czytelność, łatwość utrzymania i testowania kodu.

Zachowaj spójność konfiguracji

Każdy moduł wprowadza dodatkowe obciążenie związane z konfiguracją. Jeśli liczba modułów osiągnie określony próg, zarządzanie spójną konfiguracją staje się trudne. Na przykład ważne jest, aby moduły korzystały z zależności w tej samej wersji. Jeśli musisz zaktualizować dużą liczbę modułów tylko po to, aby zwiększyć wersję zależności, jest to nie tylko pracochłonne, ale też może prowadzić do błędów. Aby rozwiązać ten problem, możesz użyć jednego z narzędzi Gradle do scentralizowania konfiguracji:

  • Katalogi wersji to bezpieczna pod względem typów lista zależności generowana przez Gradle podczas synchronizacji. Jest to centralne miejsce do deklarowania wszystkich zależności i jest dostępne dla wszystkich modułów w projekcie.
  • Używaj wtyczek konwencji, aby udostępniać logikę kompilacji między modułami.

Jak najmniejsza ekspozycja

Interfejs publiczny modułu powinien być minimalny i udostępniać tylko niezbędne elementy. Nie powinna ujawniać żadnych szczegółów implementacji na zewnątrz. Ograniczaj zakres wszystkiego w jak największym stopniu. Użyj zakresu widoczności private lub internal w języku Kotlin, aby ustawić deklaracje jako prywatne dla modułu. Podczas deklarowania zależności w module preferuj implementation zamiast api. Ten drugi typ udostępnia zależności przechodnie konsumentom modułu. Użycie implementacji może skrócić czas kompilacji, ponieważ zmniejsza liczbę modułów, które wymagają ponownej kompilacji.

Preferuj moduły Kotlin i Java

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

  • Moduły aplikacji to punkt wejścia do aplikacji. Mogą zawierać kod źródłowy, zasoby, komponenty i AndroidManifest.xml. Wynikiem działania modułu aplikacji jest pakiet Android App Bundle (AAB) lub pakiet aplikacji na Androida (APK).
  • Moduły biblioteki mają taką samą zawartość jak moduły aplikacji. Są one używane przez inne moduły Androida jako zależności. Dane wyjściowe modułu biblioteki to archiwum Androida (AAR), które jest strukturalnie identyczne z modułami aplikacji, ale jest kompilowane do pliku archiwum Androida (AAR), który może być później używany przez inne moduły jako zależność. Moduł biblioteki umożliwia hermetyzację i ponowne wykorzystanie tej samej logiki i zasobów w wielu modułach aplikacji.
  • Biblioteki Kotlin i Java nie zawierają żadnych zasobów Androida, plików zasobów ani plików manifestu.

Moduły Androida wiążą się z narzutem, dlatego w miarę możliwości warto używać modułów w języku Kotlin lub Java.