The Composable Architecture (省略すると TCA) は、コンポジション、テスト、開発者にとっての使いやすさを考慮し、一貫性のある理解しやすい方法でアプリケーションを構築するためのライブラリです。SwiftUI、UIKit などで使用することができ、Apple のどのプラットフォーム (iOS, macOS, tvOS, watchOS) でも使用できます。
- [The Composable Architecture とは何か?](#The-Composable-Architecture とは何か?)
- もっと学ぶために
- 利用例
- 基本的な利用方法
- 補助的なライブラリ
- FAQ
- 利用するために必要な要件
- インストール方法
- ドキュメント
- ヘルプ
- 翻訳
- [Credits と thanks](#Credits と thanks)
- 他のライブラリ
このライブラリは、さまざまな目的や複雑さのアプリケーションを構築するために使用できる、いくつかのコアツールを提供します。アプリケーションを構築する際に日々遭遇する多くの問題を解決するために、以下のような説得力のあるストーリーが提供されています。
-
状態管理
シンプルな値型を使用してアプリケーションの状態を管理し、多くの画面で状態を共有して、ある画面での状態の変更を別の画面ですぐに observe できるようにする方法。 -
コンポジション
大きな機能を小さな Component に分解し、それぞれを独立したモジュールに抽出し、簡単にそれらを繋いで機能を形成する方法。 -
副作用
可能な限りテスト可能で理解しやすい方法で、アプリケーションの特定の部分を外界と対話させる方法。 -
テスト
アーキテクチャで構築された機能をテストするだけでなく、多くの Component で構成された機能の Integration test を書いたり、副作用がアプリケーションに与える影響を理解するために E2E テストを書いたりする方法。これにより、ビジネスロジックが期待通りに動作していることを強く保証することができる。 -
開発者にとっての使いやすさ
上記の全てを、できるだけ少ないコンセプトと動作するパーツからなるシンプルな API で実現する方法。
The Composable Architecture は Brandon Williams と Stephen Celis がホストする、関数型プログラミングと Swift 言語を探求するビデオシリーズである Point-Free の多くのエピソードを経て設計されています。
全エピソードはこちらでご覧いただけます。また、ゼロから Architecture を学ぶいくつかのパートからなる TCA 専用のツアーもあります。 part 1, part 2, part 3, part 4.
このリポジトリには、The Composable Architecture で一般的な問題や複雑な問題を解決する方法を示す数多くのサンプルが含まれています。この [Exampleshttps://github.com/pointfreeco/swift-composable-architecture/tree/main/Examples) ディレクトリを checkout して、それらを全て見てください。そのディレクトリには以下が含まれています。
- Case Studies
- Getting started
- Effects
- Navigation
- Higher-order reducers
- Reusable components
- Location manager
- Motion manager
- Search
- Speech Recognition
- Tic-Tac-Toe
- Todos
- Voice memos
もっと充実したものをお探しですか? 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 }
}
重要なのは、この機能全体を実際に手元で Live Effect (実際のリクエスト) なしで実装できたということです。これは、依存関係を構築することなく機能を分離して構築できることを意味し、コンパイル時間の短縮に繋げることができるため重要です。
また、この Store から UIKit の Controller を駆動させることも簡単です。UI を更新して Alert を表示するために、viewDidLoad
で Store を subscribe すれば良いのです。コードは SwiftUI バージョンよりも少し長いため、折りたたんでいます。
クリックして展開してください!
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)
}
}
この View を表示する準備ができたら、例えば Scene delegate で Store を構築することができます。これは依存関係を提供する必要がある瞬間ですが、今はモックされた string を即座に return する Effect を使用すれば良いだけです。
let appView = AppView(
store: Store(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment(
mainQueue: .main,
numberFact: { number in Effect(value: "\(number) is a good number Brent") }
)
)
)
そしてそれは、Screen 上で実装していく方法を得るために十分です。素の SwiftUI の方法でこれを行う場合よりも、確かにいくつかのステップを必要としますが、いくつかの利点があります。いくつかの Observable な Object や UI Component のさまざまな Action closure でロジックを散乱させる代わりに、State の変更を適用するための一貫した方法を提供します。また、副作用を簡潔に表現することができます。そして、このロジックは Effect を含め、すぐにテストすることができます。
テストを行うには、まず通常の Store
を作成するのと同じ情報を使って TestStore
を作成します。ただし今回はテストに適した依存関係を提供することができます。特に live の DispatchQueue.main
Scheduler ではなく、テスト用の Scheduler を使用することで、処理を実行するタイミングを制御でき、キューが追いつくのを人為的に待つ必要がなくなるからです。
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") }
)
)
Test store を作成したら、それを使ってユーザーフロー全体のステップを assertion することができます。各ステップごとに、State が期待通りに変化したことを証明する必要があります。さらに、あるステップで Effect が実行され、それが Store にデータをフィードバックする場合、それらの Action が適切に受信されたことを保証する必要があります。
以下のテストでは、ユーザーがカウントをインクリメント・デクリメントし、その後数字についての事実を尋ね、その Effect のレスポンスが Alert を表示するトリガーとなり、その後 Alert を解除すると Alert が消えることを確かめています。
// インクリメント/デクリメントボタンをタップするとカウントが変化することを確認する
store.send(.incrementButtonTapped) {
$0.count = 1
}
store.send(.decrementButtonTapped) {
$0.count = 0
}
// fact ボタンをタップすると、Effect からレスポンスが返ってくることをテストします。注意
// reducer で `.receive(on:)` を使っているため、Scheduler を advance させなければならないことに注意してください。
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
}
これが The Composable Architecture の機能を構築し、テストするための基本です。コンポジション、モジュール化、適応性、複雑な Effect など、探究すべきことはもっとたくさんあります。Examples ディレクトリには、より高度な使い方を見るためのプロジェクトがたくさんあります。
The Composable Architecture には、デバッグを支援するためのツールが多数付属しています。
-
reducer.debug()
は、Reducer が受け取る全ての Action と State に対する全ての変更を記述する debug-printing で、Reducer を強化します。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()
は Reducer に signpost をつけて、Action の実行にかかる時間や Effect が実行されるタイミングを知ることができるようにします。
The Composable Architecture の最も重要な原則の一つは、副作用は決して直接実行されず、代わりに Effect
型にラップされて Reducer から return され、後で Store
が Effect を実行することです。これはアプリケーション内のデータの流れを単純化し、ユーザーアクションから Effect の実行までの完全な E2E テスト容易性を得るために重要なことです。
しかし、これはあなたが日常的に使っている多くのライブラリや SDK が The Composable Architecture のスタイルにもう少しフレンドリーであるように改修する必要があることも意味しています。そういうわけで、私たちは Apple の最も人気のあるフレームワークのいくつかを、私たちのライブラリとうまく連携する方法でその機能を公開するラッパーライブラリを提供することで、The Composable Architecture 使用時の苦痛を軽減したいと思っています。今のところ、私たちは以下をサポートしています。
ComposableCoreLocation
:CLLocationManager
のラッパーです。Reducer から簡単に利用することができ、あなたのアプリのロジックがCLLocationManager
の機能とどのように相互作用するかのテストを簡単に書くことができます。ComposableCoreMotion
:CMMotionManager
のラッパーです。Reducer から簡単に利用することができ、あなたのアプリのロジックがCMMotionManager
の機能とどのように相互作用するかのテストを簡単に書くことができます。- より多くのラッパーは近日公開予定です。ご期待ください 😉
もし、私たちがまだカバーしていないフレームワークのラッパーライブラリを提供することに興味があれば、遠慮なくあなたの関心を表現する issue をオープンしてください。
-
The Composable Architecture は Elm や Redux などと比較してどうなのでしょうか?
答えを見るために展開する
The Composable Architecture (TCA) は、Elm Architecture (TEA) と Redux で広まったアイデアを基礎に、Swift 言語と Apple のプラットフォームで快適に動作するように作られています。ある意味、TCA は他のライブラリよりも少し意見が強いと言えます。例えば、Redux は副作用の実行方法について規定がありませんが、TCA は全ての副作用を
Effect
型でモデル化し、Reducer から返すことを要求しています。他の点では、TCA は他のライブラリよりも少し緩いと言えます。例えば Elm は
Cmd
型を介してどのような種類の Effect を作成できるかを制御しますが、TCA ではEffect
が CombinePublisher
protocol に準拠しているため、どのような種類の Effect にもすることが可能です。そして、Redux や Elm、あるいは他のほとんどのライブラリでは重視されない、TCA が非常に優先していることがあるのです。例えば、コンポジションは TCA にとって非常に重要な側面であり、大きな機能をより小さな Unit に分解し、それを繋げるプロセスです。これは Reducer の
pullback
とcombine
operator で実現され、複雑な機能の処理と、より分離されたコードベースとコンパイル時間の改善のためのモジュール化を支援します。 -
なぜ
Store
はスレッドセーフではないのですか?
なぜsend
はキューに入らないのですか?
なぜsend
はメインスレッドで実行されないのですか?答えを見るために展開する
Store
のインスタンス (全てのスコープと派生したViewStore
を含む) との全てのインタラクションは同じスレッドで行われなければなりません。Store が SwiftUI や UIKit の View を駆動している場合、全てのインタラクションは _main_thread で実行されなければなりません。Action が
Store
に送られると、現在の State に対して Reducer が実行され、この処理は複数のスレッドから実行することはできません。回避策として、send
の実装でキューを使用することが考えられますが、これにはいくつかの新しい複雑な問題が発生します。-
もし単純に
DispatchQueue.main.async
で実行すると、すでにメインスレッドにいる時でもスレッドホップが発生します。これは UIKit や SwiftUI において、animation block のように同期的に作業を行う必要がある場合に、予期しない動作につながることがあります。 -
メインスレッドでは即座に処理を実行し、それ以外では
DispatchQueue.main.async
を使用する Scheduler を作成することができます。 (e.g. CombineScheduler のUIScheduler
を参照してください)。これは、より多くの複雑さをもたらすので、おそらく非常に良い理由がない限り、採用されるべきではないでしょう。
これが、全ての Action が同じスレッドから送られることを要求する理由です。この要件は
URLSession
や他の Apple API がどのように設計されているかと同じ精神に基づいています。これらの API は、自分にとって最も便利なスレッドで出力を行う傾向があり、その後それが必要であればメインキューにディスパッチバックするのはあなたの責任です。The Composable Architecture は、メインスレッドで Action を送信することを確認する責任をあなたに負わせます。もし、メインスレッドではないスレッドで出力を行う可能性のある Effect を使用している場合、メインスレッドに強制的に戻すために明示的に.receive(on:)
を実行しなければなりません。この方法は、Effect の生成と変換について最も少ない仮定を立て、不要なスレッドホップや再ディスパッチを防ぎます。また、テストの面でも利点があります。Effect が自分自身のスケジューリングに責任を持たない場合、テストでは全ての Effect が同期してすぐに実行されます。そうすると、複数の実行中の Effect が互いに影響し合って、アプリケーションの State にどのような影響を与えるかをテストすることができません。しかし、スケジューリングを
Store
から切り離すことで、必要であれば Effect のこれらの側面をテストすることができますし、必要であれば無視することもできます。私たちにはその柔軟性があります。しかし、もしあなたがまだ私たちの選択を好まないのであれば、心配しないでください!The Composable Architecture は柔軟性に富んでいるので、あなたが望めばこの機能を自分で導入することができます。Effect がどこで処理を行うかにかかわらず、全ての Effect の出力をメインスレッドで行うように強制できる higher-order reducer を作成することが可能です。
extension Reducer { func receive<S: Scheduler>(on scheduler: S) -> Self { Self { state, action, environment in self(&state, action, environment) .receive(on: scheduler) .eraseToEffect() } } }
スレッドホップを不必要に行わないようにするために、
UIScheduler
のようなものがきっと必要になるでしょう。 -
The Composable Architecture は Combine フレームワークに依存しているため、最低でも iOS 13, macOS 10.15, Mac Catalyst 13, tvOS 13, watchOS 6 のデプロイメントターゲットが必要です。どうしても古い OS に対応したい場合は、ReactiveSwift や RxSwift の fork がありますので、そちらを採用してください!
The Composable Architecture は package の依存関係として追加することで、Xcode プロジェクトに追加することができます。
- File メニューから、Add Packages... を選択します。
- package リポジトリの URL のテキストフィールドに "https://github.com/pointfreeco/swift-composable-architecture" を入力します。
- プロジェクトの構成によって異なります。
- もしライブラリにアクセスする必要がある単一のアプリケーションターゲットがある場合、アプリケーションに直接 ComposableArchitecture を追加してください。
- もし複数の Xcode ターゲットからこのライブラリを使用したい場合、または Xcode ターゲットと SPM ターゲットを混在させたい場合は ComposableArchitecture に依存する共有フレームワークを作成し、全てのターゲットでそのフレームワークに依存する必要があります。この例として、Tic-Tac-Toe デモアプリケーションをチェックしてください。これは多くの機能をモジュールに分割し、tic-tac-toe Swift package を使用してこの方法で静的ライブラリを使用しています。
releases と main
のドキュメントはこちらでご覧になれます。
他のバージョン
もし、The Composable Architecture について議論したい、または特定の問題を解決するために TCA をどのように使用するかについて質問がある場合、このリポジトリの discussions タブでトピックを立ち上げるか、its Swift forum で質問することができます。
この README の以下の翻訳は、コミュニティのメンバーによって寄稿されたものです。
もし翻訳を提供したい場合は、Gist へのリンクを添えて open a PR してください!
初期の頃のライブラリにご意見をいただき、現在のライブラリの姿になったのは、以下の方々のおかげです。
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 subscribers 全ての人たち 😁.
多くの奇妙な SwiftUI の癖を解決し、最終的な API を洗練するのを助けてくれた Chris Liscio に特別な感謝を捧げます。
そして Shai Mishali と CombineCommunity プロジェクトのおかげで、Publishers.Create
の実装を取得できました。この実装は Effect
で使用して、delegate と callback ベースの API をブリッジングし、サードパーティのフレームワークとのインターフェースをより簡単にするために役立ちます。
The Composable Architecture は、他のライブラリ、特に Elm や Redux によって始められたアイデアの基礎の上に構築されています。
また、Swift や iOS のコミュニティには、多くのアーキテクチャライブラリが存在します。これらはそれぞれ The Composable Architecture とは異なる優先順位やトレードオフの設定を持っています。
本ライブラリは、MIT ライセンスで公開されています。詳しくは LICENSE をご覧ください。