Skip to content

Instantly share code, notes, and snippets.

@kalupas226
Last active June 19, 2024 05:11
Show Gist options
  • Save kalupas226/bdf577e4a7066377ea0a8aaeebcad428 to your computer and use it in GitHub Desktop.
Save kalupas226/bdf577e4a7066377ea0a8aaeebcad428 to your computer and use it in GitHub Desktop.
A Japanese translation of The Composable Architecture's README.md

The Composable Architecture

CI

The Composable Architecture (省略すると TCA) は、コンポジション、テスト、開発者にとっての使いやすさを考慮し、一貫性のある理解しやすい方法でアプリケーションを構築するためのライブラリです。SwiftUI、UIKit などで使用することができ、Apple のどのプラットフォーム (iOS, macOS, tvOS, watchOS) でも使用できます。

The Composable Architecture とは何か?

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

  • 状態管理
    シンプルな値型を使用してアプリケーションの状態を管理し、多くの画面で状態を共有して、ある画面での状態の変更を別の画面ですぐに observe できるようにする方法。

  • コンポジション
    大きな機能を小さな Component に分解し、それぞれを独立したモジュールに抽出し、簡単にそれらを繋いで機能を形成する方法。

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

  • テスト
    アーキテクチャで構築された機能をテストするだけでなく、多くの Component で構成された機能の Integration test を書いたり、副作用がアプリケーションに与える影響を理解するために E2E テストを書いたりする方法。これにより、ビジネスロジックが期待通りに動作していることを強く保証することができる。

  • 開発者にとっての使いやすさ
    上記の全てを、できるだけ少ないコンセプトと動作するパーツからなるシンプルな API で実現する方法。

もっと学ぶために

The Composable Architecture は Brandon WilliamsStephen Celis がホストする、関数型プログラミングと Swift 言語を探求するビデオシリーズである Point-Free の多くのエピソードを経て設計されています。

全エピソードはこちらでご覧いただけます。また、ゼロから Architecture を学ぶいくつかのパートからなる TCA 専用のツアーもあります。 part 1, part 2, part 3, part 4.

video poster image

利用例

Screen shots of example applications

このリポジトリには、The Composable Architecture で一般的な問題や複雑な問題を解決する方法を示す数多くのサンプルが含まれています。この Examples ディレクトリを checkout して、それらを全て見てください。そのディレクトリには以下が含まれています。

もっと充実したものをお探しですか? SwiftUI と The Composable Architecture で作られた iOS の単語検索ゲームである isowords のソースコードをチェックしてみてください。

基本的な利用方法

The Composable Archtiecture を使用して機能を構築するには、ドメインをモデル化するいくつかの型と値を定義します。

  • State: あなたのアプリの機能がロジックを実行し、UI をレンダリングするために必要なデータを記述する型です。
  • Action: ユーザーのアクション、通知、イベントソースなど、あなたのアプリの機能で起こりうる全てのアクションを表す型です。
  • Environment: API クライアントや Analytics クライアントなど、あなたのアプリの機能が必要とするあらゆる依存関係を保持する型です。
  • Reducer: Action が与えられた時に、アプリの現在の State を次の State に進化させる方法を記述する関数です。Reducer は API リクエストのような実行すべき Effects を return する役割も担っており、Effect を return することでそれを実行できます。
  • Store: 実際にあなたのアプリの機能を動かす runtime です。全てのユーザーアクションを Store に送信し、Store は Reducer と Effect を実行できるようにし、Store の状態変化を observe して UI を更新できるようにします。

この方法の利点は、あなたのアプリの機能のテスタビリティを即座に解除し、大規模で複雑な機能を繋げることができる小さなドメインに分割できるようになることです。

基本的な例として、数字と数字を増減させる「+」「-」ボタンが表示される UI を考えてみましょう。さらに面白いことに、タップすると API リクエストを行い、その数字に関するランダムな事実を取得し、その事実を Alert で表示するボタンがあるとします。

