Skip to content

Instantly share code, notes, and snippets.

@MarcelStarczyk
Created April 13, 2023 14:39
Show Gist options
  • Save MarcelStarczyk/6b6153051f46912a665c32199f0d1d54 to your computer and use it in GitHub Desktop.
Save MarcelStarczyk/6b6153051f46912a665c32199f0d1d54 to your computer and use it in GitHub Desktop.

The Composable Architecture

CI Slack

The Composable Architecture (w skrócie TCA) to biblioteka do budowania aplikacji w sposób spójny i zrozumiały, z myślą o kompozycji, testach i ergonomii. Może być używana w SwiftUI, UIKit i innych oraz na dowolnej platformie Apple (iOS, macOS, tvOS i watchOS).

Czym jest The Composable Architecture?

Ta biblioteka zapewnia kilka podstawowych narzędzi, które można wykorzystać przy budowaniu aplikacji do różnych celów i o różnej złożoności. Zapewnia rozwiązania, które możesz wykorzystać, aby poradzić sobie z wieloma problemami, z którymi spotykasz się przy codziennej pracy:

  • Zarządzanie stanem
    Jak zarządzać stanem aplikacji za pomocą prostych typów i współdzielić stan pomiędzy wieloma ekranami, aby zmiany stanu na jednym ekranie można natychmiast zaobserwować na innym.

  • Kompozycja
    Jak rozbić duże funkcjonalności na mniejsze, które można wyodrębnić do własnych, odizolowanych modułów i które później, można w prosty sposób połączyć we wspólną funkcjonalność.

  • Efekty uboczne
    Jak pozwolić częściom aplikacji rozmawiać ze światem zewnętrznym w najbardziej testowalny i zrozumiały sposób.

  • Testy
    Jak napisać testy, nie tylko do pojedynczej funkcjonalności napisanej w tej architekturze, ale również jak napisać testy integracyjne do wielu połączonych ze sobą funkcjonalności oraz jak napisać testy end-to-end sprawdzające wpływ skutków ubocznych oddziałujących na Twoją aplikację. Wszystkie te rzeczy pozwolą Ci być pewnym, że Twoja logika biznesowa działa zgodnie z założeniami.

  • Ergonomia
    Jak osiągnąć wszystkie wyżej wymienione cele przy użyciu przejrzystego API, z minimalną ilością założeń.

Dowiedz się więcej

Composable Architecture została zaprojektowana na przestrzeni wielu odcinków na Point-Free, które eksplorują programowanie funkcyjne oraz język Swift, prowadzonych przez Brandona Williamsa oraz Stephena Celisa.

Możesz obejrzeć wszystkie odcinki tutaj, lub zacząć od wprowadzenia do architektury

video poster image

Przykłady

Screen shots of example applications

To repozytorium zawiera mnóstwo przykładów, których zadaniem jest pomóc w rozwiązywaniu prostych oraz skomplikowanych problemów przy pomocy Composable Architecture. Sprawdź ten folder, w którym znajdziesz m.in.:

Brakuje Ci czegoś bardziej konkretnego? Sprawdź kod źródłowy do isowords, gry słownej napisanej dla systemu iOS przy użyciu SwiftUI oraz Composable Architecture.

Podstawowe użycie

By zbudować funkcjonalność w Composable Architecture będziesz definiował pewne typy i wartości opisujące Twoją domenę:

  • Stan (State): Typ opisujący dane, których potrzebuje Twoja funkcjonalność do wykonania logiki biznesowej i wygenerowania interfejsu użytkownika.
  • Akcja (Action): Typ reprezentujący wszystkie akcje, możliwe do wykonania w Twojej funkcjonalności, takie jak akcje interfejsu użytkownika, notyfikacje lub akcje pochodzące z jakichkolwiek innych źródeł.
  • Reduktor (Reducer): Funkcja opisująca mutację aktualnego stanu w nowy stan, wynikającą z wykonanej akcji. Reduktor jest również odpowiedzialny za zwracanie potencjalnych efektów, które powinny nastąpić w skutek akcji (np. zapytanie API). Dzieje się to poprzez zwrócenie wartości Effect.
  • Magazyn (Store): Miejsce, które steruje Twoją funkcjonalnością. Wysyłasz do magazynu wszystkie akcje użytkownika, dzięki czemu może on uruchomić reduktor i efekty, a Ty możesz obserwować wynikające z tego zmiany, aby móc aktualizować interfejs użytkownika.

