Jak poukładać kod w aplikacji mobilnej? - cz. 1

25.11.2024 |  Aleksander Kania

Wstęp

Wyobraźmy sobie sytuację, w której czteroosobowy zespół doświadczonych programistów dostaje zadanie stworzenia mobilnej aplikacji finansowej od podstaw. Aplikacja ma umożliwiać proste rozliczenia między znajomymi za wspólne zakupy lub wyjścia. Rozpoczynamy pracę, wdrażamy kolejne funkcje i staramy się szybko realizować zadania, by zdążyć na wyznaczony termin i móc zaprezentować efekty klientowi. Po dwóch miesiącach dostarczamy wersję MVP (Minimum Viable Product). Brzmi dobrze? Niestety, rzeczywistość bywa bardziej złożona.

Pojawia się pytanie: "dlaczego?". Projekty tego typu charakteryzują się tym, że nowe wymagania pojawiają się stale – nie tylko w formie nowych funkcji, ale też rozszerzeń istniejących już elementów. Załóżmy, że Product Owner chce dodać możliwość sortowania listy wydatków według kwoty. Pozornie proste zadanie okazuje się bardziej złożone, ponieważ implementacja bazowa nie przewidziała sortowania według kwoty, co wymaga modyfikacji kilku metod API. Realizacja tego zadania zajmuje więcej czasu, niż zakładaliśmy, ale jest to część codzienności pracy programisty.

Wyobraźmy sobie jednak, że po kolejnych dwóch miesiącach napotykamy następne wyzwanie – rozwinięcie funkcji, której realizacja ponownie sprawia trudność, ponieważ obecna architektura nie jest wystarczająco elastyczna. Może się wydawać, że powiększenie zespołu przyspieszy pracę, jednak często bywa odwrotnie – im większy zespół, tym wolniej postępuje rozwój projektu. Dlaczego? Bo wraz z rozrostem zespołu rośnie także kod i jego złożoność. Decyzje podjęte na początku projektu zaczynają ujawniać swoje konsekwencje, co prowadzi do dodatkowych kosztów i problemów.

Czy możemy uniknąć takiej sytuacji? 

Tak – podejmując odpowiedzialne i przemyślane decyzje dotyczące architektury projektu już na jego początku. A co z istniejącymi projektami? Czy jest za późno na poprawki? Absolutnie nie! Gdy zauważymy, że aktualna architektura staje się problematyczna, możemy ją stopniowo ulepszać, co z czasem ułatwi nam pracę.
Takie działania wymagają jednak cierpliwości i stopniowego podejścia. Nie można od razu przeprowadzić całościowej refaktoryzacji. Kluczem jest wdrażanie zmian małymi krokami, by nie spowolnić rozwoju. Zacznijmy od przemyślenia nowej architektury i wprowadzajmy ją w nowych funkcjach. Jednocześnie, podczas pracy nad istniejącym kodem, warto dokonywać drobnych ulepszeń w miejscach, które aktualnie modyfikujemy. Dzięki temu możemy podzielić odpowiedzialność, powoli ulepszać kod i stale się uczyć.
Wraz z rozwojem zespołu zwiększają się także nasze możliwości wprowadzania poprawek w architekturze. Dobrym podejściem jest identyfikacja obszarów wymagających poprawy i rozwiązywanie problemów krok po kroku. Z czasem nasz kod będzie coraz lepszy, jednak warto pamiętać, że jest to proces – wymaga czasu i cierpliwości.

Jak zrobić pierwszy krok w kierunku pisania lepszego kodu? 

Choć odpowiedź na to pytanie wydaje się prosta, nie każdy programista jest jej świadomy, a niektórzy z zasady unikają zmian, uznając, że „skoro działa, to nie ma potrzeby tego zmieniać.”
A jednak — jest jedna zasada, która powinna być punktem wyjścia do pisania lepszego kodu: stosowanie się do reguł.

SOLID 