この機能の State は、現在のカウントを表す integer と表示したい Alert のタイトルを表す optional な string で構成されます。 (nil は Alert を表示しないということを表すため、Alert のタイトルは optional として表現されます)

struct AppState: Equatable {
  var count = 0
  var numberFactAlert: String?
}

次に、その機能における Action です。数字を減少させるボタン、増加させるボタン、または数字についての事実を表示するためのボタンをタップするような、明白な Action があります。しかし、ユーザーが Alert を解除する Action や、数字についての事実を取得するための API リクエストからレスポンスを受信した時に発生する Action など、少しわかりにくい Action もあります。

enum AppAction: Equatable {
  case factAlertDismissed
  case decrementButtonTapped
  case incrementButtonTapped
  case numberFactButtonTapped
  case numberFactResponse(Result<String, ApiError>)
}

struct ApiError: Error, Equatable {}

次に、この機能が動作するために必要な依存関係の Environment をモデル化します。特に、数に関する事実を取得するためには、ネットワークリクエストをカプセル化した Effect の値を作成する必要があります。この依存関係は、Int から Effect<String, ApiError> への関数であり、String はリクエストからのレスポンスを表します。さらに Effect は通常バックグランドスレッドで処理を行うため (URLSession の場合と同様) 、メインキューで Effect の値を受け取る方法が必要です。これはメインキューの Scheduler によって行われます。これは、テストを書きやすくするために制御しておくことが重要な依存関係です。本番環境では live の DispatchQueue を、テスト環境ではテスト用の Scheduler を使用できるよう、AnyScheduler を使用しなければなりません。

struct AppEnvironment {
  var mainQueue: AnySchedulerOf<DispatchQueue>
  var numberFact: (Int) -> Effect<String, ApiError>
}

次に、このアプリのドメインロジックを実装した Reducer を実装します。現在の State を次の State に変更する方法を記述し、どのような Effect を実行する必要があるかを記述しています。Action によっては、Effect を実行する必要がないものもあるので、それを表すために .none を return することができます。

let appReducer = Reducer<AppState, AppAction, AppEnvironment> { state, action, environment in
  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 environment.numberFact(state.count)
      .receive(on: environment.mainQueue)
      .catchToEffect(AppAction.numberFactResponse)

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

  case .numberFactResponse(.failure):
    state.numberFactAlert = "Could not load a number fact :("
    return .none
  }
}

そして最後に、アプリの機能を表示する View を定義します。View では Store<AppState, AppAction> を保持し、State への全ての変更を observe して再レンダリングするできるようにします。また、State が変化するように、全てのユーザーの Action を Store に送ることができます。また .alert view modifier が必要とする Identifiable を実現するために、数に関する事実を表示する Alert の周りに struct のラッパーを導入する必要があります。

struct AppView: View {
  let store: Store<AppState, AppAction>

  var body: some View {
    WithViewStore(self.store) { 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 }
}

重要なのは It's important to note that we were able to implement this entire feature without having a real, live effect at hand. This is important because it means features can be built in isolation without building their dependencies, which can help compile times.

It is also straightforward to have a UIKit controller driven off of this store. You subscribe to the store in viewDidLoad in order to update the UI and show alerts. The code is a bit longer than the SwiftUI version, so we have collapsed it here:

Click to expand!
class AppViewController: UIViewController {
  let viewStore: ViewStore<AppState, AppAction>
  var cancellables: Set<AnyCancellable> = []

  init(store: Store<AppState, AppAction>) {
    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)
  }
}

Once we are ready to display this view, for example in the scene delegate, we can construct a store. This is the moment where we need to supply the dependencies, and for now we can just use an effect that immediately returns a mocked string:

let appView = AppView(
  store: Store(
    initialState: AppState(),
    reducer: appReducer,
    environment: AppEnvironment(
      mainQueue: .main,
      numberFact: { number in Effect(value: "\(number) is a good number Brent") }
    )
  )
)

