Jak poukładać kod w aplikacji mobilnej? - cz. 2
25.11.2024 | Aleksander Kania
W tym artykule będę kontynuował temat dobrych praktyk szeroko pojętego programowania zapoczątkowanych w poprzedniej części, do której zapraszam jako wprowadzenie do tematu.
Wstęp
Na początku warto zadać sobie pytanie: po co w ogóle potrzebujemy architektury w procesie tworzenia aplikacji, czy – mówiąc potocznie – pisania kodu? Niektórzy mogliby powiedzieć: „skoro działa, to działa”.
Jednak jako programiści podczas swojej pracy mierzymy się z wieloma różnorodnymi projektami. Mogą to być mniejsze aplikacje, np. mobilne czy backendowe, ale również skomplikowane systemy, takie jak aplikacje bankowe. Przed rozpoczęciem pracy obowiązuje jedna podstawowa zasada: musimy dobrać odpowiednie narzędzia do zadania. W tym kontekście nie mówimy jedynie o IDE, języku programowania czy frameworku. Równie istotnym narzędziem, które warto starannie dobrać, jest architektura aplikacji.
Brak wyraźnie określonej architektury też jest swego rodzaju decyzją, ale dobrze zaplanowana architektura daje nam znacznie więcej korzyści. Umożliwia tworzenie kodu, który jest czytelny, łatwy w utrzymaniu i podatny na rozbudowę. Wybór odpowiedniej architektury przekłada się na efektywniejszą pracę, zwłaszcza gdy w przyszłości aplikacja wymaga rozwijania lub modyfikacji.
Przemyślane podejście do organizacji kodu to klucz do sukcesu. Co więcej, nawet jeśli na początku nie zdecydujemy się na wdrożenie najlepszych praktyk, nic nie stoi na przeszkodzie, aby zrobić to później. Wprowadzenie dobrych wzorców i uporządkowanie kodu zawsze pozostaje w naszym zasięgu, niezależnie od etapu projektu.
Popularne architektury stosowane w aplikacjach mobilnych
Przyjrzyjmy się kilku popularnym architekturom, które są często wybierane podczas tworzenia aplikacji mobilnych, choć ich zastosowanie nie ogranicza się wyłącznie do tego obszaru.
Model-View-Controller (MVC)
MVC to domyślna architektura proponowana (choć niewymuszana) przez Apple podczas tworzenia aplikacji na iOS przy użyciu frameworka UIKit. Zakłada ona podział każdego ekranu na trzy kluczowe komponenty:
- Model – odpowiada za przechowywanie logiki biznesowej i danych aplikacji, np. obiekt AccountDetails
- View – zawiera logikę odpowiedzialną za rysowanie i wyświetlanie elementów interfejsu użytkownika
- Controller – pełni rolę pośrednika, przetwarzając dane i obsługując interakcje użytkownika
Model-View-Presenter (MVP)
Architektura MVP jest bardzo podobna do MVC, z tą różnicą, że logika przetwarzania danych dla widoku jest przeniesiona do odrębnego komponentu zwanego Prezenterem.
- Prezenter można „poprosić” o wykonanie konkretnych działań, np. przejścia na inny ekran po kliknięciu przycisku
- Odpowiada on za pobieranie, przetwarzanie i przekazywanie danych w formie gotowej do wyświetlenia w widoku
- Co ważne, prezenter posiada referencję do widoku, co pozwala na jego bezpośrednie aktualizowanie
Model-View-ViewModel (MVVM)
MVVM jest podobne do MVP, ale wprowadza dodatkową warstwę abstrakcji w postaci ViewModelu, który stanowi „opakowanie” dla danych dostarczanych do widoku.
- ViewModel udostępnia komendy (metody) umożliwiające komunikację między modelem danych a widokiem
- Kluczową różnicą w stosunku do MVP jest brak referencji ViewModelu do widoku – nie wie on, gdzie i jak dane są prezentowane. Komunikacja odbywa się za pomocą callbacków, delegacji lub sygnałów (w przypadku programowania reaktywnego)
- Dzięki niezależności ViewModel może być ponownie wykorzystywany w różnych miejscach aplikacji. Przykładem jest SearchViewModel, który przetwarza dane do widoku wyszukiwania i może być używany w różnych ekranach bez konieczności modyfikacji
Którą architekturę wybrać najlepiej?
Nie da się jednoznacznie odpowiedzieć na to pytanie, ponieważ wybór odpowiedniej architektury zależy od indywidualnych potrzeb, preferencji oraz stopnia złożoności projektu. Choć każda z opisanych architektur cechuje się względną prostotą w swojej konstrukcji, niewłaściwe zastosowanie może prowadzić do nadmiernego skomplikowania kodu.
Spójrzmy na przykład. Poniżej zaprezentowałem kontroler napisany w języku Swift oraz najbardziej prostej implementacji MVC, który ma za zadanie wyświetlić listę dostępnych kontaktów do przelewu:
final class PaymentsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() configureView() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) let contacts = fetchContacts() displayContacts(contacts: contacts) } private func configureView() { // do some view configurations } private func fetchContacts() -> [Contact] { // fetch contacts from system and do other stuff return [ .init(name: "John Appleseed", phoneNumber: "2112345"), .init(name: "Jane Appleseed", phoneNumber: "2112345"), ] } private func displayContacts(contacts: [Contact]) {} }
Już na pierwszy rzut oka widzimy, że naruszamy tutaj zasadę Single Responsibility (z SOLID). Dlaczego? Ponieważ nasz kontroler przejmuje odpowiedzialność za niemal wszystkie podprocesy związane z wyświetlaniem kontaktów:
- pobiera dane z bazy,
- renderuje widok,
a na dodatek łatwo ulec pokusie, by dodać do niego obsługę kliknięć na elementy widoku. Takie podejście prowadzi do kilku istotnych problemów:
- rozrastający się kod – po kilku sprintach kontroler może liczyć nawet tysiąc linii kodu, co znacząco utrudnia jego zrozumienie,
- spadek czytelności – z czasem kod staje się trudny w utrzymaniu i nieczytelny, szczególnie dla nowych członków zespołu,
- problemy z rozwojem – rozbudowa kontrolera o kolejne funkcjonalności staje się skomplikowana i czasochłonna.
Jak temu zaradzić? Jednym z rozwiązań jest zastosowanie odpowiednich technik i wzorców projektowych, które pozwalają na uproszczenie i uporządkowanie kodu. W tym przypadku warto sięgnąć po architekturę, która narzuca dobrą strukturę aplikacji. W tym artykule pokażę, jak możemy rozwiązać ten problem za pomocą architektury MVP (Model-View-Presenter). Zobaczmy, jak mogłoby to wyglądać w praktyce:
Utworzenie protokołu PaymentsView, za pomocą którego prezenter będzie komunikował się z kontrolerem
protocol PaymentsView: AnyObject {
func displayContacts(contacts: [Contact])
}
Implementacja PaymentsPresenter
final class PaymentsPresenter { weak var view: PaymentsView? func attachView(_ view: PaymentsView) { self.view = view } func viewDidAppear() { let contacts = fetchContacts() view?.displayContacts(contacts: contacts) } private func fetchContacts() -> [Contact] { // fetch contacts from system and do other stuff return [ .init(name: "John Appleseed", phoneNumber: "2112345"), .init(name: "Jane Appleseed", phoneNumber: "2112345"), ] } }
Poprawiony controller
final class PaymentsViewController: UIViewController { private var presenter: PaymentsPresenter? override func viewDidLoad() { super.viewDidLoad() configureView() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) presenter?.viewDidAppear() } func configure(with presenter: PaymentsPresenter) { self.presenter = presenter presenter.attachView(self) } private func configureView() { // do some view configurations } }
Przyjrzymy się zmianom:
PaymentsPresenter – Czytelny i Nowoczesny Podział Odpowiedzialności
W przypadku tego rozwiązania PaymentsPresenter przejmuje odpowiedzialność za pobieranie danych w odpowiedzi na sygnał z widoku (np. viewDidAppear) i zwraca je do widoku, z którym jest powiązany (czyli PaymentsView). Dzięki temu nasz kontroler skupia się wyłącznie na dwóch zadaniach: konfiguracji widoku i prezentacji danych, co odpowiada oczekiwanej strukturze odpowiedzialności.
Konfiguracja widoku – Kluczowe Elementy
1. Metoda configure(with presenter: PaymentsPresenter)
Ta metoda może być wywoływana na przykład przez obiekt typu Factory, który odpowiada za tworzenie ekranu, lub w innym miejscu inicjalizacji kontrolera. Jej zadaniem jest także poinformowanie prezentera, z którym widokiem będzie pracował.
2. Metoda configureView()
Jej odpowiedzialność pozostaje bez zmian – odpowiada za konfigurację elementów widoku, takich jak kolor tła, ustawienia przycisków i inne detale wyglądu interfejsu użytkownika.
Komunikacja z Presenterem – Deklaratywność i Elastyczność
Przykład wywołania: presenter?.viewDidAppear() pokazuje, jak wygląda współpraca widoku z presenterem. Nic nie stoi na przeszkodzie, aby zmienić nazwę metody na np. fetchData, ale obecne rozwiązanie ma swoje zalety. Pozostając przy nazwie viewDidAppear, zachowanie kontrolera zostaje spójnie odwzorowane w zachowaniu prezentera, co poprawia czytelność i lepiej odwzorowuje stan widoku.
Warto zauważyć, że widok nie musi wiedzieć, co dokładnie wydarzy się po wywołaniu metody – jego rolą jest jedynie poinformowanie o akcji (np. „widok się pojawił”) i oczekiwanie na odpowiedź od prezentera. Takie deklaratywne podejście nie tylko ułatwia utrzymanie kodu, ale także sprawia, że rozwiązanie jest bardziej nowoczesne i czytelne.
Podsumowując - dzięki takiemu podziałowi ról:
- Kontroler odpowiada jedynie za zarządzanie widokiem
- Presenter przejmuje logikę biznesową i zarządzanie danymi
- Widok pozostaje prostym odbiorcą sygnałów, który nie musi znać szczegółów implementacyjnych
Efekt? Kod staje się bardziej modularny, łatwy w utrzymaniu i skalowalny.
Zastosowanie jednej z popularnych architektur, aby uporządkować kod to dopiero początek. Jeśli przyjrzymy się naszemu poprawionemu rozwiązaniu szybko zauważymy, że nasz prezenter przejął teraz cały kod, który był wcześniej w kontrolerze co w efekcie może prowadzić do podobnego problemu z czytelnością tylko tym razem przeniesionym do innej klasy.
Aby temu zaradzić należy jeszcze raz przyjrzeć się odpowiedzialności klasy i mając na uwadzę poznane wcześniej zasady SOLID wydzielić odpowiednią logikę do osobnych komponentów. Dzięki temu nasz kod stanie się czytelniejszy i modularny. Jeden z takich sposobów zaprezentuję w swoim kolejnym artykule.
Podsumowanie
Bardzo ważnym elementem w procesie rozwijania architektury są eksperymenty i iteracje. Testowanie różnych podejść w praktyce pozwala ocenić, które rozwiązanie najlepiej odpowiada specyfice danej aplikacji i zespołu. Nie ma jednego idealnego szablonu – kluczowe jest dostosowanie architektury do rzeczywistych potrzeb projektu.
Na zakończenie warto również dodać, że technologia cały czas się rozwija. Wybranie dzisiaj jednej architektury, nie zawsze sprawdzi się w kolejnych latach. Przykładowo na platformie iOS tworząc widoki z wykorzystaniem UIKit z powodzeniem możemy stosować wzorzec MVP, jednak w SwiftUI bardziej sprawdzi nam się MVVM. Nie ma więc nic złego w zmianach i np. tworzeniu hybryd różnych architektur.