Zasady SOLID są od lat powszechnie stosowane w świecie programowania, pomagając w tworzeniu kodu, który jest czysty, łatwy do zrozumienia i prosty do utrzymania. Zastosowanie ich w praktyce pomaga uniknąć przyszłych problemów związanych z rozwojem projektu. Podsumujmy, co oznaczają te zasady w kilku prostych słowach:
S - Single Responsibility Principle (Zasada Jednej Odpowiedzialności)
Każdy moduł, czy to plik, komponent, czy klasa, powinien odpowiadać za jedno konkretne zadanie i mieć jasno określoną odpowiedzialność. Dzięki temu kod staje się bardziej przejrzysty, a ewentualne zmiany w jednej części nie wpływają negatywnie na inne funkcje systemu.
O - Open-Closed Principle (Zasada Otwarte-Zamknięte)
Moduł powinien być tak zaprojektowany, aby łatwo było go rozszerzać o nowe funkcje bez konieczności modyfikowania istniejącego kodu. Taka zasada chroni kod przed błędami, które mogą się pojawić przy zmianach, a także ułatwia dodawanie nowych funkcjonalności.
L - Liskov Substitution Principle (Zasada Podstawienia Liskov)
Komponenty powinny być wymienne, co oznacza, że każda klasa pochodna powinna zachowywać się jak klasa bazowa, nie zmieniając podstawowej logiki działania. Dzięki temu kod jest bardziej elastyczny i umożliwia łatwą wymianę komponentów bez konieczności modyfikacji pozostałych części systemu.
I - Interface Segregation Principle (Zasada Segregacji Interfejsów)
Interfejsy powinny zawierać tylko te metody, które są faktycznie potrzebne i wykorzystywane przez moduł, który je implementuje. Takie podejście zapobiega „ciężkim” interfejsom, które wymagają od klas implementacji metod, z których nigdy nie korzystają.
D - Dependency Inversion Principle (Zasada Odwrócenia Zależności)
Kod powinien opierać się na abstrakcjach, a nie na konkretnych implementacjach, co zwiększa jego elastyczność i ułatwia modyfikację. Dzięki temu klasy wysokopoziomowe nie są uzależnione od szczegółów klas niskopoziomowych, co znacząco poprawia możliwość ponownego użycia kodu i jego testowalność.
Stosowanie zasad SOLID pomaga tworzyć kod łatwy do zarządzania, elastyczny i odporny na zmiany, co w dłuższej perspektywie znacząco upraszcza rozwój projektu.

Spójrzmy teraz na prostą implementację zasad SOLID w jezyku Swift.

S - Single Resposibility Principle

protocol TransactionProcessor {
  func process()
}
class ExternalTransactionProcessor: TransactionProcessor {
  func process() {
    print("Processing external transaction")
  }
}
class InternalTransactionProcesor: TransactionProcessor {
  func process() {
    print("Processing internal transaction")
  }
}
final class TransactionFacade {
  func processInternalTransaction() {
    InternalTransactionProcesor().process()
  }
  func ExternalTransactionProcessor() {

    PDFFileProcesor().process()
  }
}

O - Open-Close Principle

Poniższy przykład ilustruje nieco inne podejście do zasady Open-Closed. W tym ujęciu zasada Open-Closed odnosi się bardziej do struktury całej aplikacji niż do pojedynczych klas.
Zobaczmy, jak można sformułować definicję:
Moduł powinien być otwarty na rozszerzenia, ale zamknięty na modyfikacje — należy chronić komponenty wyższego poziomu przed wpływem zmian w komponentach niższego poziomu.
W praktyce oznacza to, że programista może dokonać zmian w warstwie widoku (np. ViewController), podczas gdy warstwa logiki biznesowej (ViewModel) pozostanie nietknięta. Innymi słowy, komponent wyższego poziomu (ViewModel) jest zabezpieczony przed zmianami w komponencie niższego poziomu (ViewController). Dzięki temu możemy zmieniać sposób prezentacji danych bez ingerencji w logikę ich pobierania, co zwiększa stabilność i łatwość rozwoju aplikacji.

final class TransactionHistoryViewModel {
    private let external: ExternalTransactionInteractor
    private let internal: InternalTransactionInteractor
    func fetchTransactions(type: TransactionType) {
        // ...
    }
}
final class TransactionHistoryViewController: UIViewController {
    private let viewModel = TransactionHistoryViewModel()
    override func viewDidLoad() {
        super.viewDidLoad()
        interactor.fetchTransactions(type: .external)
    }
}

L - Liskov Substition Principle