And that is enough to get something on the screen to play around with. It's definitely a few more steps than if you were to do this in a vanilla SwiftUI way, but there are a few benefits. It gives us a consistent manner to apply state mutations, instead of scattering logic in some observable objects and in various action closures of UI components. It also gives us a concise way of expressing side effects. And we can immediately test this logic, including the effects, without doing much additional work.

Testing

To test, you first create a TestStore with the same information that you would to create a regular Store, except this time we can supply test-friendly dependencies. In particular, we use a test scheduler instead of the live DispatchQueue.main scheduler because that allows us to control when work is executed, and we don't have to artificially wait for queues to catch up.

let mainQueue = DispatchQueue.test

let store = TestStore(
  initialState: AppState(),
  reducer: appReducer,
  environment: AppEnvironment(
    mainQueue: mainQueue.eraseToAnyScheduler(),
    numberFact: { number in Effect(value: "\(number) is a good number Brent") }
  )
)

Once the test store is created we can use it to make an assertion of an entire user flow of steps. Each step of the way we need to prove that state changed how we expect. Further, if a step causes an effect to be executed, which feeds data back into the store, we must assert that those actions were received properly.

The test below has the user increment and decrement the count, then they ask for a number fact, and the response of that effect triggers an alert to be shown, and then dismissing the alert causes the alert to go away.

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

// Test that tapping the fact button causes us to receive a response from the effect. Note
// that we have to advance the scheduler because we used `.receive(on:)` in the reducer.
store.send(.numberFactButtonTapped)

mainQueue.advance()
store.receive(.numberFactResponse(.success("0 is a good number Brent"))) {
  $0.numberFactAlert = "0 is a good number Brent"
}

// And finally dismiss the alert
store.send(.factAlertDismissed) {
  $0.numberFactAlert = nil
}

That is the basics of building and testing a feature in the Composable Architecture. There are a lot more things to be explored, such as composition, modularity, adaptability, and complex effects. The Examples directory has a bunch of projects to explore to see more advanced usages.

Debugging

The Composable Architecture comes with a number of tools to aid in debugging.

  • reducer.debug() enhances a reducer with debug-printing that describes every action the reducer receives and every mutation it makes to state.

    received action:
      AppAction.todoCheckboxTapped(id: UUID(5834811A-83B4-4E5E-BCD3-8A38F6BDCA90))
      AppState(
        todos: [
          Todo(
    -       isComplete: false,
    +       isComplete: true,
            description: "Milk",
            id: 5834811A-83B4-4E5E-BCD3-8A38F6BDCA90
          ),
          … (2 unchanged)
        ]
      )
  • reducer.signpost() instruments a reducer with signposts so that you can gain insight into how long actions take to execute, and when effects are running.

Supplementary libraries

One of the most important principles of the Composable Architecture is that side effects are never performed directly, but instead are wrapped in the Effect type, returned from reducers, and then the Store later performs the effect. This is crucial for simplifying how data flows through an application, and for gaining testability on the full end-to-end cycle of user action to effect execution.

However, this also means that many libraries and SDKs you interact with on a daily basis need to be retrofitted to be a little more friendly to the Composable Architecture style. That's why we'd like to ease the pain of using some of Apple's most popular frameworks by providing wrapper libraries that expose their functionality in a way that plays nicely with our library. So far we support:

  • ComposableCoreLocation: A wrapper around CLLocationManager that makes it easy to use from a reducer, and easy to write tests for how your logic interacts with CLLocationManager's functionality.
  • ComposableCoreMotion: A wrapper around CMMotionManager that makes it easy to use from a reducer, and easy to write tests for how your logic interacts with CMMotionManager's functionality.
  • More to come soon. Keep an eye out 😉