Korzyści wynikające z takiego podejścia są następujące: natychmiast zyskujesz możliwość otestowania swojej funkcjonalności, a dodatkowo będziesz mógł podzielić duże i złożone funkcjonalności na mniejsze domeny, które można ze sobą połączyć.

Jako przykład, rozważmy UI, który wyświetla liczbę wraz z przyciskami "+" i "−", które zwiększają i zmniejszają tę liczbę. Aby sprawić, żeby było ciekawiej, załóżmy, że jest również przycisk, który po naciśnięciu wykonuje żądanie API, aby pobrać losowy fakt dotyczący tej liczby, a następnie wyświetla go w formie alertu.

Aby zaimplementować tę funkcjonalność, tworzymy nowy typ, który będzie zawierał domenę i zachowanie funkcjonalności poprzez zastosowanie protokołu ReducerProtocol:

import ComposableArchitecture

struct Feature: ReducerProtocol {
}

W środku musimy zdefiniować typ stanu funkcjonalności, który składa się z liczby całkowitej dla bieżącego licznika oraz opcjonalnego ciągu znaków, który reprezentuje tytuł alertu, który chcemy pokazać (opcjonalnego, ponieważ nil oznacza, że alert nie jest wyświetlany):

struct Feature: ReducerProtocol {
  struct State: Equatable {
    var count = 0
    var numberFactAlert: String?
  }
}

Musimy również zdefiniować typ akcji dla tej funkcjonalności. Oczywiste są akcje, takie jak kliknięcie przycisku dekrementującego, inkrementującego lub przycisku faktów. Ale są też nieco mniej oczywiste, takie jak akcja użytkownika zamykająca alert oraz akcja, która występuje przy otrzymaniu odpowiedzi z API faktów.

struct Feature: ReducerProtocol {
  struct State: Equatable {  }
  enum Action: Equatable {
    case factAlertDismissed
    case decrementButtonTapped
    case incrementButtonTapped
    case numberFactButtonTapped
    case numberFactResponse(TaskResult<String>)
  }
}

Następnie implementujemy metodę reduce, która odpowiada za obsługę właściwej logiki i zachowania funkcjonalności. Opisuje, jak zmienić bieżący stan na następny stan i określa, jakie efekty muszą być wykonane. Niektóre akcje nie wymagają wykonania efektów i mogą zwrócić .none, aby to wyrazić:

struct Feature: ReducerProtocol {
  struct State: Equatable {  }
  enum Action: Equatable {}
  
  func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
      case .factAlertDismissed:
        state.numberFactAlert = nil
        return .none

      case .decrementButtonTapped:
        state.count -= 1
        return .none

      case .incrementButtonTapped:
        state.count += 1
        return .none

      case .numberFactButtonTapped:
        return .task { [count = state.count] in
          await .numberFactResponse(
            TaskResult {
              String(
                decoding: try await URLSession.shared
                  .data(from: URL(string: "http://numbersapi.com/\(count)/trivia")!).0,
                as: UTF8.self
              )
            }
          )
        }

      case let .numberFactResponse(.success(fact)):
        state.numberFactAlert = fact
        return .none

      case .numberFactResponse(.failure):
        state.numberFactAlert = "Could not load a number fact :("
        return .none
    }
  }
}

Na końcu definiujemy widok, który wyświetla funkcjonalność. Widok przechowuje StoreOf<Feature>, aby obserwować wszystkie zmiany stanu i ponownie renderować widok oraz wysyłać wszystkie akcje użytkownika do magazynu (Store), aby zmieniać stan. Musimy również wprowadzić nową strukturę opakowującą alert z faktami, aby implementował Identifiable, czego wymaga modyfikator widoku .alert:

