SwiftUIでアプリ開発する時の問題点
Appleは、SwiftUIについて、UIKitでのMVCのようなアーキテクチャを「採用」していません。 SwiftUIには、@State、@ObservedObjectなどの多数の状態管理キーワードやプロパティラッパー(property wrapper)が用意されていますが、公式のSwiftUIチュートリアルには、データの伝達や 状態管理に関する部分がありますが、開発者が安定して拡張可能なアプリを構築するための指導には不充分です。
SwiftUIの基本的な状態管理パターンは、single source of truthを実現しています。すべてのviewは状態によって導かれるため、多くの欠点が存在します。例えば、以下のようなことがあげられます。
・複雑なproperty wrapperが必要で、正常に使用するには、少なくとも@State、@ObservedObject、@StateObject、@Binding、@EnvironmentObjectそれぞれの特徴と違いを覚えておかなければなりません。
・多くの状態変更コードがView.bodyに内包されており、他のViewコードと混在することがあります。同じ状態は、複数の関係のないViewから直接変更される可能性があり、これらの変更は追跡および特定するのが困難であり、アプリがより複雑になると悪夢となる可能性があります。
・テストが困難です。 SwiftUIフレームワークのViewは完全にStateによって決定されるため、理論的にはState(つまりモデル層)をテストするだけで十分ですが、厳密にAppleの公式チュートリアルの基本的な手順にしたがうと、アプリには多数のプライベートな状態が存在し、これらの状態はモック化するのが困難であり、できたとしてもこれらの状態の変更をテストする方法についての問題があります。
もちろん、これらの不満は克服できます。例えば、5つのプロパティラッパーの書き方を上手に使える、共有可能、変更可能な状態を減らして誤って変更されることを避ける、そしてAppleの推奨にしたがってプレビュー用のデータを準備し、Viewファイルを開いてプレビューの結果を確認する。
SwiftUIの使用をもっと簡単にするため、本当にアーキテクチャが必要です。
Elmから得た啓示
フロントエンドの開発界隈では、毎年に何個以上のアーキテクチャが生まれることがあります(笑)。新しいアーキテクチャが必要な場合、フロントエンドからアイデアを"参考"する事は間違いではないでしょう。SwiftUIの特徴を考慮に入れると、Elmは非常に優れた"参考"の対象です。
TEA のアーキテクチャ部品
全体のプロセスは以下のようになります (Cmd の部分は省略しています):
- ユーザーが view 上で操作 (たとえば、ボタンを押すなど) を行うと、メッセージが送信されます。Elm のある機構がこのメッセージをキャッチします。
- 新しいメッセージが到着すると、現在の Model と共に update 関数に入力として渡されます。この関数は通常、アプリ開発者が最も時間をかける部分であり、アプリの状態の変化を制御します。Elm アーキテクチャの中核であるこの関数は、入力されたメッセージと状態にもとづいて、新しい Model を演算する必要があります。
- 新しい Model は古い Model を置き換え、次のメッセージが到着する準備が整います。そして、新しい状態を取得するために上記のプロセスを繰り返します。
- Elm ランタイムは新しい Model を取得した後、view 関数を呼び出して結果をレンダリングします (Elm の文脈では、フロントエンド HTML ページです)。ユーザーはそれを介して新しいメッセージを再度送信し、上記のループを繰り返すことができます。
現在、TEAに基本的な理解ができました。SwiftUIでの実装について、これらのステップを比較すると、ステップ4はSwiftUIにすでに含まれていることがわかります。つまり、@Stateや@ObservedObjectの@Publishedが変更されたとき、SwiftUIは自動的にView.bodyを呼び出して新しいインタフェースをレンダリングします。したがって、SwiftUIでTEAを実装するには、ステップ1から3を実装する必要があります。言い換えると、SwiftUIの状態管理方法を規範化するルールが必要です。TCAはこの分野で多くの努力をしています。
最初の TCA アプリ
実際に何かを作ってみましょう。非常に地味ですが、 Counter を作ってみましょう。SwiftUI プロジェクトを新規作成します。テストについて多くの話題をあつかう予定なので、「Include Tests」にチェックを入れることを忘れないでください。そして、プロジェクトのパッケージ依存関係に TCA を追加します。
CounterReducer.swift を新規作成して以下を入れます。
public struct CounterReducer: ReducerProtocol {
public struct State: Equatable {
var count = 0
}
public enum Action: Equatable {
case increment
case decrement
}
// 2
public func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
switch action {
case .decrement:
// 3
state.count -= 1
return .send(.changeCountColor(state.count))
case .increment:
// 3
state.count += 1
return .send(.changeCountColor(state.count))
}
}
ContentView.swift を新規作成して以下を入れます。
public struct CounterView: View {
public let store: StoreOf<CounterReducer>
public var body: some View {
WithViewStore(store, observe: { $0 }) { viewStore in
VStack {
HStack {
// 1
Button("-") { viewStore.send(.decrement) }
Text("\(viewStore.count)")
Button("+") { viewStore.send(.increment) }
}
}
}
}
}
基本的には、前述のElmの要素をほんやくを加えたものです。Model → State、Msg → Action、update(msg:model:) → Reducer、view(model:) → ContentView.bodyにそれぞれ変更がくわえられています。
Reducer、Store、WithViewStoreはTCAで使用される型です。
- Reducerは関数型プログラミングにおいて一般的な概念で、複数の要素を結合して最終的な結果を返します。
- CounterViewでは、CounterReducer.Stateを直接操作するのではなく、StateをStoreに格納します。このStoreはCounterのStateとActionを接続する役割を担います。
上記のコードでは、1〜3の部分が、TEAの構成要素の対応する部分に正確に対応しています。
-
ユーザー操作は、直接状態を変更するのではなく、viewStoreにActionを送信することによって表現します。
- ここでは、ユーザーが「-」または「+」ボタンを押した場合、対応するCounterReducer.Actionを送信します。Actionをenumとして定義することで、意図をより明確に表現することができます。さらに、複数のReducerを結合する際に便利な特徴を提供することができます。強制ではないですが、特別な理由がない限り、Actionを表現するためにenumを使用することをお勧めします。
-
状態の変更はReducerの中でのみ行います。
-
Reducerは論理の核心部分であり、TCAの中で最も柔軟な部分でもあります。ほとんどの作業は、適切なReducerを構築すること中心にて行われるべきです。状態の変更はReducerの中でのみおこない、その初期化メソッドは、次のような関数を受け取るようにします。
- @escaping (inout State, Action) -> EffectTask
inoutのStateは、Stateを返すことなく、Stateを"現在地"で変更することができます。この関数の戻り値EffectTaskは、APIリクエスト、現在の時間の取得などのReducerで行うべきでない副作用を表する物です。
-
-
ステータスを更新してレンダリングを発動する
- Reducerのクロージャ内で状態を変更することは合法であり、TCAは新しい状態を使用してビューのレンダリングをします。そしてStateを保存して、次のアクションが到着するのを待って。TCAはViewStore(ViewStore自体はObservableObject)を使用して、@ObservedObjectを介してUIの更新を発動します。
これらの内容があれば、モジュール全体の実行は完了します。Previewの部分で、初期のモデルインスタンスとReducerを渡してStoreを作成します:
struct CounterView_Previews: PreviewProvider {
static var previews: some View {
CounterView(store: .init(initialState: .init(), reducer: CounterReducer()))
}
}
デバッグとテスト
この仕組みが正常に動作するための重要な前提条件の一つは、Stateを使用してビューをレンダリングする部分が正しいことです。つまり、SwiftUIにおいて、State -> Viewのプロセスが正しいことを信頼する必要があります(実際には正しくなくても、SwiftUIのフレームワークの使用者として、私たちができることは限られています)。この前提条件の下で、私たちは、Actionの送信が正しいか、ReducerによるStateの変更が正しいかを確認するだけで済みます。
TCAのReducerには、非常に便利な._printChanges()メソッドがあります。これを使用すると、このReducerでコンソールのデバッグ出力を有効にし、受信したActionとその中のStateの変更を出力できます。
このメソッドは、#if DEBUGのコンパイル条件下でのみ出力されます。つまり、リリース時には影響を与えません。また、より多くの、より複雑なReducerがある場合、デバッグに役立つReducerのみ._printChanges()を呼び出すことができます。TCAでは、関連するState/Reducer/ActionのグループがComponentと呼ばれます。常に、小さなウィジェットのComponentをまとめて、より大きなFeatureを構成したり、Featureも他のFeatureに追加したりして、より大きなFeatureのグループを形成することができます。このような組み合わせによる開発方法を使用することで、小さな機能のテスト可能性と利用可能性を維持できます。そして、このような組み合わせこそが、The Composable ArchitectureでComposableが代表する意味です。
現時点では、Counterという1つのComponentしかありません。アプリが複雑になるにつれて、より多くのComponentが表示されるようになります。TCAが提供するツールを使用して、それらを組み合わせる方法があります。
._printChanges()を使用すると、状態の変化をコンソール上で実際に見ることができますが、もしこれらの変化をユニットテストで保証できるであれば、安全性はもっとの保証されます。.incrementを送信する場合の状況を検証するユニットテストを追加します:
func testCounterIncrement() throws {
let store = TestStore(
initialState: CounterReducer.State(),
reducer: CounterReducer()
)
store.send(.increment) { state in
state.count += 1
}
}
TestStoreは、テストに特化したTCAの一種のストアです。このストアは、sendで送信されたアクションを受け取ると同時に、内部にアサーションを持っています。もし、Actionによって新しいStateの状態が提供されたStateの状態と一致しない場合、テストは失敗します。上記の例では、store.send(.increment)によってStateが変更され、countが増加するはずです。そのため、sendメソッドで提供されるクロージャの内部で、最終状態としてstateを正しく更新しました。
もしテストが失敗した場合、TCAは非常に見やすいdiff結果を出力し、エラーを明確にします。
TestStoreには、内蔵のアサーション以外にも、順番に敏感なテストに使用するための他の用途があります。また、適切なDependenciesを設定することで、安定した副作用を提供してモックとして使用することもできます。これらの問題は、他のアーキテクチャを使用する場合にも遭遇することがあり、いくつかの場合には扱いが難しい場合があります。このような場合、開発者の選択肢はしばしば「テストの書き方が難しい、あきらめることにする」かな。TCAの使いやすいテストスイートの助けを借りることで、テストを回避するためにこの口実を使うことはほとんど不可能になります。多くの場合、テストの書き方はむしろ楽しみになり(?)、プロジェクトの品質を向上させるための重要な役割を果たします。
StoreとViewStore
Storeを分割して、不要なビューの更新を回避する
このシンプルな例では、StoreとViewStoreの設計について強調する必要がある重要な部分があります。Storeは状態の保持者であり、実行時にStateとActionを接続する役割も担っています。Single source of truthは、状態に基づいてUIを動作させるための基本原則の1つであり、この要件により、状態を保持する役割は1つだけであることが望ましいです。したがって、全体的にアプリには1つのストアしか存在しないという選択肢が一般的です。UIはこのストアを監視し(@ObservedObjectで設定することなどによって)、必要な状態を取得し、状態の変化に応答します。
通常の場合、このようなストアには非常に多くの状態が存在しますが、具体的なビューは通常、その中の非常に小さなサブセットの状態のみを必要とします。たとえば、上図のView1はState1に依存するだけで、State2には全く関心がありません。
ビューを直接ストア全体を観察させると、ある状態が変化した場合、SwiftUIはストアを観察するすべてのUIの更新を要求し、すべてのビューがbodyを再計算することになり、非常に無駄になります。例えば、下の図では、State2が変化しましたが、State2に依存しないView1とView1-1は、ストアを観察したために@ObservedObjectの特性により、bodyを再計算する必要があります。
TCAでは、この問題を回避するために、従来のストアの機能を分割し、ViewStoreの概念を発明しました。
Storeは引き続き状態の実際の管理者であり所有者であり、アプリケーションの状態の純粋なデータ層を表します。TCAの使用者にとって、Storeの最も重要な機能は、状態を分割することです。例えば、図に示すようなStateとStoreに対して:
struct State1 {
struct State1_1 {
var foo: Int
}
var childState: State1_1
var bar: Int
}
struct State2 {
var baz: Int
}
struct AppState {
var state1: State1
var state2: State2
}
let store = Store(
initialState: AppState( /* */ ),
reducer: appReducer
)
異なる画面にストアを渡す場合、.scopeを使用してそれを「切り分ける」ことができます:
let store: Store<AppState, AppAction>
var body: some View {
TabView {
View1(
store: store.scope(
state: \.state1, action: AppAction.action1
)
)
View2(
store: store.scope(
state: \.state2, action: AppAction.action2
)
)
}
}
これにより、各画面でアクセスできる状態を制限し、明確に保ちます。
最後に、最もシンプルなTCAアーキテクチャのコードを見てみましょう:
struct CounterView: View {
let store: StoreOf<CounterReducer>
var body: some View {
WithViewStore(store) { viewStore in
HStack {
Button("-") { viewStore.send(.decrement) }
Text("\(viewStore.count)")
Button("+") { viewStore.send(.increment) }
}
}
}
}
TCAは、WithViewStoreを介して純粋なデータを表すStoreをSwiftUIで監視可能なデータに変換します。予期しないことがない限り、WithViewStoreが受け取るクロージャがViewプロトコルを満たす場合、それ自体もViewを満たします。これが、CounterViewのbodyでそれを直接使用してViewを構築できる理由です。このViewであるWithViewStoreは、内部的にViewStore型を持っており、さらにストアへの参照を保持しています。Viewとして、@ObservedObjectを使用してこのViewStoreを監視し、その変更に応答します。したがって、Viewが持つのが分割されたStoreの場合、元のStoreの他の部分の変更は、現在のStoreのスライスに影響を与えず、現在のUIの更新を引き起こさない。
View間でデータを上から下へ伝達する場合、Storeを細分化するように心がけることで、モジュール同士が干渉しないようにできます。ただし、TCAを使用してプロジェクトを行う場合、より小さなモジュールから構築することが多く、それぞれが独自のFeatureを持ち、その後、これらのローカルコンテンツを上位のものに"追加"することが一般的です。そのため、Storeの分割は自然なものになります。この部分についてまだ疑問を抱いているかもしれませんが、Featureの分割と組織化について徐々に深入りし、より多くの例を見ることができます。
最後
StoreとViewStoreの分離により、TCAはUIフレームワークに依存せずに使用できます。 SwiftUIでは、bodyの更新は、提供される機能を@ObservedObject属性でラップすることによって、SwiftUIランタイムによって行われます。 これらの機能は、現在WithViewStoreに含まれています。 ただし、StoreとViewStore自体は、特定のUIフレームワークに依存しません。 つまり、同じAPIを使用してUIKitまたはAppKitのアプリでTCAを使用することもできます。 ViewとModelを自分でバインドする必要があるため、多少手間がかかりますが、TCAをすぐに試してみたいがSwiftUIを使用できない場合は、UIKitで学習を行うこともできます。 経験は、他のUIプラットフォーム(Webアプリケーションを含む)に簡単に移行できます。