iOSDCでのyimajoさんの発表など、the Composable Architecture(以下、TCA)が良さそうという評判を聞いて調べてみました。どこから始めていいのか少し迷ったので、公式レポジトリのREADMEにあるBasic UsageをベースにTCAの始め方を解説してみます。
TCAどころかSwiftUIすら勉強し始めなので、間違いなどあるかもしれません。コメントで教えて頂けると嬉しいです!
TCAって何?
TCAはiOSなどのAppleプラットフォームのアプリケーション開発のためのフレームワークです。
Combineを前提としているため、iOSだとiOS13以上が対象となる制約があります。(ちなみにiOS13未満向けにRxSwift版のforkもあるみたいです)
SwiftUIとの親和性が高く、SwiftUIをより使いやすくする機能が充実しています。
TCAが提供する機能については、READMEのWhat is the Composable Architecture?に以下が挙げられています。
State Management
シンプルな値型によるアプリケーションの状態管理の手段を提供します。複数の画面にまたがって状態は共有され、1つの画面での状態の更新はただちに他の画面にも反映されます。Composition
巨大な機能を小さなコンポーネントにブレイクダウンして独立したモジュールに分離し、それらを簡単にまとめ直して1つの機能に組み上げる手段を提供します。Side Effects
可能な限り最もテスタビリティが高く、理解しやすい方法で、副作用を伴うアプリケーションの外側の世界とやり取りする手段を提供します。Testing
TCAを使って実装した機能をテストするだけでなく、多くの部品で構成された機能の統合テストを書いたり、副作用がアプリケーションにどのように影響するかを把握するためのエンドツーエンドのテストを書く手段を提供します。これにより、ビジネスロジックが期待通りに動作していることを強力に保証することができます。Ergonomics
コンセプトと部品を可能な限り少なくしたシンプルなAPIにより、上記のすべてを達成する手段を提供します。
アーキテクチャ概要
コードを書き始める前に簡単にTCAのアーキテクチャとしての概要を説明したいと思います。
TCAはReduxやFluxといったアーキテクチャと近い構造を持っていて、Store/State/Action/Reducerという型を持っています。またこれらに加えてEffectとEnvironmentという型も定義しています。
まずState/Action/Reducerについて解説します。
- State: 1つの機能がそのロジックを実行したり、UIを描画したりするために、必要となるデータを定義する。
- Action: 1つの機能で発生するすべてのアクション(ユーザからのUI操作やユーザへの通知やデータ層からのデータ受け取りなど)を表現する。
-
Reducer: 受け取ったActionに応じてStateを更新するファンクション。Stateを更新するために発生するあらゆる作用(副作用を含む)に対して処理を行う責務をもち、作用からは
Effect
型で値が返却されてくることになる。
上記で出てきたEffectがTCAに特有の考え方になっています。
- Effect
- 副作用(Side Effect)を含む作用を表現する型
- Reduxでは副作用はStore/State/Action/Reducerの枠組みの外で扱うべきとなっている(ActionCreatorなどが担当することになる)が、TCAではReducerの中で扱うことができるようになっている。
ReducerがEffectを扱うことができることによって、プレゼンテーション層/ドメイン層/データ層の各層で発生するすべてのアクションを単なるActionとして扱うことができるようになっていることが、TCAの特徴の一つだと思います。
次にEnvironmentです。
- Environment: 1つの機能において必要となる依存を保持する。
いわゆるDI(Dependency Injection)を提供します。
Basic Usageの中ではあまり本質的な使い方がされてなくてよくわかっていない部分があるのですが、Environmentによってロジックの入れ替えが実現可能となり、テスタビリティ向上やState/Action/Reducerの再利用の促進(よりComposableにできる)に繋がっているのかなーと思っています。
最後にStoreです。
- Store: State/Action/Reducer/Environmentを一つにまとめ、それらの窓口となる。すべてのActionはStoreに対して送信され、それを受けてStoreはReducerを動かす。Reducerの処理結果で発生したStateの変更は、Storeを経由してViewで監視できるようになっている。
StoreをViewにバインドする時は WithViewStore
を使い、ViewStoreというViewにバインドできる型になります。
これらを図にまとめると以下のような関係性になっています。
Viewでの操作はすべてActionに変換されます。
ActionをReducerで処理してStateに反映する単方向のデータ制御を実現しており、ViewStoreを経由してViewにStateの変更が反映されるようになっています。
データ層へのアクセスについては、ReducerまたはEnvironmentを経由してEffectを発行し、非同期でデータ操作を行います。結果はEffectからActionを投げることで、Stateに反映されます。
Basic Usage
ここからはTCAを使ってコードを書いていきます。
Basic Usageに記載されている、数字をインクリメント/デクリメントする画面を作成します。
「+」ボタンでカウンタがインクリメントされ、「-」ボタンでデクリメントされます。
「Number fact」ボタンを押すとアラートを表示します。アラートではカウンタを含んだ文字列が表示されるようにします。
プロジェクト作成からインストール
まずはXcodeで新規プロジェクトを作成します。「Interface」には「SwiftUI」を選択します。
TCAをインストールします。
TCAはライブラリとして提供されており、Swift Package Managerを使ってインストールします。
State/Action/Environment/Reducer
TCAの根幹となるState/Action/Environment/Reducerを作ります。
State
import ComposableArchitecture
struct AppState: Equatable {
var count = 0
var numberFactAlert: String?
}
count
が画面に表示するカウンタの値です。
numberFactAlert
には画面の「Number fact」ボタンを押下した時にアラートで表示する文字列が格納されます。
Action
enum AppAction: Equatable {
case factAlertDismissed
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(Result<String, ApiError>)
}
struct ApiError: Error, Equatable {}
enumでActionを定義します。各ケースは以下のアクションに対応しています。
-
factAlertDismissed
: アラートのボタンを押した時のアクション -
decrementButtonTapped
: マイナスボタンを押した時のアクション -
incrementButtonTapped
: プラスボタンを押した時のアクション -
numberFactButtonTapped
: 「Number fact」ボタンを押した時のアクション -
numberFactResponse
: 「Number fact」ボタンにより発生するEffectの戻りのアクション
factAlertDismissed
から numberFactButtonTapped
までがプレゼンテーション層から発生するアクションです。
numberFactResponse
はドメイン層/データ層から発生するアクションです。Basic Usageではデータ層へのアクセスはないため、ドメイン層からのアクションになりますが、データ層にアクセスした場合も似たような形になると思います。成功/失敗に対応できるように Result
をAssociated Valueとして受け取れるようになっています。
Envrionment
struct AppEnvironment {
var mainQueue: AnySchedulerOf<DispatchQueue>
var numberFact: (Int) -> Effect<String, ApiError>
}
依存対象を切り出しています。
numberFact
はカウンタの値を引数にアラートに表示する文字列を作るクロージャです。
Reducer
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()
.map(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
}
}
Reducerでは各Actionに対応する「Stateへの処理」と「実行するべきEffect」について記述します。
ケース文の最後にreturnしている .none
はEmptyのEffectです。これによりそのケースでは実行されるべきEffectがないことを示しています。つまりReducerはState, Action, Environmentを引数にしてEffectを返すクロージャと認識すればよいかなと思います。(正確にはクロージャを引数にした.initを持つstructです)
Effectを使っている case .numberFactButtonTapped:
について取り上げます。
「Number fact」ボタンが押された際はEffectで非同期的な処理を実行するようになっています。
AppEnvironment
で定義した numberFact
クロージャを動かし、戻り値のEffectを最終的にはActionに変換しています。Actionに変換することにより再度Reducerが呼び出されます。ドメイン層/データ層からの戻り値を再びReducerで処理することができるようになっています。
(途中の receive(on:)
は実行スレッドの指定、 catchToEffect()
は receive(on:)
で Publisher
に変換された型を再び Effect
に戻しています。)
View
SwiftUIのViewへTCAを組み込みます。プロジェクト新規作成時にデフォルトで存在する ContentView
を使うように原本から少し改変しています。
import SwiftUI
import ComposableArchitecture
struct ContentView: 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 }
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView(
store: Store(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
numberFact: { number in
Effect(value: "\(number) is a good number Brent")
}
)
)
)
}
}
通常のSwiftUIで出てこないTCA特有の要素について解説していきます。
WithViewStore(self.store) { viewStore in }
WithViewStore
でViewにStoreを組み込みます。
WithViewStore
はSwiftUIの View
を返すので、 var body: some View {}
の中で使うことができます。
Button("−") { viewStore.send(.decrementButtonTapped) }
viewStore.send()
でActionを送ります。
ここでは「-」ボタンを押した時に .decrementButtonTapped
Actionを送るように設定しています。
Text("\(viewStore.count)")
viewStore
のプロパティとしてStateのプロパティにアクセスできます。
Stateが更新されれば自動的にViewが更新されます。
SwiftUIだと値をバインドする時にはプロパティラッパーを使うことになると思うのですが、単なるViewStoreのプロパティとして書けるので簡単ですね。
(StoreからStateにプロパティが置き換わっているのがどうやって実現しているのかはまだよくわかっていません...🤔 )
viewStore.binding(get:send:)
viewStore.binding(get:send:)
でSwiftUIの Binding
を提供して、ViewとViewStoreの間に双方向のバインディングを実現します。ViewStore→View方向のバインディングは get:
で指定してStateが引数として渡され、Viewへの出力内容を設定できます。View→ViewStore方向のバインディングは send:
で指定し、Viewの操作をトリガーにActionを送ることができます。
ここでは alert(item:content:)
の item: Binding<FactAlert?>
を作るために使われています。
ViewStore→View方向では、Stateの numberFactAlert
をAlertで使用する FactAlert
struct に変換しています。Stateで保持しているドメイン層のモデルをプレゼンテーション層のモデル( FactAlert
)に変換しているイメージかなと思います。
View→ViewStore方向では、アラートでボタンを押した時のアクションを設定しています。
Store(initialState:reducer:environment:)
ContentView_Previews
でプレビューできるようにStoreを作っています。 environment:
で指定している AppEnvironment(mainQueue:numberFact)
の numberFact:
で Effect<String>
を返すクロージャを指定し、ようやくここでアラートにどういった文字を表示するのかが決まります。
App
最後にAppでの ContentView
生成箇所を修正して完了です。
import SwiftUI
import ComposableArchitecture
@main
struct TCABasicUsageApp: App {
var body: some Scene {
WindowGroup {
ContentView(
store: Store(
initialState: AppState(),
reducer: appReducer,
environment: AppEnvironment(
mainQueue: DispatchQueue.main.eraseToAnyScheduler(),
numberFact: { number in
Effect(value: "\(number) is a good number Brent")
}
)
)
)
}
}
}
やっていることはViewの ContentView_Previews
と同じです。
これで実行すれば画面が表示できると思います。お疲れ様でした!🎉
感想
TCA触ってみた感想です。
- 単方向制御がTCAの中で完結している
- プレゼンテーション層からの入力も、副作用を伴うデータ層からの入力もActionに変換されるので単方向制御が実現しやすい。
- ReducerからもEffectを介してActionを発行できるので、ドメイン層内のアクションにも対応できる。
- 非同期の処理をEffectで表現して、CombineとState/Reducer/Actionの世界をうまく繋げている。
- プレゼンテーション層からの入力も、副作用を伴うデータ層からの入力もActionに変換されるので単方向制御が実現しやすい。
- SwiftUIとの親和性が高い
- Viewでの値監視についてプロパティラッパーを意識しないで済むので$アクセスが不要になる。単なるViewStoreのプロパティとしてアクセスすればよくなるのでSwiftUIがより簡単になる。
煩雑になりがちな処理・データの流れが明快で、すごく洗練されたアーキテクチャになっているなと思いました。iOS13以上の制約がクリアできるならぜひ使いたいです。
あと、the Composable ArchitechtureのComposableたる所以である combine
/ pullBack
についてはBasic Usageでは使われておらず、この記事では書いていません。時間があればまたどこかで書きたいなと思っています。
最後までご覧頂きありがとうございました🙇