struct FeatureView: View {
  let store: StoreOf<Feature>

  var body: some View {
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      VStack {
        HStack {
          Button("") { viewStore.send(.decrementButtonTapped) }
          Text("\(viewStore.count)")
          Button("+") { viewStore.send(.incrementButtonTapped) }
        }

        Button("Number fact") { viewStore.send(.numberFactButtonTapped) }
      }
      .alert(
        item: viewStore.binding(
          get: { $0.numberFactAlert.map(FactAlert.init(title:)) },
          send: .factAlertDismissed
        ),
        content: { Alert(title: Text($0.title)) }
      )
    }
  }
}

struct FactAlert: Identifiable {
  var title: String
  var id: String { self.title }
}

Przygotowanie kontrolera w UIKit na bazie tego magazynu (Store) również jest proste. Definiujemy subskrypcję na magazynie w metodzie viewDidLoad w celu aktualizacji interfejsu użytkownika i wyświetlania alertów. Kod jest trochę dłuższy niż w przypadku SwiftUI, więc tutaj go zwinęliśmy:

Naciśnij, aby rozwinąć!
class FeatureViewController: UIViewController {
  let viewStore: ViewStoreOf<Feature>
  var cancellables: Set<AnyCancellable> = []

  init(store: StoreOf<Feature>) {
    self.viewStore = ViewStore(store)
    super.init(nibName: nil, bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()

    let countLabel = UILabel()
    let incrementButton = UIButton()
    let decrementButton = UIButton()
    let factButton = UIButton()

    // Omitted: Add subviews and set up constraints...

    self.viewStore.publisher
      .map { "\($0.count)" }
      .assign(to: \.text, on: countLabel)
      .store(in: &self.cancellables)

    self.viewStore.publisher.numberFactAlert
      .sink { [weak self] numberFactAlert in
        let alertController = UIAlertController(
          title: numberFactAlert, message: nil, preferredStyle: .alert
        )
        alertController.addAction(
          UIAlertAction(
            title: "Ok",
            style: .default,
            handler: { _ in self?.viewStore.send(.factAlertDismissed) }
          )
        )
        self?.present(alertController, animated: true, completion: nil)
      }
      .store(in: &self.cancellables)
  }

  @objc private func incrementButtonTapped() {
    self.viewStore.send(.incrementButtonTapped)
  }
  @objc private func decrementButtonTapped() {
    self.viewStore.send(.decrementButtonTapped)
  }
  @objc private func factButtonTapped() {
    self.viewStore.send(.numberFactButtonTapped)
  }
}

Kiedy jesteśmy gotowi, by wyświetlić ten widok, np. w punkcie wejściowym aplikacji, możemy stworzyć magazyn. Można to zrobić, określając początkowy stan, od którego zacznie się aplikacja, a także reduktor, który będzie ją napędzał:

import ComposableArchitecture

@main
struct MyApp: App {
  var body: some Scene {
    WindowGroup {
      FeatureView(
        store: Store(
          initialState: Feature.State(),
          reducer: Feature()
        )
      )
    }
  }
}

I w ten sposób, już mamy na ekranie coś, z czym możemy wejść w interakcję. Z pewnością jest to kilka kroków więcej niż w przypadku korzystania z czystego SwiftUI, ale ma to kilka korzyści. Daje nam to spójny sposób na stosowanie mutacji stanu, zamiast rozrzucania logiki w różnych obiektach obserwowalnych i w różnych domknięciach (closures) akcji komponentów interfejsu użytkownika. Daje nam to również zwięzły sposób wyrażania skutków ubocznych. Możemy też od razu testować tę logikę, w tym również skutki uboczne, bez konieczności wykonywania dodatkowej pracy.

Testowanie

Aby uzyskać więcej szczegółowych informacji na temat testowania, zobacz dedykowany artykuł dotyczący testowania.

W testach korzystamy z TestStore, który może być utworzony z takimi samymi danymi jak Store, ale dodatkowo pozwala na sprawdzanie, jak funkcjonalność zachowuje się podczas przesyłania akcji:

@MainActor
func testFeature() async {
  let store = TestStore(
    initialState: Feature.State(),
    reducer: Feature()
  )
}

Po utworzeniu TestStore możemy użyć go do przeprowadzenia asercji dla całej ścieżki użytkownika krok po kroku. W każdym kroku musimy udowodnić, że stan zmienił się zgodnie z naszymi oczekiwaniami. Na przykład, możemy symulować ścieżkę użytkownika klikając na przyciski zwiększenia i zmniejszenia wartości:

// Testuje zmianę wartości licznika poprzez interakcję z przyciskami "+" oraz "-"
await store.send(.incrementButtonTapped) {
  $0.count = 1
}
await store.send(.decrementButtonTapped) {
  $0.count = 0
}

Dodatkowo, jeśli krok powoduje wykonanie efektu, który przekazuje dane z powrotem do magazynu, musimy to sprawdzić. Na przykład, jeśli symulujemy kliknięcie przez użytkownika przycisku faktów, oczekujemy otrzymania odpowiedzi z faktem, co powoduje wyświetlenie alertu:

await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse(.success(???))) {
  $0.numberFactAlert = ???
}

