swift-composable-architecture - 構成、テスト、および人間工学を念頭に置いて、一貫性のある理解しやすい方法でアプリケーションを構築するためのライブラリ。

(A library for building applications in a consistent and understandable way, with composition, testing, and ergonomics in mind.)

Created at: 2020-05-04 07:18:40
Language: Swift
License: MIT

コンポーザブルアーキテクチャ

ティッカー

コンポーザブルアーキテクチャ(略してTCA)は、構成、テスト、および人間工学を念頭に置いて、一貫性のある理解しやすい方法でアプリケーションを構築するためのライブラリです。SwiftUI、UIKitなど、および任意のAppleプラットフォーム(iOS、macOS、tvOS、watchOS)で使用できます。

コンポーザブルアーキテクチャとは何ですか?

このライブラリは、さまざまな目的と複雑さのアプリケーションを構築するために使用できるいくつかのコアツールを提供します。次のようなアプリケーションを構築するときに日常的に発生する多くの問題を解決するために従うことができる説得力のあるストーリーを提供します。

  • 状態管理
    単純な値型を使用してアプリケーションの状態を管理し、多くの画面間で状態を共有して、ある画面のミューテーションを別の画面ですぐに確認できるようにする方法。

  • コンポジション
    大きな特徴を小さなコンポーネントに分割して、独自の孤立したモジュールに抽出し、簡単に接着して特徴を形成する方法。

  • 副作用
    アプリケーションの特定の部分を、可能な限り最もテスト可能で理解しやすい方法で外部と通信させる方法。

  • テスト アーキテクチャに組み込まれた機能をテストするだけでなく、多くの部分で構成される機能の統合テスト
    を記述し、副作用がアプリケーションに与える影響を理解するためのエンドツーエンドのテストを記述する方法。これにより、ビジネス ロジックが期待どおりに実行されていることを強力に保証できます。

  • 人間工学
    概念と可動部分をできるだけ少なくした単純なAPIで上記のすべてを達成する方法。

詳細情報

コンポーザブルアーキテクチャは、ブランドンウィリアムズスティーブンセリスがホストを務める関数型プログラミングとスウィフト言語を探求するビデオシリーズであるPoint-Freeの多くのエピソードの過程で設計されました。

ここですべてのエピソードと、アーキテクチャの専用マルチパートツアーを最初から見ることができます。

ビデオポスター画像

サンプルアプリケーションのスクリーンショット

このリポジトリには、コンポーザブルアーキテクチャに関する一般的で複雑な問題を解決する方法を示す多くの例が付属しています。このディレクトリをチェックして、以下を含むすべてを確認してください。

もっと充実したものをお探しですか?SwiftUIとコンポーザブルアーキテクチャに組み込まれたiOS単語検索ゲームであるisowordsのソースコードを確認してください。

基本的な使い方

コンポーザブル アーキテクチャを使用して機能を構築するには、ドメインをモデル化するいくつかの型と値を定義します。

  • 状態: 機能がロジックを実行し、UI をレンダリングするために必要なデータを記述する型。
  • アクション: ユーザーアクション、通知、イベントソースなど、機能で発生する可能性のあるすべてのアクションを表すタイプ。
  • Reducer: アクションが与えられた次の状態にアプリの現在の状態を進化させる方法を記述する関数。また、レデューサーは、API 要求など、実行する必要があるエフェクトを返す役割も担います。
    Effect
  • ストア: 機能を実際に駆動するランタイム。ストアがレジューサーとエフェクトを実行できるようにすべてのユーザー アクションをストアに送信し、ストアの状態の変化を観察して UI を更新できるようにします。

これを行う利点は、機能のテスト容易性を即座に解除し、大規模で複雑な機能を接着できる小さなドメインに分割できることです。

基本的な例として、数値を増減する "+" ボタンと "-" ボタンと共に数値を表示する UI を考えてみましょう。物事を面白くするために、タップされたときにその番号に関するランダムな事実を取得するAPIリクエストを行い、その事実をアラートに表示するボタンもあるとします。

この機能を実装するために、以下に準拠して機能のドメインと動作を格納する新しい型を作成します。

ReducerProtocol

import ComposableArchitecture

struct Feature: ReducerProtocol {
}

ここでは、現在のカウントの整数と、表示するアラートのタイトルを表すオプションの文字列で構成される機能の状態の型を定義する必要があります (オプションの場合は、アラートを表示しないことを表します)。

nil

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

