Skip to content

Instantly share code, notes, and snippets.

@barabashd
Last active September 26, 2023 16:41
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save barabashd/33b64676195ce41f4bb73c327ea512a8 to your computer and use it in GitHub Desktop.
Save barabashd/33b64676195ce41f4bb73c327ea512a8 to your computer and use it in GitHub Desktop.
TCA_README_UKR.md

The Composable Architecture

CI Slack

The Composable Architecture (скорочено TCA) - це бібліотека для побудови додатків у послідовному та зрозумілому підході з урахуванням композиції, тестування та ергономіки. Вона може бути використана у SwiftUI, UIKit та інших фреймворках на будь-якій платформі Apple (iOS, macOS, tvOS та watchOS).

Що таке Composable Architecture?

Ця бібліотека надає декілька основних інструментів, що можуть використовуватися для побудови додатків різної цілі та складності. Вона надає підходи, які можна використовувати для вирішення багатьох проблем, з якими ви стикаєтеся щодня при розробці додатків, таких як:

  • Управління станом
    Як керувати станом вашого додатку, використовуючи прості типи значень та передавати стан між багатьма екранами так, щоб зміни на одному екрані миттєво спостерігалися на іншому екрані.

  • Композиція
    Як розбивати великі фічі на менші компоненти, які можуть бути витягнуті у власні ізольовані модулі і легко збиратися назад для формування фічі.

  • Побічні ефекти
    Як дозволити окремим частинам додатку спілкуватися з зовнішнім світом у найтестованіший та зрозумілий спосіб.

  • Тестування
    Як не лише тестувати фічу, побудовану цією архітектурою, але й писати інтеграційні тести для фіч, які складаються з багатьох частин, а також писати end-to-end тести, щоб розуміти, як побічні ефекти впливають на ваш додаток. Це дозволяє вам впевненіше гарантувати, що ваша бізнес-логіка працює так, як очікується.

  • Ергономіка
    Як досягнути всього вищезазначеного за допомогою простого API з якомога меншою кількістю концепцій та рухомих частин.

Дізнатися більше

The Composable Architecture була розроблена протягом багатьох епізодів на Point-Free, відеосерії, присвяченій вивченю функціонального програмування та мові Swift, веденої Брендоном Вільямсом та Стефеном Селісом.

Ви можете переглянути всі епізоди тут, а також присвячений багатошаровий тур по архітектурі з нуля.

video poster image

Приклади

Screen shots of example applications

У цьому репозиторії є багато прикладів, які демонструють, як вирішувати загальні і складні проблеми за допомогою Composable Architecture. Перегляньте цю папку, щоб побачити їх всі, включаючи:

Шукаєте щось більш значуще? Перегляньте вихідний код для isowords, гри пошуку слів для iOS, побудованої на SwiftUI та Composable Architecture.

Базове використання

Примітка Для інтерактивного посібника крок за кроком обов'язково перегляньте Meet the Composable Architecture.

Для побудови фічі за допомогою Composable Architecture ви визначаєте деякі типи та значення, які моделюють ваш домен:

  • Стан (State): Тип, який описує дані, які потребні вашій фічі для виконання логіки та відображення свого користувацького інтерфейсу.
  • Дія (Action): Тип, що представляє всі можливі дії, які можуть статися у вашій фічі, такі як дії користувача, сповіщення, джерела подій тощо.
  • Редуктор (Reducer): Функція, що описує, як змінити поточний стан додатка до наступного стану на основі отриманої дії. Редуктор також відповідає за повернення будь-яких ефектів, які потрібно виконати, таких як запити API, що можуть бути виконані, повертаючи значення Effect.
  • Сховише (Store): Середовище виконання (runtime), що фактично керую вашою фічею. Ви надсилаєте всі дії користувача в сховище, для того, щоб сховише могло запустити редуктор та ефекти, а також ви можете спостерігати за змінами стану у сховищі, щоб можна було оновлювати користувацький інтерфейс.

Переваги цього підходу полягають у тому, що ви миттєво зможете проводити тестування вашого функціоналу, а також ви зможете розбивати великі та складні функціонали на менші домени, які можна злити разом.

Як базовий приклад, розглянемо користувацький інтерфейс, що відображає число разом з кнопками "+" та "−", що інкрементують та декрементують число. Щоб зробити річ цікавішою, припустимо, яка при натисканні виконує запит API для отримання випадкового факту про це число, а потім відображає факт у вигляді сповіщення.

Для реалізації цього функціоналу ми створюємо новий тип, який буде містити домен та поведінку цієї фічі, реалізував протокол Reducer:

import ComposableArchitecture

struct Feature: Reducer {
}

Тут нам потрібно визначити тип для стану фічі, який складається з цілого числа для поточного лічильника і необов'язкового рядка, який представляє заголовок сповіщення, яке ми хочемо показати (необов'язково, оскільки nil означає, що сповіщення не показується):

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

Також нам потрібно визначити тип для дій фічі. Є очевидні дії, такі як натискання кнопки зменшення, кнопки збільшення або кнопки факту. Але також є деякі не такі очевидні дії, такі як дія користувача, коли він закриває сповіщення, і дія, яка відбувається при отриманні відповіді від API-запиту факту:

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

