Site icon AppTractor

The Composable Architecture достигла версии 1.0: что это такое

После более чем 3 лет разработки, 145 участников и 983 закрытых пул-реквестов Composable Architecture наконец-то достигла версии 1.0.

Библиотека была достаточно стабильна с момента своего появления, но авторы не были готовы поставить на нее ярлык «1.0» до тех пор, пока не выпустили инструменты навигации, что произошло всего несколько недель назад. Если вы хотите начать работу с библиотекой уже сегодня, у вас есть два варианта:

Что такое Composable Architecture?

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

Например:

Для создания функции с помощью Composable Architecture необходимо определить некоторые типы и значения, которые моделируют ваш домен:

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

Пример Composable Architecture

В качестве базового примера рассмотрим пользовательский интерфейс, в котором отображается число, а также кнопки «+» и «-«, увеличивающие и уменьшающие это число. Чтобы сделать ситуацию более интересной, предположим, что есть также кнопка, которая при нажатии делает 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(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
        let (data, _) = try await URLSession.shared.data(
          from: URL(string: "http://numbersapi.com/\(count)/trivia")!
        )
        await send(
          .numberFactResponse(String(decoding: data, as: UTF8.self))
        )
      }

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

И, наконец, мы определяем представление, которое отображает функцию. Оно хранит StoreOf<Feature>, так что может наблюдать за всеми изменениями состояния и перерисовывать его, а мы можем посылать в хранилище все действия пользователя, чтобы состояние менялось. Мы также должны ввести структурную обертку вокруг сообщения о числе, чтобы сделать его идентифицируемым, чего требует модификатор вида .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. Вы подписываетесь на магазин в 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()

    // 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)
  }
}

После того как мы готовы отобразить это представление, например, в точке входа приложения, мы можем построить хранилище. Это можно сделать, указав начальное состояние, в котором будет запускаться приложение, а также редуктор, на котором будет работать приложение:

import ComposableArchitecture

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

И этого достаточно, чтобы получить на экране нечто, с чем уже можно поиграть. Это, безусловно, на несколько шагов больше, чем если бы вы делали это в ванильном SwiftUI, но есть несколько преимуществ. Это дает нам последовательный способ применения мутаций состояния, вместо того чтобы разбрасывать логику по некоторым наблюдаемым объектам и различным замыканиям компонентов пользовательского интерфейса. Это также дает нам лаконичный способ выражения побочных эффектов. И мы можем сразу же протестировать эту логику, включая эффекты, не делая большой дополнительной работы.

Дополнительно

Exit mobile version