Jednak jak możemy być pewni, jaki fakt o liczbie zostanie nam zwrócony?

Obecnie nasz reducer używa efektu, który sięga poza naszą aplikację i wysyła zapytanie do serwera API, co oznacza, że nie mamy żadnej kontroli nad jego zachowaniem. Jesteśmy uzależnieni od naszego połączenia internetowego i dostępności serwera API, aby napisać ten test.

Lepiej byłoby, gdyby ta zależność została przekazana do reduktora, abyśmy mogli używać żywej zależności podczas uruchamiania aplikacji na urządzeniu, ale używali zależności testowej podczas testów. Możemy to zrobić, dodając stałą do reduktora Feature:

struct Feature: ReducerProtocol {
  let numberFact: (Int) async throws -> String}

Następnie możemy użyć go w implementacji reduce:

case .numberFactButtonTapped:
  return .task { [count = state.count] in 
    await .numberFactResponse(TaskResult { try await self.numberFact(count) })
  }

A w punkcie wejściowym aplikacji możemy dostarczyć wersję zależności, która rzeczywiście łączy się z serwerem API w rzeczywistym świecie:

@main
struct MyApp: App {
  var body: some Scene {
    FeatureView(
      store: Store(
        initialState: Feature.State(),
        reducer: Feature(
          numberFact: { number in
            let (data, _) = try await URLSession.shared
              .data(from: .init(string: "http://numbersapi.com/\(number)")!)
            return String(decoding: data, as: UTF8.self)
          }
        )
      )
    )
  }
}

Ale w testach możemy użyć testowej zależności, która natychmiast zwraca określony, przewidywalny fakt:

@MainActor
func testFeature() async {
  let store = TestStore(
    initialState: Feature.State(),
    reducer: Feature(
      numberFact: { "\($0) is a good number Brent" }
    )
  )
}

Dzięki tym kilku początkowym czynnościom możemy ukończyć test przez symulację naciśnięcia przez użytkownika na przycisk faktów, otrzymanie odpowiedzi od zależności, która wywołuje alert, a następnie zamknięcie alertu.

await store.send(.numberFactButtonTapped)

await store.receive(.numberFactResponse(.success("0 is a good number Brent"))) {
  $0.numberFactAlert = "0 is a good number Brent"
}

await store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

Możemy również poprawić ergonomię korzystania z zależności numberFact w naszej aplikacji. Z czasem w aplikacji pojawi się wiele funkcjonalności, a niektóre z nich mogą również chcieć uzyskać dostęp do numberFact, a jawne przekazywanie go przez wszystkie warstwy może być irytujące. Istnieje proces, który można zastosować do "rejestracji" zależności w bibliotece, dzięki czemu będą one natychmiastowo dostępne dla dowolnej warstwy w aplikacji.