If you are interested in contributing a wrapper library for a framework that we have not yet covered, feel free to open an issue expressing your interest so that we can discuss a path forward.

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 Effect type and returned from the reducer.

    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 Cmd type, but TCA allows an escape hatch to any kind of effect since Effect conforms to the Combine Publisher protocol.

    And then there are certain things that TCA prioritizes highly that are not points of focus for Redux, Elm, or most other libraries. For example, composition is very important aspect of TCA, which is the process of breaking down large features into smaller units that can be glued together. This is accomplished with the pullback and combine operators on reducers, and it aids in handling complex features as well as modularization for a better-isolated code base and improved compile times.

  • Why isn't Store thread-safe?
    Why isn't send queued?
    Why isn't send run on the main thread?

    Expand to see answer

    All interactions with an instance of Store (including all of its scopes and derived ViewStores) must be done on the same thread. If the store is powering a SwiftUI or UIKit view then, all interactions must be done on the main thread.

    When an action is sent to the Store, a reducer is run on the current state, and this process cannot be done from multiple threads. A possible work around is to use a queue in sends implementation, but this introduces a few new complications:

    1. If done simply with DispatchQueue.main.async you will incur a thread hop even when you are already on the main thread. This can lead to unexpected behavior in UIKit and SwiftUI, where sometimes you are required to do work synchronously, such as in animation blocks.

    2. It is possible to create a scheduler that performs its work immediately when on the main thread and otherwise uses DispatchQueue.main.async (e.g. see CombineScheduler's UIScheduler). This introduces a lot more complexity, and should probably not be adopted without having a very good reason.

    This is why we require all actions be sent from the same thread. This requirement is in the same spirit of how URLSession and other Apple APIs are designed. Those APIs tend to deliver their outputs on whatever thread is most convenient for them, and then it is your responsibility to dispatch back to the main queue if that's what you need. The Composable Architecture makes you responsible for making sure to send actions on the main thread. If you are using an effect that may deliver its output on a non-main thread, you must explicitly perform .receive(on:) in order to force it back on the main thread.

    This approach makes the fewest number of assumptions about how effects are created and transformed, and prevents unnecessary thread hops and re-dispatching. It also provides some testing benefits. If your effects are not responsible for their own scheduling, then in tests all of the effects would run synchronously and immediately. You would not be able to test how multiple in-flight effects interleave with each other and affect the state of your application. However, by leaving scheduling out of the Store we get to test these aspects of our effects if we so desire, or we can ignore if we prefer. We have that flexibility.

    However, if you are still not a fan of our choice, then never fear! The Composable Architecture is flexible enough to allow you to introduce this functionality yourself if you so desire. It is possible to create a higher-order reducer that can force all effects to deliver their output on the main thread, regardless of where the effect does its work:

    extension Reducer {
      func receive<S: Scheduler>(on scheduler: S) -> Self {
        Self { state, action, environment in
          self(&state, action, environment)
            .receive(on: scheduler)
            .eraseToEffect()
        }
      }
    }

    You would probably still want something like a UIScheduler so that you don't needlessly perform thread hops.

Requirements

The Composable Architecture depends on the Combine framework, so it requires minimum deployment targets of iOS 13, macOS 10.15, Mac Catalyst 13, tvOS 13, and watchOS 6. If your application must support older OSes, there are forks for ReactiveSwift and RxSwift that you can adopt!

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 mixing 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.

Documentation

The documentation for releases and main are available here:

Other versions

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!

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.

And thanks to Shai Mishali and the CombineCommunity project, from which we took their implementation of Publishers.Create, which we use in Effect to help bridge delegate and callback-based APIs, making it much easier to interface with 3rd party frameworks.

Other libraries

The Composable Architecture was built on a foundation of ideas started by other libraries, in particular Elm and Redux.

There are also many architecture libraries in the Swift and iOS community. Each one of these has their own set of priorities and trade-offs that differ from the Composable Architecture.

License

This library is released under the MIT license. See LICENSE for details.

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