-
2022/10/16 更新
v0.42.0から採用されたReducerProtocolで書き方が大きく変わりましたので、記載内容やコードを見直しました
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という型も定義しています。
詳細は後回しにして、いったん図にまとめると以下のような関係性になります。
Viewでの操作(ユースケース)はすべてActionで表現します。
ActionをReducerで処理してStateに反映する単方向のデータ制御を実現しており、ViewStoreを経由してViewにStateの変更が反映されるようになっています。
データ層へのアクセスについては、ReducerからEffectを発行し、非同期でデータ操作を行います。結果はEffectからActionを投げることで再びReducerで処理され、Stateに反映されます。
では、各登場人物の詳細について解説します。まず、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の特徴の一つだと思います。
最後にStoreです。
- Store: State/Action/Reducerを一つにまとめ、それらの窓口となる。すべてのActionはStoreに対して送信され、それを受けてStoreはReducerを動かす。Reducerの処理結果で発生したStateの変更は、Storeを経由してViewで監視できるようになっている。
StoreをViewにバインドする時は WithViewStore
を使い、ViewStoreというViewにバインドできる型になります。
Basic Usage
ここからはTCAを使ってコードを書いていきます。
Basic Usageに記載されている、数字をインクリメント/デクリメントする画面を作成します。
「+」ボタンでカウンタがインクリメントされ、「-」ボタンでデクリメントされます。
「Number fact」ボタンを押すとアラートを表示します。アラートではカウンタを含んだ文字列が表示されるようにします。
プロジェクト作成からインストール
まずはXcodeで新規プロジェクトを作成します。「Interface」には「SwiftUI」を選択します。
TCAをインストールします。
TCAはライブラリとして提供されており、Swift Package Managerを使ってインストールします。
State/Action/Reducer
TCAの根幹となるState/Action/Reducerを作っていきますが、はじめにこれらをまとめるstructを作り、ReducerProtocolに準拠させます。これにより、State/Action/Reducerが関連付けされるようになります。
import Foundation
import ComposableArchitecture
struct Feature: ReducerProtocol {
}
Feature
structのインナークラスとしてState/Action/Reducerを定義していきます。
State
import ComposableArchitecture
struct Feature: ReducerProtocol {
struct State: Equatable {
var count = 0
var numberFactAlert: String?
}
}
count
が画面に表示するカウンタの値です。
numberFactAlert
には画面の「Number fact」ボタンを押下した時にアラートで表示する文字列が格納されます。
Action
struct Feature: ReducerProtocol {
struct State: Equatable { … }
enum Action: Equatable {
case factAlertDismissed
case decrementButtonTapped
case incrementButtonTapped
case numberFactButtonTapped
case numberFactResponse(TaskResult<String>)
}
}
enumでActionを定義します。各ケースは以下のアクションに対応しています。
-
factAlertDismissed
: アラートのボタンを押した時のアクション -
decrementButtonTapped
: マイナスボタンを押した時のアクション -
incrementButtonTapped
: プラスボタンを押した時のアクション -
numberFactButtonTapped
: 「Number fact」ボタンを押した時のアクション -
numberFactResponse
: 「Number fact」ボタンにより発生するEffectの戻りのアクション
factAlertDismissed
から numberFactButtonTapped
までがプレゼンテーション層から発生するアクションです。
numberFactResponse
はドメイン層/データ層から発生するアクションです。Basic Usageではデータ層へのアクセスはないため、ドメイン層からのアクションになりますが、データ層にアクセスした場合も似たような形になると思います。SwiftConcurrencyによる非同期処理の結果を受け取り、かつ、Swift標準のResultと同様に成功/失敗を表現できる TaskResult
というTCA独自の型をassociated valueとして受け取れるようになっています。
Reducer
struct Feature: ReducerProtocol {
struct State: Equatable { … }
enum Action: Equatable { … }
func reduce(into state: inout State, action: Action) -> Effect<Action, Never> {
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
}
}
}
}
Reducerでは各Actionに対応する「Stateへの処理」と「実行するべきEffect」について記述します。
ケース文の最後にreturnしている .none
はEmptyのEffectです。これによりそのケースでは実行されるべきEffectがないことを示しています。
Effectを使っている case .numberFactButtonTapped:
について取り上げます。
「Number fact」ボタンが押された際は非同期処理を実行するようになっています。
returnしている Effect.task
がTask
のようにSwiftConcurrencyの非同期処理を動かせる機能を提供し、戻り値のStringをTaskResultとActionでラップしています。
出来上がったActionに応じて再度Reducerが呼び出され、ドメイン層/データ層からの戻り値を再びReducerで処理することができるようになっています。
(なお、この例では非同期処理にHTTP通信を使用しているため、正しく動作させるにはATSに例外設定する、といった対応が必要です(設定方法の参考))
View
SwiftUIのViewへTCAを組み込みます。プロジェクト新規作成時にデフォルトで存在する ContentView
を使うように原本から少し改変しています。
import SwiftUI
import ComposableArchitecture
struct FeatureView: View {
let store: StoreOf<Feature>
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 FeatureView_Previews: PreviewProvider {
static var previews: some View {
ContentView(
store: Store(
initialState: Feature.State(),
reducer: reducer: Feature()
)
)
}
}
通常のSwiftUIで出てこないTCA特有の要素について解説していきます。
StoreOf<Feature>
ReducerProtocol
に準拠した Feature
型をジェネリクスのパラメータに指定することで、簡単にこのビューで使うState/Action/Reducerを指定できるようになっています。
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のプロパティとして書けるので簡単ですね。
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)
FeatureView_Previews
でプレビューできるようにStoreを作っています。
App
最後にAppでの FeatureView
生成箇所を修正して完了です。
import SwiftUI
import ComposableArchitecture
@main
struct TCABasicUsageApp: App {
var body: some Scene {
FeatureView(
store: Store(
initialState: Feature.State(),
reducer: Feature()
)
)
}
}
やっていることはViewの FeatureView_Previews
と同じです。
これで実行すれば画面が表示できると思います。お疲れ様でした!🎉
感想
TCA触ってみた感想です。
- 単方向制御がTCAの中で完結している
- プレゼンテーション層からの入力も、副作用を伴うデータ層からの入力もActionに変換されるので単方向制御が実現しやすい。
- ReducerからもEffectを介してActionを発行できるので、ドメイン層内のアクションにも対応できる。
- 非同期の処理をEffectで表現して、CombineとState/Reducer/Actionの世界をうまく繋げている。
- プレゼンテーション層からの入力も、副作用を伴うデータ層からの入力もActionに変換されるので単方向制御が実現しやすい。
- SwiftUIとの親和性が高い
- Viewでの値監視についてプロパティラッパーを意識しないで済むので$アクセスが不要になる。単なるViewStoreのプロパティとしてアクセスすればよくなるのでSwiftUIがより簡単になる。
煩雑になりがちな処理・データの流れが明快で、すごく洗練されたアーキテクチャになっているなと思いました。iOS13以上の制約がクリアできるならぜひ使いたいです。
あと、the Composable ArchitechtureのComposableたる所以であるState/Reducer/Actionをコンポーネント化・統合する機能についてはBasic Usageでは使われておらず、この記事では書いていません。時間があればまたどこかで書きたいなと思っています。
最後までご覧頂きありがとうございました🙇