Aby uzyskać bardziej szczegółowe informacje na temat zarządzania zależnościami, zobacz dedykowany artykuł zależności.

Możemy zacząć od opakowania funkcjonalności zwracającej fakt o numerze w nowy typ:

struct NumberFactClient {
  var fetch: (Int) async throws -> String
}

Następnie można zarejestrować ten typ w systemie zarządzania zależnościami, spełniając warunek protokołu DependencyKey, który wymaga określenia wartości do użycia podczas uruchamiania aplikacji w symulatorach lub na urządzeniach:

extension NumberFactClient: DependencyKey {
  static let liveValue = Self(
    fetch: { number in
      let (data, _) = try await URLSession.shared
        .data(from: .init(string: "http://numbersapi.com/\(number)")!)
      return String(decoding: data, as: UTF8.self)
    }
  )
}

extension DependencyValues {
  var numberFact: NumberFactClient {
    get { self[NumberFactClient.self] }
    set { self[NumberFactClient.self] = newValue }
  }
}

Z tym niewielkim wstępnym przygotowaniem można natychmiast zacząć korzystać z zależności w każdej funkcjonalności, używając property wrappera @Dependency.

 struct Feature: ReducerProtocol {
-  let numberFact: (Int) async throws -> String
+  @Dependency(\.numberFact) var numberFact-  try await self.numberFact(count)
+  try await self.numberFact.fetch(count)
 }

Ten kod działa dokładnie tak samo jak wcześniej, ale już nie trzeba jawnie przekazywać zależności przy tworzeniu reduktora danej funkcjonalności. Gdy aplikacja jest uruchamiana w podglądach, symulatorze lub na urządzeniu, do reduktora zostanie przekazana rzeczywista zależność, a w testach zostanie przekazana testowa zależność.

To oznacza, że punkt wejściowy aplikacji już nie musi tworzyć zależności:

@main
struct MyApp: App {
  var body: some Scene {
    FeatureView(
      store: Store(
        initialState: Feature.State(),
        reducer: Feature()
      )
    )
  }
}

Tak samo TestStore może być zbudowany bez określania żadnych zależności, ale nadal możesz zastąpić dowolną zależność, której potrzebujesz do danego testu:

let store = TestStore(
  initialState: Feature.State(),
  reducer: Feature()
) {
  $0.numberFact.fetch = { "\($0) is a good number Brent" }
}

To są podstawy tworzenia i testowania funkcjonalności w Composable Architecture. Jest jeszcze bardzo dużo rzeczy do odkrycia, takich jak kompozycja, modularność, adaptacyjność i skomplikowane efekty. W katalogu Examples znajduje się wiele projektów do zbadania, gdzie można zobaczyć bardziej zaawansowane zagadnienia.

Dokumentacja

Dokumentacja dla poszczególnych wersji i gałęzi main jest dostępna tutaj:

Inne wersje

Jest kilka artykułów w dokumentacji, które mogą Ci się przydać, gdy zaczniesz czuć się bardziej komfortowo z biblioteką:

Społeczność

Jeśli chcesz podyskutować na temat Composable Architecture lub masz pytanie dotyczące sposobu jej użycia w rozwiązaniu konkretnego problemu, istnieje kilka miejsc, gdzie możesz porozmawiać z innymi entuzjastami Point-Free:

Instalacja

Można dodać ComposableArchitecture do projektu Xcode, dodając go jako zależność SPM.

  1. Z menu File wybierz Add Packages...
  2. Wpisz "https://github.com/pointfreeco/swift-composable-architecture" w pole tekstowe URL repozytorium paczek
  3. W zależności od struktury projektu:
    • Jeśli masz jeden docelowy target aplikacji, który potrzebuje dostępu do biblioteki, dodaj ComposableArchitecture bezpośrednio do aplikacji.
    • Jeśli chcesz używać tej biblioteki z wielu targetów Xcode lub mieszać targety Xcode i targety SPM, musisz utworzyć wspólny framework, którego zależnością jest ComposableArchitecture, a następnie on sam jest zależnością we wszystkich potrzebnych targetach. Dla przykładu tego rozwiązania zobacz demo aplikacji Tic-Tac-Toe, która dzieli wiele funkcjonalności na moduły i używa biblioteki statycznej w ten sposób za pomocą paczki tic-tac-toe.