また、機能のアクションのタイプを定義する必要があります。デクリメントボタン、インクリメントボタン、ファクトボタンをタップするなど、明らかなアクションがあります。ただし、ユーザーがアラートを却下するアクションや、ファクト API 要求から応答を受信したときに発生するアクションなど、少しわかりにくいものもあります。

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

次に、機能の実際のロジックと動作を処理するメソッドを実装します。現在の状態を次の状態に変更する方法と、実行する必要がある効果について説明します。一部のアクションはエフェクトを実行する必要がなく、それを表すために戻ることができます。

reduce
.none

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

最後に、フィーチャを表示するビューを定義します。状態に対するすべての変更を監視して再レンダリングできるようにし、すべてのユーザーアクションをストアに送信して状態を変化させることができます。また、ファクトアラートの周りに構造体ラッパーを導入する必要があります alert それを作るために、view修飾子はこれを必要とします:

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 コントローラーをこのストアから追い出すのも簡単です。UIを更新してアラートを表示するために、ストアを購読します。コードはSwiftUIバージョンよりも少し長いので、ここでは折りたたんでいます。

viewDidLoad

クリックして展開!
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)
  }
}

このビューを表示する準備ができたら、たとえばアプリのエントリ ポイントにストアを構築できます。これを行うには、アプリケーションを起動する初期状態と、アプリケーションに電力を供給するレデューサーを指定します。

import ComposableArchitecture

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

そして、それは画面上で何かをいじるのに十分です。バニラSwiftUIの方法でこれを行う場合よりも間違いなく数ステップ多くなりますが、いくつかの利点があります。これにより、一部の監視可能なオブジェクトやUIコンポーネントのさまざまなアクションクロージャにロジックを分散させる代わりに、状態の変更を適用する一貫した方法が提供されます。また、副作用を表現する簡潔な方法も提供します。そして、多くの追加作業を行うことなく、効果を含むこのロジックをすぐにテストできます。

テスティング

テストの詳細については、専用のテストに関する記事を参照してください。

テストするには、と同じ情報で作成できますが、アクションが送信されるにつれて機能がどのように進化するかをアサートできるように追加の作業を行います。

TestStore
Store

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

テスト ストアが作成されたら、それを使用して、ユーザー フロー全体のステップのアサーションを作成できます。その状態が私たちの期待を変えたことを証明する必要がある方法の各ステップ。たとえば、インクリメントボタンとデクリメントボタンをタップするユーザーフローをシミュレートできます。

// Test that tapping on the increment/decrement buttons changes the count
await store.send(.incrementButtonTapped) {
  $0.count = 1
}
await store.send(.decrementButtonTapped) {
  $0.count = 0
}

さらに、ステップによってエフェクトが実行され、データがストアにフィードバックされる場合は、それをアサートする必要があります。たとえば、ユーザーがファクト ボタンをタップすることをシミュレートすると、ファクト応答がファクトとともに返され、アラートが表示されます。

await store.send(.numberFactButtonTapped)

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

しかし、どのような事実が私たちに送り返されるかをどうやって知ることができますか?

現在、私たちのレデューサーは、現実世界に手を差し伸べてAPIサーバーにヒットするエフェクトを使用しているため、その動作を制御する方法はありません。このテストを書くために、インターネット接続とAPIサーバーの可用性の気まぐれです。

この依存関係をレデューサーに渡して、デバイスでアプリケーションを実行するときにライブ依存関係を使用できるようにしますが、テストにはモック依存関係を使用することをお勧めします。これを行うには、プロパティを theducer に追加します。

Feature

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

次に、実装で使用できます。

reduce

case .numberFactButtonTapped:
  return .task { [count = state.count] in 
    await .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(),
    reducer: Feature(
      numberFact: { "\($0) is a good number Brent" }
    )
  )
}

その少しの事前作業で、ユーザーがファクト ボタンをタップし、依存関係から応答を受信してアラートをトリガーし、アラートを閉じることをシミュレートすることで、テストを終了できます。

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
}

また、アプリケーションで使用する人間工学を改善することもできます依存関係。時間が経つにつれて、アプリケーションは多くの機能に進化する可能性があり、それらの機能の一部もアクセスしたい場合があり、すべてのレイヤーに明示的に渡すのは面倒な場合があります。依存関係をライブラリに「登録」し、アプリケーション内の任意のレイヤーですぐに使用できるようにするためのプロセスがあります。

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

少しの事前作業を行うと、任意の機能の依存関係をすぐに利用できるようになります。

