The Composable Architecture (скорочено TCA) - це бібліотека для побудови додатків у послідовному та зрозумілому підході з урахуванням композиції, тестування та ергономіки. Вона може бути використана у SwiftUI, UIKit та інших фреймворках на будь-якій платформі Apple (iOS, macOS, tvOS та watchOS).
- Що таке Composable Architecture?
- Дізнатися більше
- Приклади
- Базове використання
- Документація
- Спільнота
- Встановлення
- Переклади
Ця бібліотека надає декілька основних інструментів, що можуть використовуватися для побудови додатків різної цілі та складності. Вона надає підходи, які можна використовувати для вирішення багатьох проблем, з якими ви стикаєтеся щодня при розробці додатків, таких як:
-
Управління станом
Як керувати станом вашого додатку, використовуючи прості типи значень та передавати стан між багатьма екранами так, щоб зміни на одному екрані миттєво спостерігалися на іншому екрані. -
Композиція
Як розбивати великі фічі на менші компоненти, які можуть бути витягнуті у власні ізольовані модулі і легко збиратися назад для формування фічі. -
Побічні ефекти
Як дозволити окремим частинам додатку спілкуватися з зовнішнім світом у найтестованіший та зрозумілий спосіб. -
Тестування
Як не лише тестувати фічу, побудовану цією архітектурою, але й писати інтеграційні тести для фіч, які складаються з багатьох частин, а також писати end-to-end тести, щоб розуміти, як побічні ефекти впливають на ваш додаток. Це дозволяє вам впевненіше гарантувати, що ваша бізнес-логіка працює так, як очікується. -
Ергономіка
Як досягнути всього вищезазначеного за допомогою простого API з якомога меншою кількістю концепцій та рухомих частин.
The Composable Architecture була розроблена протягом багатьох епізодів на Point-Free, відеосерії, присвяченій вивченю функціонального програмування та мові Swift, веденої Брендоном Вільямсом та Стефеном Селісом.
Ви можете переглянути всі епізоди тут, а також присвячений багатошаровий тур по архітектурі з нуля.
У цьому репозиторії є багато прикладів, які демонструють, як вирішувати загальні і складні проблеми за допомогою Composable Architecture. Перегляньте цю папку, щоб побачити їх всі, включаючи:
- Кейси
- Початок роботи
- Ефекти
- Навігація
- Редуктори вищого порядку
- Перевикористовувані компоненти
- Location manager
- Motion manager
- Search
- Speech Recognition
- SyncUps app
- Tic-Tac-Toe
- Todos
- Voice memos
Шукаєте щось більш значуще? Перегляньте вихідний код для isowords, гри пошуку слів для iOS, побудованої на SwiftUI та Composable Architecture.
[!Нотаточка] Для інтерактивного посібника крок за кроком обов'язково перегляньте Meet the Composable Architecture.
Для побудови фічі за допомогою Composable Architecture ви визначаєте деякі типи та значення, які моделюють ваш домен:
- Стан (State): Тип, який описує дані, які потребні вашій фічі для виконання логіки та відображення свого користувацького інтерфейсу.
- Дія (Action): Тип, що представляє всі можливі дії, які можуть статися у вашій фічі, такі як дії користувача, сповіщення, джерела подій тощо.
- Редуктор (Reducer): Функція, що описує, як змінити поточний стан додатка до наступного стану на основі отриманої дії. Редуктор також відповідає за повернення будь-яких ефектів, які потрібно виконати, таких як запити API, що можуть бути виконані, повертаючи значення Effect.
- Сховише (Store): Середовище виконання (runtime), що фактично керую вашою фічею. Ви надсилаєте всі дії користувача в сховище, для того, щоб сховише могло запустити редуктор та ефекти, а також ви можете спостерігати за змінами стану у сховищі, щоб можна було оновлювати користувацький інтерфейс.
Переваги цього підходу полягають у тому, що ви миттєво зможете проводити тестування вашого функціоналу, а також ви зможете розбивати великі та складні функціонали на менші домени, які можна злити разом.
Як базовий приклад, розглянемо користувацький інтерфейс, що відображає число разом з кнопками "+" та "−", що інкрементують та декрементують число. Щоб зробити річ цікавішою, припустимо, яка при натисканні виконує запит API для отримання випадкового факту про це число, і відображає його на вью.
Для реалізації цього функціоналу ми створюємо новий тип, який буде містити домен та поведінку цієї
фічі, яка буде анотована через макро @Reducer
:
import ComposableArchitecture
@Reducer
struct Feature {
}
Тут нам потрібно визначити тип для стану фічі, який складається з цілого числа для поточного лічильника і необов'язкового рядка, який представляє відображення факторіалу:
@Reducer
struct Feature {
@ObservableState
struct State: Equatable {
var count = 0
var numberFact: String?
}
}
[!Нотаточка] Ми застосували макро
@ObservableState
доState
, для того щоб використати механізм спостерігача в бібліотеці.
Також нам потрібно визначити тип для дій фічі. Є очевидні дії, такі як натискання кнопки зменшення, кнопки збільшення або кнопки факту. Але також є деякі не такі очевидні дії, такі як дія, яка відбувається при отриманні відповіді від API-запиту факту:
@Reducer
struct Feature {
@ObservableState
struct State: Equatable { … }
enum Action {
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(TaskResult<String>)
}
}
Потім ми реалізуємо змінну body
, яка відповідає за створення фактичної логіки та поведінки
фічі. В ній ми можемо використати Reduce
щоб описаи, як змінити поточний стан на наступний стан, та які ефекти потрібно
виконати. Деякі дії не потребують виконання ефектів, і вони можуть повертати значення .none
, щоб
показати це:
@Reducer
struct Feature {
@ObservableState
struct State: Equatable { … }
enum Action { /* ... */ }
var body: some Reducer<State, Action> {
Reduce { state, action in
switch action {
case .decrementButtonTapped:
state.count -= 1
return .none
case .incrementButtonTapped:
state.count += 1
return .none
case .numberFactButtonTapped:
return .run { [count = state.count] send in
await send(
.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.numberFact = "Хай йому грець! Не можу загрузити число факт :("
return .none
}
}
}
}
І наостанок ми визначаємо представлення, яке відображає фічу. Воно зберігає посилання на StoreOf<Feature>
,
щоб відстежувати усі зміни стану і здійснювати перерендеринг, і ми можемо надсилати усі дії користувача в сховище,
щоб змінювати стан:
struct FeatureView: View {
let store: StoreOf<Feature>
var body: some View {
Form {
Section {
Text("\(store.count)")
Button("Decrement") { store.send(.decrementButtonTapped) }
Button("Increment") { store.send(.incrementButtonTapped) }
}
Section {
Button("Number fact") { store.send(.numberFactButtonTapped) }
}
if let fact = store.numberFact {
Text(fact)
}
}
}
}
Також нескладно мати контролер UIKit, який працює на основі цього cховища. Ви можете спостерігати за змінами стану в сховищі у viewDidLoad
,
і тоді наповнити UI компонентів даними зі сховища. Код трохи довший, ніж версія для SwiftUI, тому ми згорнули його тут:
Тицьни щоб розкрити!
class FeatureViewController: UIViewController {
let store: StoreOf<Feature>
var cancellables: Set<AnyCancellable> = []
init(store: StoreOf<Feature>) {
self.store = 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 decrementButton = UIButton()
let incrementButton = UIButton()
let factLabel = UILabel()
// Пропущено: Додаєм сабвьюхи і встановлюємо констрейнти...
observe { [weak self] in
guard let self
else { return }
countLabel.text = "\(self.store.text)"
factLabel.text = self.store.numberFact
}
}
@objc private func incrementButtonTapped() {
self.store.send(.incrementButtonTapped)
}
@objc private func decrementButtonTapped() {
self.store.send(.decrementButtonTapped)
}
@objc private func factButtonTapped() {
self.store.send(.numberFactButtonTapped)
}
}
Коли ми готові відобразити це представлення, наприклад, в точці входу програми, ми можемо створити сховище. Це можна зробити, вказавши початковий стан, з якого почнеться програма, а також редуктор, який буде приводити програму в дію:
import ComposableArchitecture
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature()
}
)
}
}
}
І цього вистачить, щоб отримати щось на екрані, з чим можна поекспериментувати. Звичайно, це трохи більше кроків, ніж якщо ви робили б це в стандартний спосіб SwiftUI, але є кілька переваг. Він надає нам послідовний спосіб застосування мутацій стану, замість розсіювання логіки в деяких спостережуваних об'єктах та у різних замиканнях дій компонентів користувацького інтерфейсу. Він також надає нам лаконічний спосіб вираження побічних ефектів. І ми можемо негайно тестувати цю логіку, включаючи ефекти, без додаткових зусиль.
[!Нотаточка] Для докладнішої інформації щодо тестування перегляньте окрему статтю про тестування.
Щоб тестів, використовуйте TestStore
, який можна створити з тією самою інформацією, що й Store
, але
він робить додаткову роботу, щоб дозволити вам перевірити, як розвивається ваша фіча при відправці дій:
@MainActor
func testFeature() async {
let store = TestStore(initialState: Feature.State()) {
Feature()
}
}
Після створення тестового сховища ми можемо використовувати його для перевірки послідовності кроків користувача. На кожному кроці ми повинні переконатися, що стан змінився так, як очікувалося. Наприклад, ми можемо симулювати послідовність натискань на кнопки "збільшити" і "зменшити":
// Тест, що тицьнувши на "збільшити" і "зменшити" змінюється лічильник
await store.send(.incrementButtonTapped) {
$0.count = 1
}
await store.send(.decrementButtonTapped) {
$0.count = 0
}
Далі, якщо крок спричиняє виконання ефекту, який повертає дані назад до сховища, ми повинні перевірити
його. Наприклад, якщо ми симулюємо натискання кнопки факту, ми очікуємо отримати відповідь з фактом,
яке потім спричинить зміну стану numberFact
:
await store.send(.numberFactButtonTapped)
await store.receive(\.numberFactResponse) {
$0.numberFact = ???
}
Однак, як ми можемо знати, який факт буде надісланий нам?
Наразі наш редуктор використовує ефект, який звертається до реального світу для отримання доступу до сервера API, і це означає, що ми не маємо можливості контролювати його поведінку. Ми залежимо від нашого інтернет-з'єднання та доступності сервера API, щоб написати цей тест.
Краще було б передавати цю залежність до редуктора, щоб ми могли використовувати реальну залежність під
час виконання програми на пристрої, а для тестів використовувати підроблену залежність. Ми можемо зробити
це, додавши властивість до редуктора Feature
:
@Reducer
struct Feature {
let numberFact: (Int) async throws -> String
…
}
Таким чином, ми можемо використовувати у reduce
імплементацію:
case .numberFactButtonTapped:
return .run { [count = state.count] send in
await send(
.numberFactResponse(TaskResult { try await self.numberFact(count) })
)
}
І на точці входу додатку ми можемо надати версію залежності, яка фактично взаємодіє з реальним сервером API:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
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)
}
)
)
)
}
}
}
Але в тестах ми можемо використовувати імітовану залежність, яка негайно повертає визначений, передбачуваний факт:
@MainActor
func testFeature() async {
let store = TestStore(initialState: Feature.State()) {
Feature(numberFact: { "\($0) is a good number Brent" })
}
}
Завдяки цьому невеликому етапу роботи ми можемо завершити тест, симулюючи натискання користувачем на кнопку факту, і тоді отримання відповіді від залежності для для відображення факторіалу:
await store.send(.numberFactButtonTapped)
await store.receive(\.numberFactResponse) {
$0.numberFact = "0 це є курва, неймовірне число, Остапе"
}
await store.send(.factAlertDismissed) {
$0.numberFactAlert = nil
}
Ми також можемо поліпшити ергономіку використання залежності numberFact
в нашому додатку. З часом
додаток може розвиватися і містити багато фіч, і деякі з них також можуть потребувати доступу до
numberFact
, а явне передавання його через всі рівні може бути незручним. Існує процес, який можна
слідувати, щоб "зареєструвати" залежності з бібліотекою, що дозволить їм негайно стати доступними в
будь-якому рівні застосунку.
[!Нотаточка] Для більш детальної інформації, дивитися присвячену статтю про залежності.
Ми можемо розпочати, обгортаючи функціональності факту про число в новий тип:
struct NumberFactClient {
var fetch: (Int) async throws -> String
}
А потім реєструємо цей тип у системі керування залежностями, реалізовуючи протокол DependencyKey
, який
вимагає вказати живе значення для використання під час запуску додатка на симуляторах або на девайсах:
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 }
}
}
Завдяки цьому невеликому початковому етапу ви можете негайно почати використовувати залежність в
будь-якій функціональності, використовуючи обгортку властивості @Dependency
:
@Reducer
struct Feature {
- let numberFact: (Int) async throws -> String
+ @Dependency(\.numberFact) var numberFact
…
- try await self.numberFact(count)
+ try await self.numberFact.fetch(count)
}
Цей код працює точно так само, як і раніше, але вам більше не потрібно явно передавати залежність при створенні редуктора функціональності. При запуску програми у превью, на симуляторі або на пристрої, жива залежність буде надана редуктору, а під час тестування буде надано тестову залежність.
Це означає, що точка входу в додаток більше не потребує створення залежностей:
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature()
}
)
}
}
}
І тестовий об'єкт-сховище може бути створений без вказівки будь-яких залежностей, але ви все ще можете перевизначити будь-яку залежність, яка вам потрібна для тестування:
let store = TestStore(initialState: Feature.State()) {
Feature()
} withDependencies: {
$0.numberFact.fetch = { "\($0) це є курва неймовірне число, Остапе" }
}
…
Це основи створення та тестування функціональності у Composable Architecture. Є ще багато інших речей, які варто дослідити, таких як композиція, модульність, адаптивність і складні ефекти. У каталозі Examples є кілька проектів, які можна дослідити, щоб побачити більш продвинуті використання.
Документація для релізів та гілки main
доступна тут:
Інші версії
У документації є кілька статей, які можуть бути корисними, коли ви стаєте більш знайомими з бібліотекою:
Якщо ви бажаєте обговорити Composable Architecture або мати питання щодо використання його для вирішення певної проблеми, є кілька місць, де ви можете обговорити це з іншими прихильниками Point-Free:
- Для довгих обговорень ми рекомендуємо перейти до вкладки обговорення цього репозиторію.
- Для неформального спілкування ми рекомендуємо Point-Free Community slack.
Ви можете додати ComposableArchitecture як залежність пакету до вашого проекту в Xcode.
- У меню File в Xcode виберіть Add Package Dependencies...
- У полі введення URL-адреси репозиторію пакету введіть "https://github.com/pointfreeco/swift-composable-architecture"
- Залежно від структури вашого проекту:
- Якщо у вас є одит таргет додатку, який потребує доступу до бібліотеки, додайте ComposableArchitecture безпосередньо до вашого додатку.
- Якщо ви хочете використовувати бібліотеку з кількох таргетів Xcode або комбінувати Xcode та SPM таргети, вам потрібно створити спільний фреймворк, який залежить від ComposableArchitecture і потім залежити від цього фреймворку в усіх ваших таргетах. Ви можете переглянути демонстраційній додаток Tic-Tac-Toe для прикладу такого підходу, де різні фічі розбиті на модулі та використовують статичну бібліотеку за допомогою пакету Swift tic-tac-toe.
The Composable Architecture побудована з думкою про розширення, а також існує низка бібліотек, які підтримуються спільнотою та доступні для зручності у вашому додатку:
- Composable Architecture Extras: Партнерська бібліотека для Composable Architecture.
- TCAComposer: Макро бібліотека для генерування шаблонного коду у Composable Architecture.
- TCACoordinators: Шаблон координатор у Composable Architecture.
Якщо ви бажаєте додати свою бібліотеку, будь ласка відкрийте ріквест на зміни через посилання до нього!!!
Переклади цього README були зроблені учасниками спільноти:
Якшо б ви хотіли внести свій вклад у переклад, будь ласка створіть PR з посиланням на Gist!
-
Як Composable Architecture у порівнянні Elm, Redux та іншим бібліотекам?
Тицьни щоб відкрити відповіль
The Composable Architecture (TCA) базується на ідеях, популяризованих Elm Architecture (TEA) та Redux, але створений так, щоб відчуватися комфортно у мові Swift та на платформах Apple.В деяких аспектах TCA є трохи більш визначеною, ніж інші бібліотеки. Наприклад, Redux не накладає вимог щодо того, як виконувати побічні ефекти, але в TCA всі побічні ефекти повинні бути сформовані у типі
Effect
та повернуті з редуктора.В інших аспектах TCA є трохи більш гнучкою, ніж інші бібліотеки. Наприклад, Elm контролює, які типи ефектів можна створювати за допомогою типу
Cmd
, але TCA дозволяє будь-які типи ефектів, оскільки Effect обгортається навколо асинхронної операції.І, нарешті, є певні речі, на яких TCA надає велику увагу, а які не є основними для Redux, Elm та більшості інших бібліотек. Наприклад, композиція є дуже важливим аспектом TCA, яка полягає в процесі розбиття великих функціональностей на менші одиниці, які можуть бути з'єднані разом. Це досягається за допомогою побудови редукторів та операторів, таких як
Scope
, і допомагає управляти складними функціональностями, а також модуляризацією для кращої ізольованості коду та поліпшенням часу компіляції.
Наступні люди надали зворотній зв'язок про бібліотеку на ранніх етапах та допомогли зробити бібліотеку такою яка вона є сьогодні:
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, та усім підписничам сім'ї Point-Free 😁.
Особлива подяка Chris Liscio, який допоміг нам пропрацювати багато дивних SwiftUI особливостей та допоміг уточнити остаточний API.
І подяка Shai Mishali і проект
CombineCommunity, з якого ми взяли їх реалізацію
Publishers.Create
, яку ми використали в Effect
щоб полегшити поєднання делегату та API на замиканнях,
що полегшило роботу інтерфейсу зі сторонніми фрейморками.
The Composable Architecture була побудована на основі ідей, розпочатих іншими бібліотеками, зокрема Elm та Redux.
Також існує багато бібліотек архітектури в Swift та iOS-спільноті. Кожна з цих бібліотек має свій набір пріоритетів та компромісів, які відрізняються від Composable Architecture.
-
Та інші
Ця бібліотека випущена під MIT правами. Дивись Права за деталями.