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?
- Dowiedz się więcej
- Przykłady
- Podstawowe użycie
- Dokumentacja
- Społeczność
- Instalacja
- Tłumaczenia
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ń.
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
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.:
- Case Studies
- Pierwsze kroki
- Efekty
- Nawigacja
- Reduktory wyższego rzędu
- Reużywalne komponenty
- Location manager
- Motion manager
- Search
- Speech Recognition
- Tic-Tac-Toe
- Todos
- Voice memos
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.
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.
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 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ą:
- Getting started - rozpoczęcie pracy z Composable Architecture
- Dependency management - zarządzanie zależnościami
- Testing - testowanie
- Performance - wydajność
- Concurrency - współbieżność
- Bindings - powiązania
- Migrating to the reducer protocol - migracja do
ReducerProtocol
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:
- Dla dłuższych dyskusji, polecamy zakładkę discussions w tym repozytorium.
- Dla nieformalnych rozmów polecamy Point-Free Community slack.
Można dodać ComposableArchitecture do projektu Xcode, dodając go jako zależność SPM.
- Z menu File wybierz Add Packages...
- Wpisz "https://github.com/pointfreeco/swift-composable-architecture" w pole tekstowe URL repozytorium paczek
- 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.
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!
- 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.
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.
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.
-
I więcej
Ta biblioteka została wydana na licencji MIT. Szczegóły znajdują się w pliku LICENSE.