Poniższa implementacja ilustruje prosty sposób zastosowania zasady podstawienia Liskov (LSP) na przykładzie różnych typów transakcji. Aby lepiej to zrozumieć, zastanówmy się: czy każda transakcja w banku jest taka sama? Oczywiście nie – jednak wszystkie posiadają wspólna właściwość - jej wykonanie. Z punktu widzenia programisty te cechy można ująć za pomocą wspólnej metody execute, która umożliwia dostęp do kluczowego momentu dla każdej transakcji. 

class WebTransaction: Transaction {
  func execute() {
    print("WebTransaction")
  }
}
class CardTransaction: Transaction {
  func execute() {
    print("CardTransaction")
  }
}
// Złamanie zasady LSP
class CarTransaction: Transaction {
  func execute() {
    // ..
  }
}

I - Interface Segregation Principle

Przyjrzyjmy się teraz zasadzie ISP. Dla użytkownika (programisty, który korzysta z modułu) udostępniamy wyłącznie te funkcjonalności, które są mu naprawdę potrzebne, za pomocą interfejsów lub ich odpowiedników w innych językach programowania, takich jak protokoły w języku Swift.

protocol PDFProcessor {
  func processPDFFile()
}
extension FileProcessFacade: PDFProcessor {}
let processor: PDFProcessor = FileProcessFacade()
func processPDFRaport(processor: PDFProcessor) {
  processor.processPDFFile()
}

Dzięki zasadzie ISP możemy ukryć skomplikowaną implementację metody processPDFFile, jednocześnie korzystając z różnych modułów i funkcji w tle. Finalnie użytkownik otrzymuje prostą, łatwą do użycia metodę, bez konieczności martwienia się o szczegóły implementacyjne.

D - Dependency Inversion Principle

Zasada DIP cieszy się dużą popularnością wśród programistów, szczególnie gdy potrzebujemy wprowadzić abstrakcje w kodzie, takie jak tworzenie mocków czy serwisów. Dzięki DIP możemy "wstrzyknąć" dowolną implementację do modułu lub klasy, a jedynym wymogiem jest spełnienie określonego interfejsu i zaimplementowanie odpowiednich metod.
Przykładem zastosowania DIP mogą być testy jednostkowe, gdzie musimy przetestować komponent wymagający dostępu do różnych serwisów, często z połączeniem internetowym. Ponieważ w testach jednostkowych nie chcemy polegać na zewnętrznych połączeniach, rozwiązaniem jest stworzenie mocków serwisów, które zwracają przygotowane odpowiedzi. Następnie, za pomocą Dependency Injection, wstrzykujemy te mocki do komponentu. Dzięki temu możemy skupić się na testowaniu tylko istotnej logiki komponentu, bez zależności od zewnętrznych serwisów.
Oto przykład:

let fileProcessor: FileProcessor = PDFFileProcesor()
  fileProcessor.process()
  class MockedFileProcessor: FileProcessor {
    func process() {
      print("Process file for tests")
    }
  }
  let mockedFileProcessor: FileProcessor = MockedFileProcessor()
  mockedFileProcessor.process()
  

Podsumowanie

Powyższe przykłady stanowią solidną podstawę do codziennego stosowania zasad SOLID w kodzie. Jednak programowanie i projektowanie architektury to znacznie więcej. Pracując nad dużymi projektami, musimy zarządzać setkami, a czasami nawet tysiącami modułów. W wielu przypadkach te moduły wymagają integracji z zewnętrznymi komponentami, co stawia przed nami wyzwanie stworzenia czytelnego i łatwego do zarządzania kodu. Zastosowanie zasad SOLID to pierwszy krok do zaprojektowania modułów w sposób rozszerzalny i łatwy do utrzymania.
To jednak dopiero początek! Aplikacje niezależnie od tego, czy jest to frontend, backend czy aplikacje mobilne posiadaja punkt wyjścia, który może przyjąć formę ekranów, widoków lub kontrolerów CRUD czy interfejsu w terminalu. Każde z tych wejść pobiera dane z serwisu lub otrzymuje je z innych źródeł następnie przetwarza je i zwraca użytkownikowi w odpowiedniej, czytelnej formie. Istnieje wiele sposobów, aby zrealizować tę logikę. Jednym z nich jest stosowanie Clean Architecture. W drugiej części tego artykułu przyjrzymy się, jak zaprojektować moduł oparty na zasadach SOLID, korzystając z tej architektury.