Tłumaczenia

Następujące tłumaczenia tego pliku README zostały udostępnione przez członków społeczności:

Jeśli chcesz przyczynić się do tłumaczenia, prosimy o otwarcie PR z łączem do Gist!

FAQ

  • Jak Composable Architecture porównuje się do Elm, Redux i innych?
Rozwiń, aby zobaczyć odpowiedź Composable Architecture (TCA) jest zbudowane na fundamencie idei popularyzowanych przez Elm Architecture (TEA) i Redux, ale zostało zaprojektowane w taki sposób, aby pasowało do języka Swift i platform Apple.

W niektórych aspektach TCA jest bardziej "opiniotwórcze" niż inne biblioteki. Na przykład Redux nie narzuca sposobu wykonywania efektów ubocznych (side effects), ale TCA wymaga, aby wszystkie efekty uboczne były modelowane w typie Effect i zwracane z reduktora.

W innych aspektach TCA jest bardziej elastyczne niż inne biblioteki. Na przykład Elm kontroluje rodzaje efektów, które można tworzyć za pomocą typu Cmd, ale TCA pozwala na wyjście poza domyślne efekty dzięki temu, że Effect jest zgodny z protokołem Combine Publisher.

Istnieją również pewne rzeczy, które TCA priorytetyzuje, a które nie są priorytetem w Reduxie, Elmie ani w większości innych bibliotek. Na przykład kompozycja jest bardzo ważnym aspektem TCA, który polega na dzieleniu dużych funkcjonalności na mniejsze, które można łączyć. Jest to osiągane dzięki tworzeniu reduktorów oraz operatorów, takich jak Scope, co ułatwia zarówno obsługę bardziej złożonych funkcjonalności, jak i modularność, co przekłada się na lepiej izolowany kod i poprawę czasów kompilacji.

Podziękowania i uznania

Następujące osoby udzieliły opinii na temat biblioteki we wczesnych etapach jej rozwoju i pomogły w stworzeniu biblioteki, takiej jaką jest dzisiaj:

Paul Colton, Kaan Dedeoglu, Matt Diephouse, Josef Doležal, Eimantas, Matthew Johnson, George Kaimakas, Nikita Leonov, Christopher Liscio, Jeffrey Macko, Alejandro Martinez, Shai Mishali, Willis Plummer, Simon-Pierre Roy, Justin Price, Sven A. Schmidt, Kyle Sherman, Petr Šíma, Jasdev Singh, Maxim Smirnov, Ryan Stone, Daniel Hollis Tavares oraz wszyscy subskrybenci Point-Free 😁.

Szczególne podziękowania dla Chrisa Liscio, który pomógł nam przepracować wiele dziwnych cech Swift UI i dopracować finalne API.

Dziękujemy również Shai Mishali oraz projektowi CombineCommunity, z którego wzięliśmy ich implementację Publishers.Create, którą wykorzystujemy w Effect, aby pomóc w łączeniu delegatów i interfejsów API opartych na wywołaniach zwrotnych (callback-based APIs), co znacznie ułatwia interfejsowanie z narzędziami 3rd party.

Inne biblioteki

Composable Architecture została zbudowana na fundamencie idei zapoczątkowanych przez inne biblioteki, w szczególności Elm i Redux.

W społeczności Swift i iOS istnieje również wiele bibliotek architekturalnych. Każda z nich ma swoje własne priorytety i kompromisy, które różnią się od Composable Architecture.

Licencja

Ta biblioteka została wydana na licencji MIT. Szczegóły znajdują się w pliku LICENSE.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment