Coupling (sprzężenie) to metryka pokazująca, jak bardzo jeden moduł aplikacji zależy od drugiego. Według Wikipedia, słabe sprzężenie (low coupling) to cecha dobrze zaprojektowanego systemu, w którym moduły można zmieniać bez psucia sąsiednich. Zarządzanie couplingiem to jedno z głównych zadań architekta przy projektowaniu aplikacji mobilnych.
Najważniejsze
Coupling (sprzężenie) to metryka określająca, jak mocno jeden moduł lub klasa jest powiązana z innym. Im więcej jeden moduł wie o wewnętrznej budowie drugiego, tym wyższy coupling i tym trudniej zmieniać system. W dobrze zaprojektowanej architekturze coupling powinien być minimalny — moduły współdziałają tylko przez ściśle określone interfejsy.
Wyróżnia się dwie strony coupling: afferent (zależności wchodzące — ile modułów zależy od danego) i efferent (zależności wychodzące — od ilu modułów zależy dany). Analiza tych metryk pozwala zidentyfikować „gorące punkty" w architekturze, gdzie zmiana jednego modułu wpłynie na wiele innych. Narzędzia takie jak IntelliJ Dependency Analyzer i Xcode Graph wizualizują te powiązania.
Ważne jest, aby zrozumieć, że zerowy coupling jest niemożliwy — moduły muszą jakoś współdziałać, inaczej to nie system, tylko zbiór izolowanych programów. Zadaniem architekta jest uczynić coupling zarządzalnym i przejrzystym. Ideał: moduły współdziałają tylko przez interfejsy i przekazują tylko proste dane, nie wiedząc o wewnętrznej budowie siebie nawzajem. Nazywa się to loose coupling (słabe sprzężenie).
Sześć typów coupling tworzy skalę od najlepszego do najgorszego. Zrozumienie tej skali pomaga ocenić istniejący kod i wybrać kierunek refaktoryzacji. Większość projektów mobilnych ma mieszane typy coupling, a zadaniem architekta jest sukcesywne zastępowanie silnych typów słabszymi.
Data coupling (sprzężenie przez dane) — moduły wymieniają się tylko prostymi danymi przez parametry metod. Moduł A wywołuje metodę modułu B, przekazując prymitywy lub proste struktury, i otrzymuje wynik. Moduł A nie wie, jak B jest zaimplementowany wewnątrz. To najbardziej pożądany typ coupling: minimalizuje skutki zmian.
Przykład: EmailValidator.isValid(email: String): Boolean. Klasa-konsument przekazuje ciąg znaków i otrzymuje Boolean, nie mając pojęcia o wyrażeniach regularnych ani regułach walidacji wewnątrz validatora. Zmiana logiki walidacji nie wymaga zmiany konsumenta — coupling jest minimalny. Data coupling to cel dla wszystkich publicznych interfejsów w aplikacji.
Stamp coupling (sprzężenie przez strukturę) — moduły wymieniają się złożonymi obiektami, ale używają tylko części ich pól. Moduł A przekazuje obiekt User do metody calculateDiscount, która używa tylko user.status. Problem: jeśli struktura User się zmieni (dodane zostanie obowiązkowe pole), moduł calculateDiscount się nie zmieni, ale konsument tworzący obiekt User — tak.
W praktyce stamp coupling jest nieunikniony i dopuszczalny, jeśli przekazywany obiekt jest standardowym modelem danych (Entity). Problem pojawia się, gdy moduł otrzymuje cały obiekt tylko dla jednego pola. W takich przypadkach lepiej przekazywać konkretną wartość bezpośrednio (data coupling). Rozwiązanie — analizować użycie pól przez stronę odbierającą.
Control coupling — jeden moduł przekazuje drugiemu flagę sterującą jego zachowaniem (calculate(useNewAlgorithm: Boolean)). Jest gorszy niż stamp coupling, ponieważ moduł-konsument musi znać wewnętrzne warianty działania wywoływanego modułu. Rozwiązanie: podzielić metodę na dwie — calculateWithNewAlgorithm() i calculateWithLegacyAlgorithm().
External coupling — moduły zależą od zewnętrznego protokołu, formatu danych lub API. Wszystkie moduły parsujące jeden JSON lub pracujące z jedną bazą danych mają external coupling. Całkowicie go uniknąć nie można, ale można go izolować: stworzyć warstwę mapowania między zewnętrznym formatem a wewnętrznymi modelami. Common coupling — moduły współdzielą wspólny stan globalny. Content coupling — najgorszy typ, gdy moduł bezpośrednio zmienia wewnętrzne dane innego modułu.
| Typ coupling | Poziom | Opis |
|---|---|---|
| Data | Najlepszy | Przekazywanie prostych danych przez parametry |
| Stamp | Akceptowalny | Przekazywanie obiektów z częściowym użyciem |
| Control | Średni | Sterowanie zachowaniem przez flagi |
| External | Wysoki | Zależność od zewnętrznego protokołu/formatu |
| Common | Bardzo wysoki | Współdzielenie stanu globalnego |
| Content | Niedopuszczalny | Bezpośrednia zmiana wewnętrznych danych modułu |
Skala coupling od data (ideał) do content (katastrofa) — praktyczne narzędzie do code review. Jeśli widzisz w projekcie common lub content coupling — to priorytetowy cel refaktoryzacji. Data i stamp coupling są dopuszczalne i występują w każdym projekcie, ale ich liczba powinna być kontrolowana.
Wysoki coupling zamienia rozwój w spowolniony proces, gdzie każda zmiana wymaga sprawdzenia dziesiątek potencjalnie zepsutych modułów. W programowaniu mobilnym jest to szczególnie krytyczne: platformy aktualizują się corocznie (Android API Level, iOS SDK), biblioteki — kwartalnie, a wymagania biznesowe — nieprzerwanie. Słabe sprzężenie to jedyny sposób radzenia sobie z tym strumieniem zmian bez ciągłych regresji.
Przykład z praktyki: aplikacja mobilna, gdzie wszystkie ekrany bezpośrednio importują NetworkingManager i DatabaseManager. Przy wymianie klienta HTTP z Retrofit na Ktor (Android) lub z URLSession na Alamofire (iOS) programista musiałby poprawić każdy ekran. Przy niskim coupling wystarczy zmienić jedną implementację ukrytą za interfejsem NetworkDataSource — konsumenci nie zauważą zamiany.
Wpływ coupling na testy jednostkowe jest również ogromny. Klasa z wysokim coupling (bezpośrednie tworzenie zależności przez konstruktor) jest niemożliwa do przetestowania w izolacji — ciągnie za sobą bazę danych, sieć i UI. Do testowania takiej klasy trzeba uruchomić emulator i czekać na testy integracyjne. Klasa z niskim coupling przyjmuje zależności przez constructor injection i łatwo ją zmockować.
// Wysoki coupling — klasa sama tworzy swoje zależności
class ProfileViewModelHigh {
private val api = RetrofitApi()
private val db = RoomDatabase.getInstance()
private val cache = MemoryCache()
}
// Niski coupling — zależności są przekazywane przez konstruktor
class ProfileViewModelLow(
private val api: ApiService,
private val db: DatabaseService,
private val cache: CacheService
)
W pierwszym przypadku ProfileViewModelHigh jest sztywno przywiązany do konkretnych implementacji — zamiana Retrofit na Ktor wymaga zmiany kodu ViewModel. W drugim przypadku ProfileViewModelLow zależy tylko od interfejsów, których implementacje są dostarczane z zewnątrz. Testowanie drugiej klasy jest trywialne: przekazujemy mock-implementacje i sprawdzamy logikę bez emulatora.
Dependency Inversion Principle (D w SOLID) — podstawa redukcji coupling. Zasada nakazuje zależeć od abstrakcji, a nie od konkretnych implementacji. Zamiast klasa bezpośrednio tworzy obiekt RetrofitApi, powinna otrzymywać interfejs ApiService. To przenosi powiązanie z konkretnej biblioteki na poziom abstrakcji, którą można wymienić bez zmiany konsumenta.
Observer pattern (lub jego reaktywne wersje — StateFlow, Combine Publishers) zmniejsza coupling między źródłem danych a subskrybentami. Subskrybent nie wie, skąd pochodzą dane — po prostu reaguje na zmiany. To rozdziela nadawcę i odbiorcę: można dodać nowe źródło danych bez zmiany istniejących subskrybentów. EventBus i SharedFlow działają na tej samej zasadzie.
Bridge pattern oddziela abstrakcję od implementacji, pozwalając im zmieniać się niezależnie. W programowaniu mobilnym Bridge stosuje się na przykład dla modułów zależnych od platformy: wspólny interfejs ImageLoader z różnymi implementacjami dla iOS (Kingfisher, Nuke) i Android (Glide, Coil). Kod pracujący z ImageLoader nie zależy od wybranej biblioteki i może ją wymienić prostą zmianą implementacji.
Dependency Injection (DI) — najbardziej praktyczne narzędzie redukcji coupling w programowaniu mobilnym. Zamiast klasa samodzielnie tworzy swoje zależności, DI-kontener (Hilt, Koin, Dagger dla Android; Swinject, Factory dla iOS) dostarcza je z zewnątrz. Klasa otrzymuje zależności przez constructor, method lub property injection, pozostając w nieświadomości konkretnych implementacji.
DI jawnie dokumentuje zależności klasy: wystarczy spojrzeć na konstruktor, aby zrozumieć, z jakimi modułami współdziała klasa. Jeśli konstruktor przyjmuje 8 parametrów z różnych warstw — to sygnał nadmiernego coupling, wymagający refaktoryzacji. Dobrą praktyką jest nie więcej niż 3-4 zależności na klasę. Większa liczba wskazuje na naruszenie Single Responsibility i nadmierny coupling.
DI również upraszcza testowanie: dla każdego testu tworzysz klasę z mock-zależnościami, nie wymagając prawdziwej bazy danych ani sieci. We Flutter DI jest implementowane przez Provider, Riverpod lub GetIt. Niezależnie od frameworka cel jest jeden: osłabić powiązania między modułami, czyniąc zależności jawnymi i wymienialnymi. Stosowanie DI w projekcie mobilnym to de facto standard od lat 2020.
// DI-kontener buduje graf zależności
protocol AuthServiceProtocol {
func login(email: String, password: String) async throws -> User
}
final class AuthService: AuthServiceProtocol {
func login(email: String, password: String) async throws -> User {
// implementacja
}
}
// ViewModel nie wie o konkretnym serwisie — tylko protokół
final class LoginViewModel {
private let auth: AuthServiceProtocol
init(auth: AuthServiceProtocol) {
self.auth = auth
}
}
// DI Container — jedyne miejsce, gdzie tworzone są konkretne typy
final class DIContainer {
lazy var authService: AuthServiceProtocol = AuthService()
lazy var loginViewModel: LoginViewModel {
LoginViewModel(auth: self.authService)
}
}
Tutaj LoginViewModel zależy tylko od protokołu AuthServiceProtocol, a nie od konkretnego AuthService. Zamiana implementacji (na przykład przejście z Firebase Auth na własny serwer) wymaga zmian tylko w DIContainer. Wszyscy konsumenci AuthServiceProtocol pozostają nietknięci — coupling jest zredukowany do minimum przez abstrakcję i DI.
Często zadawane pytania
Cohesion mierzy wewnętrzną spójność modułu, coupling — zewnętrzne powiązania między modułami. Dobra architektura dąży do wysokiego cohesion i niskiego coupling. Te metryki są odwrotnie proporcjonalne: zwiększenie cohesion zwykle zmniejsza coupling i odwrotnie.
Data i stamp — norma i występują w każdym projekcie. Control coupling jest dopuszczalny w ograniczonych scenariuszach (na przykład strategy pattern). External coupling jest nieunikniony przy pracy z zewnętrznymi API, ale powinien być izolowany za warstwą mapowania. Common i content coupling to oznaki problemów architektonicznych wymagające natychmiastowej refaktoryzacji.
Narzędzia analizy statycznej: IntelliJ IDEA Dependency Matrix, Xcode Graph, Gradle Dependencies report, SonarQube. Metryki: afferent coupling (Ca), efferent coupling (Ce), Instability (Ce/(Ca+Ce)). Wysoki Instability (bliski 1) oznacza, że moduł łatwo zmieniać i mało kto się do niego odwołuje — to dobrze.
Bardzo niski coupling może oznaczać nadmierną liczbę abstrakcji i interfejsów, które utrudniają nawigację po kodzie. Jeśli dla każdej klasy utworzono osobny interfejs, programista traci czas na skakanie między plikami. Balans: interfejsy dla zewnętrznego API modułu, ale nie dla każdej wewnętrznej klasy pomocniczej.
Użyj techniki Strangler Fig — stopniowo zastępuj bezpośrednie wywołania przez interfejsy. Zacznij od extract interface dla klas, do których najczęściej się odwołujesz. Następnie wprowadź DI-kontener. Pokrywaj izolowany kod testami charakterystycznymi (Characterisation tests), aby upewnić się, że refaktoryzacja nie zmienia zachowania systemu.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również