struct Feature: ReducerProtocol {
  struct State {  }
  enum Action {  }
  @Dependency(\.numberFact) var numberFact
  
}

このコードは以前とまったく同じように機能しますが、フィーチャのレジューサーを構築するときに依存関係を明示的に渡す必要がなくなりました。プレビュー、シミュレーター、またはデバイスでアプリを実行すると、ライブ依存関係がレジューサーに提供され、テストではテスト依存関係が提供されます。

つまり、アプリケーションへのエントリ ポイントで依存関係を構築する必要がなくなります。

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

また、依存関係を指定せずにテスト ストアを構築できますが、テストのために必要な依存関係をオーバーライドすることもできます。

let store = TestStore(
  initialState: Feature.State(),
  reducer: Feature()
)

store.dependencies.numberFact.fetch = { "\($0) is a good number Brent" }

これが、コンポーザブルアーキテクチャの機能を構築してテストするための基本です。構成、モジュール性、適応性、複雑な効果など、探求すべきことは他にもたくさんあります。Examplesディレクトリには、より高度な使用法を確認するために探索するプロジェクトがたくさんあります。

ドキュメンテーション

リリースのドキュメントは、次の場所から入手できます。

main

他のバージョン

ドキュメントには、ライブラリに慣れるにつれて役立つ記事がいくつかあります。

Installation

You can add ComposableArchitecture to an Xcode project by adding it as a package dependency.

  1. From the File menu, select Add Packages...
  2. Enter "https://github.com/pointfreeco/swift-composable-architecture" into the package repository URL text field
  3. Depending on how your project is structured:
    • If you have a single application target that needs access to the library, then add ComposableArchitecture directly to your application.
    • If you want to use this library from multiple Xcode targets, or mix Xcode targets and SPM targets, you must create a shared framework that depends on ComposableArchitecture and then depend on that framework in all of your targets. For an example of this, check out the Tic-Tac-Toe demo application, which splits lots of features into modules and consumes the static library in this fashion using the tic-tac-toe Swift package.

Help

If you want to discuss the Composable Architecture or have a question about how to use it to solve a particular problem, you can start a topic in the discussions tab of this repo, or ask around on its Swift forum.

Translations

The following translations of this README have been contributed by members of the community:

If you'd like to contribute a translation, please open a PR with a link to a Gist!

FAQ

  • How does the Composable Architecture compare to Elm, Redux, and others?

    Expand to see answer The Composable Architecture (TCA) is built on a foundation of ideas popularized by the Elm Architecture (TEA) and Redux, but made to feel at home in the Swift language and on Apple's platforms.

    In some ways TCA is a little more opinionated than the other libraries. For example, Redux is not prescriptive with how one executes side effects, but TCA requires all side effects to be modeled in the type and returned from the reducer.

    Effect

    In other ways TCA is a little more lax than the other libraries. For example, Elm controls what kinds of effects can be created via the type, but TCA allows an escape hatch to any kind of effect since conforms to the Combine protocol.

    Cmd
    Effect
    Publisher

    そして、TCAが非常に優先するものは、Redux、Elm、または他のほとんどのライブラリの焦点ではありません。たとえば、合成はTCAの非常に重要な側面であり、大きな機能を接着できる小さなユニットに分解するプロセスです。これは、次のようなレデューサービルダーや演算子で実現され、複雑な機能の処理や、より分離されたコードベースとコンパイル時間の改善のためのモジュール化に役立ちます。

    Scope

Credits and thanks

The following people gave feedback on the library at its early stages and helped make the library what it is today:

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, and all of the Point-Free subscribers 😁.

Special thanks to Chris Liscio who helped us work through many strange SwiftUI quirks and helped refine the final API.

また、Shai MishaliCombineCommunityプロジェクトのおかげで、その実装から、ブリッジデリゲートとコールバックベースのAPIに使用し、サードパーティのフレームワークとのインターフェースをはるかに簡単にしました。

Publishers.Create
Effect

その他の図書館

コンポーザブルアーキテクチャは、他のライブラリ、特にElmReduxによって開始されたアイデアの基盤の上に構築されました。

SwiftおよびiOSコミュニティには多くのアーキテクチャライブラリもあります。これらのそれぞれには、コンポーザブルアーキテクチャとは異なる独自の優先順位とトレードオフのセットがあります。

ライセンス

このライブラリはMITライセンスの下でリリースされています。詳細については、ライセンスを参照してください。