The Composable Architecture (省略すると TCA) は、コンポジション、テスト、開発者にとっての使いやすさを考慮し、一貫性のある理解しやすい方法でアプリケーションを構築するためのライブラリです。SwiftUI、UIKit などで使用することができ、Apple のどのプラットフォーム (iOS, macOS, tvOS, watchOS) でも使用できます。
このライブラリは、さまざまな目的や複雑さのアプリケーションを構築するために使用できる、いくつかのコアツールを提供します。アプリケーションを構築する際に日々遭遇する多くの問題を解決するために、以下のような説得力のあるストーリーが提供されています。
-
状態管理
シンプルな値型を使用してアプリケーションの状態を管理し、多くの画面で状態を共有して、ある画面での状態の変更を別の画面ですぐに observe できるようにする方法。 -
コンポジション
大きな機能を小さな Component に分解し、それぞれを独立したモジュールに抽出し、簡単にそれらを繋いで機能を形成する方法。 -
副作用
可能な限りテスト可能で理解しやすい方法で、アプリケーションの特定の部分を外界と対話させる方法。 -
テスト
アーキテクチャで構築された機能をテストするだけでなく、多くの Component で構成された機能の Integration test を書いたり、副作用がアプリケーションに与える影響を理解するために E2E テストを書いたりする方法。これにより、ビジネスロジックが期待通りに動作していることを強く保証することができる。 -
開発者にとっての使いやすさ
上記の全てを、できるだけ少ないコンセプトと動作するパーツからなるシンプルな API で実現する方法。
The Composable Architecture は Brandon Williams と Stephen Celis がホストする、関数型プログラミングと Swift 言語を探求するビデオシリーズである Point-Free の多くのエピソードを経て設計されています。
全エピソードはこちらでご覧いただけます。また、ゼロから Architecture を学ぶいくつかのパートからなる TCA 専用のツアーもあります。
このリポジトリには、The Composable Architecture で一般的な問題や複雑な問題を解決する方法を示す 数多くの サンプルが含まれています。この Examples ディレクトリを checkout して、それらを全て見てください。そのディレクトリには以下が含まれています。
- Case Studies
- Getting started
- Effects
- Navigation
- Higher-order reducers
- Reusable components
- Location manager
- Motion manager
- Search
- Speech Recognition
- Standups app
- Tic-Tac-Toe
- Todos
- Voice memos
もっと充実したものをお探しですか? SwiftUI と The Composable Architecture で作られた iOS の単語検索ゲームである isowords のソースコードをチェックしてみてください。
Note ステップ・バイ・ステップのインタラクティブなチュートリアルについては、Meet the Composable Architecture を参照してください。
The Composable Archtiecture を使用して機能を構築するには、ドメインをモデル化するいくつかの型と値を定義します。
- State: あなたのアプリの機能がロジックを実行し、UI をレンダリングするために必要なデータを記述する型です。
- Action: ユーザーのアクション、通知、イベントソースなど、あなたのアプリの機能で起こりうる全てのアクションを表す型です。
- Reducer: Action が与えられた時に、アプリの現在の State を次の State に進化させる方法を記述する関数です。Reducer は API リクエストのような実行すべき effects を return する役割も担っており、
Effect
を return することでそれを実行できます。 - Store: 実際にあなたのアプリの機能を動かす runtime です。全てのユーザーアクションを Store に送信し、Store は Reducer と Effect を実行できるようにし、Store の状態変化を observe して UI を更新できるようにします。
この方法の利点は、あなたのアプリの機能のテスタビリティを即座に解除し、大規模で複雑な機能を繋げることができる小さなドメインに分割できるようになることです。
基本的な例として、数字と数字を増減させる「+」「-」ボタンが表示される UI を考えてみましょう。もっと面白い例にするために、タップすると API リクエストを行い、その数字に関するランダムな事実を取得し、その事実を Alert で表示するボタンがある UI だとします。
この機能を実装するために、ドメインと機能の振る舞いを保持する Reducer
に準拠した新しい型を定義することができます。
import ComposableArchitecture
struct Feature: Reducer {
}
ここでは、現在のカウントを表す integer と、表示したいアラートのタイトルを表す String(nil はアラートを表示しないことを表すので optional としている) で構成される機能の state のための型を定義する必要があります。
struct Feature: Reducer {
struct State: Equatable {
var count = 0
var numberFactAlert: String?
}
}
また、機能の actions のための型を定義する必要があります。decrement button, increment button, fact button などをタップするような明らかな actions があります。しかし、ユーザーがアラートを dismiss する action や fact API request から response を受け取るときに発生する action などのような少しわかりにくい actions もあります。
struct Feature: Reducer {
struct State: Equatable { /* ... */ }
enum Action: Equatable {
case factAlertDismissed
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(String)
}
}
そして、機能の実際のロジックや振る舞いをハンドリングする役割を持っている reduce
メソッドを実装する必要があります。そこには現在の state を次の state に変更する方法を記述し、どのような effects を実行する必要があるのかも記述します。actions によっては effects を実行する必要がないものもあり、その場合は .none
を return することができます。
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
}
}
}
そして最後に、機能を表示する View を定義します。その View では StoreOf<Feature>
を保持して、state への全ての変更を observe して再レンダリングできるようにし、state を変化させるためにユーザーの actions を store に送信できるようにします。また、
.alert
view modifier が必要とする Identifiable
を満たせるように、fact アラートの周辺に struct の wrapper を導入する必要があります。
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 }
}
また、この store から UIKit controller を駆動させることも簡単です。UI を更新して alert を表示するために viewDidLoad
で store を subscribe すれば良いだけです。コードは 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)
}
}
この view を表示する準備ができたら、例えばアプリのエントリポイントで store を構築することができます。これは、アプリケーションを起動するための初期 state や、アプリケーションを動かす reducer を指定することで実現できます。
import ComposableArchitecture
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature()
}
)
}
}
}
そしてそれはスクリーン上で遊ぶための何かを得るのに十分です。vanilla SwiftUI (素の SwiftUI) で同様の挙動を実現する場合よりも、確かにいくつかのステップを必要としますが、いくつかの利点があります。いくつかの observable objects や UI components の様々な action closures でロジックをばら撒く代わりに、state の mutation を適用するための一貫した方法を提供してくれます。また、副作用を簡潔に表現することもできます。そして、これらのロジックは effects を含めてすぐにテストすることができます。
Note テストに関するより詳細な情報は、testing 記事を参照してください。
テストするためには TestStore
を使用します。それは Store
と同じ情報で作成できますが、actions が送信されたときに機能がどのように変化するかを assert するための追加の作業を行うことになります。
@MainActor
func testFeature() async {
let store = TestStore(initialState: Feature.State()) {
Feature()
}
}
test store を作成したら、それを使ってユーザーフロー全体のステップを assertion することができます。各ステップごとに、state が期待通りに変化したことを証明する必要があります。例えば、increment button と decrement button をタップするというユーザーフローをシミュレートすることができます。
// increment/decrement button をタップするとカウントが変化することをテストする
await store.send(.incrementButtonTapped) {
$0.count = 1
}
await store.send(.decrementButtonTapped) {
$0.count = 0
}
さらに、ステップによって effect が実行され、データが store にフィードバックされる場合、それについて assert しなければなりません。例えば、ユーザーが fact button をタップすることをシミュレートする場合、fact を含む response が返却され、それによって alert が表示されることを期待します。
await store.send(.numberFactButtonTapped)
await store.receive(.numberFactResponse(.success(???))) {
$0.numberFactAlert = ???
}
しかし、どのような fact が送り返されるかをどうやって知ることができるでしょうか?
現在、私たちの reducer は API server を叩くために実世界に到達する effect を使用しており、それは私たちがその動作を制御する方法がないことを意味しています。このテストを書くために、私たちはインターネット接続と API server の可用性の気まぐれにさらされています。
この dependency を reducer に渡すことで、デバイス上でアプリケーションを実行するときは実際の dependency を使用し、テストでは mock 化された dependency を使用できるようにするのが良いでしょう。これは Feature
reducer に property を追加することで実現できます。
struct Feature: Reducer {
let numberFact: (Int) async throws -> String
// ...
}
そして、それを reduce
の実装で使うことができます。
case .numberFactButtonTapped:
return .run { [count = state.count] send in
let fact = try await self.numberFact(count)
await send(.numberFactResponse(fact))
}
そして、アプリケーションのエントリポイントでは、実際に現実の API server と対話する dependency を提供することができます。
@main
struct MyApp: App {
var body: some Scene {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature(
numberFact: { number in
let (data, _) = try await URLSession.shared.data(
from: URL(string: "http://numbersapi.com/\(number)")!
)
return String(decoding: data, as: UTF8.self)
}
)
}
)
}
}
しかし、テストでは決定論的で予測可能な fact を即座に返す mock dependency を使用することができます。
@MainActor
func testFeature() async {
let store = TestStore(initialState: Feature.State()) {
Feature(numberFact: { "\($0) is a good number Brent" })
}
}
このように少しの作業で、ユーザーが fact button をタップし、dependency から response を受け取り、アラートをトリガーし、アラートを dismiss することをシミュレートして、テストを終了することができます。
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
の dependency を使用する際の人間工学的な改善も可能です。時間と共にアプリケーションは多くの機能を持つようになり、それらの機能の中には numberFact
にアクセスしたいものも出てくるかもしれません。全てのレイヤーを通して明示的に numberFact
を渡すのは煩わしくなってきます。dependency をライブラリに「登録」することで、アプリケーションのどの層でも即座に dependency を利用できるようにするプロセスがあります。
Note dependency の管理についてのより詳細な情報は、dependencies の記事を参照してください。
まず、numberFact
の機能を新しい型に包むことから始めましょう。
struct NumberFactClient {
var fetch: (Int) async throws -> String
}
そして、client を DependencyKey
protocol に準拠させることで、その型を dependency の管理システムに登録します。この protocol では、シミュレータやデバイスでアプリケーションを実行するときに使用する live value (実際の値) を指定することが求められます。
extension NumberFactClient: DependencyKey {
static let liveValue = Self(
fetch: { number in
let (data, _) = try await URLSession.shared
.data(from: URL(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)
}
このコードは以前と全く同じように動作しますが、機能の reducer を構築する際に dependency を明示的に渡す必要がなくなりました。previews やシミュレータ、デバイス上でアプリを実行する場合は、live dependency が reducer に提供され、テストではテストの dependency が提供されます。
これは、アプリのエントリポイントが dependency を構築する必要がなくなったことを意味します:
@main
struct MyApp: App {
var body: some Scene {
FeatureView(
store: Store(initialState: Feature.State()) {
Feature()
}
)
}
}
また、test store は dependency を指定せずに構築できますが、テストの目的に応じて必要な dependency を override することができます。
let store = TestStore(initialState: Feature.State()) {
Feature()
} withDependencies: {
$0.numberFact.fetch = { "\($0) is a good number Brent" }
}
// ...
これが the Composable Architecture で機能を構築しテストするための基本的な方法です。composition (合成)、modularity (モジュール化)、adaptability (適応性)、複雑な effects など、まだまだ探究すべきことが 多く あります。Examples ディレクトリには、より高度な使い方を見ることができるプロジェクトが数多くあります。
releases と main
のドキュメントはこちらで見ることができます。
ドキュメントには、ライブラリを使いこなす上で参考になる記事が数多く掲載されています。
The Composable Architecture について議論したい場合、あるいは特定の問題を解決するために The Composable Architecture をどのように使うかについて質問したい場合、Point-Free の愛好家仲間と議論できる以下のような多くの場所があります。
- 長文の議論には、このリポジトリの discussions タブをお勧めします。
- カジュアルなチャットには、Point-Free Community slack をお勧めします。
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 を使用してこの方法で静的ライブラリを使用しています。
この README の以下の翻訳は、コミュニティのメンバーによって寄稿されたものです。
- Arabic
- French
- Hindi
- Indonesian
- Italian
- Japanese
- Korean
- Polish
- Portuguese
- Russian
- Simplified Chinese
- Spanish
- Ukrainian
もし翻訳を提供したい場合は、Gist へのリンクを添えて PR を作ってください!
-
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 で実現され、複雑な機能の処理と、より分離されたコードベースとコンパイル時間の改善のためのモジュール化を支援します。
初期の頃のライブラリにご意見をいただき、現在のライブラリの姿になったのは、以下の方々のおかげです。
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 をご覧ください。