Потім ми реалізуємо метод reduce, який відповідає за обробку фактичної логіки та поведінки фічі. Він описує, як змінити поточний стан на наступний стан та описує, які ефекти потрібно виконати. Деякі дії не потребують виконання ефектів, і вони можуть повертати значення .none, щоб показати це:

struct Feature: Reducer {
  struct State: Equatable {  }
  enum Action: Equatable {}
  
  func reduce(into state: inout State, action: Action) -> Effect<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 .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.numberFactAlert = "Хай йому грець! Не можу загрузити число факт :("
        return .none
    }
  }
}

І наостанок ми визначаємо представлення, яке відображає фічу. Воно зберігає посилання на StoreOf<Feature>, щоб відстежувати усі зміни стану і здійснювати перерендеринг, і ми можемо надсилати усі дії користувача в сховище, щоб змінювати стан. Також ми повинні створити обгортку структури навколо факту сповіщення, щоб зробити його Identifiable, що вимагає використання модифікатора .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 }
}

Також нескладно мати контролер UIKit, який працює на основі цього cховища. Ви підписуєтеся на сховище у viewDidLoad, щоб оновлювати інтерфейс користувача та показувати сповіщення. Код трохи довший, ніж версія для SwiftUI, тому ми згорнули його тут:

Тицьни щоб розкрити!
class FeatureViewController: UIViewController {
  let viewStore: ViewStoreOf<Feature>
  var cancellables: Set<AnyCancellable> = []

  init(store: StoreOf<Feature>) {
    self.viewStore = ViewStore(store, observe: { $0 })
    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()

    // Пропущено: Додаєм сабвьюхи і встановлюємо констрейнти...

    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: "Окі докі",
            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)
  }
}

Коли ми готові відобразити це представлення, наприклад, в точці входу програми, ми можемо створити сховище. Це можна зробити, вказавши початковий стан, з якого почнеться програма, а також редуктор, який буде приводити програму в дію:

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
}

Далі, якщо крок спричиняє виконання ефекту, який повертає дані назад до сховища, ми повинні перевірити його. Наприклад, якщо ми симулюємо натискання кнопки факту, ми очікуємо отримати відповідь з фактом, яке потім призведе до показу cповіщення:

await store.send(.numberFactButtonTapped)

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

Однак, як ми можемо знати, який факт буде надісланий нам?

Наразі наш редуктор використовує ефект, який звертається до реального світу для отримання доступу до сервера API, і це означає, що ми не маємо можливості контролювати його поведінку. Ми залежимо від нашого інтернет-з'єднання та доступності сервера API, щоб написати цей тест.

Краще було б передавати цю залежність до редуктора, щоб ми могли використовувати реальну залежність під час виконання програми на пристрої, а для тестів використовувати підроблену залежність. Ми можемо зробити це, додавши властивість до редуктора Feature:

struct Feature: Reducer {
  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 {
    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(.success("0 це є курва, неймовірне число, Остапе"))) {
  $0.numberFactAlert = "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:

 struct Feature: Reducer {
-  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 {
    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.

  1. У меню File в Xcode виберіть Add Packages...
  2. У полі введення URL-адреси репозиторію пакету введіть "https://github.com/pointfreeco/swift-composable-architecture"
  3. Залежно від структури вашого проекту:
    • Якщо у вас є одит таргет додатку, який потребує доступу до бібліотеки, додайте ComposableArchitecture безпосередньо до вашого додатку.
    • Якщо ви хочете використовувати бібліотеку з кількох таргетів Xcode або комбінувати Xcode та SPM таргети, вам потрібно створити спільний фреймворк, який залежить від ComposableArchitecture і потім залежити від цього фреймворку в усіх ваших таргетах. Ви можете переглянути демонстраційній додаток Tic-Tac-Toe для прикладу такого підходу, де різні фічі розбиті на модулі та використовують статичну бібліотеку за допомогою пакету Swift tic-tac-toe.

Переклади

Переклади цього README були зроблені учасниками спільноти:

Якшо б ви хотіли внести свій вклад у переклад, будь ласка створіть PR з посиланням на Gist!

FAQ

  • Як Composable Architecture у порівнянні Elm, Redux та іншим бібліотекам?

    Тицьни щоб відкрити відповіль The Composable Architecture (TCA) базується на ідеях, популяризованих Elm Architecture (TEA) та Redux, але створений так, щоб відчуватися комфортно у мові Swift та на платформах Apple.

    В деяких аспектах TCA є трохи більш визначеною, ніж інші бібліотеки. Наприклад, Redux не накладає вимог щодо того, як виконувати побічні ефекти, але в TCA всі побічні ефекти повинні бути сформовані у типі Effect та повернуті з редуктора.

    В інших аспектах TCA є трохи більш гнучкою, ніж інші бібліотеки. Наприклад, Elm контролює, які типи ефектів можна створювати за допомогою типу Cmd, але TCA дозволяє будь-які типи ефектів, оскільки Effect реалізовує протокол Combine Publisher.

    І, нарешті, є певні речі, на яких 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 правами. Дивись Права за деталями.

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