LoginSignup
169
118

【SwiftUI】なぜ、MVVMをやめて、The Composable Architecture(TCA)を採用するのか?

Last updated at Posted at 2022-04-23

はじめに

先月、 【「SwiftUIでMVVMを採用するのは止めよう」と思い至った理由】
という記事を公開し、多くの反響がありました。

上記の記事では

「じゃあ、MVVMをやめて、アーキテクチャは何を採用すればよいの?」

という問いに対する、明確な答えを出していませんでした。

あれから時が経ち、今ならば、この問いに対して、

ぼくは 「The Composable Architecture(TCA)をおすすめします」 と答えることができます。

Screen Shot 2022-04-23 at 13.58.31.png

以下は公式ページから抜粋したものを翻訳しました。

「The Composable Architecture(TCA)」の目的について、以下の様に記述されています。

アプリケーションアーキテクチャの探求は、どんなアーキテクチャも解決することを目的とする核となる問題を理解することから始めます。そして、SwiftUIが状態管理にアプローチする方法を見ることで、これらの問題を探求します。これは、解決しなければならない5つの大きな問題を定式化することにつながり、この時点から私たちのアーキテクチャの開発を導きます。

TCAが解決する5つの大きな問題とは、以下のように記述されています。

・アプリケーション全体の状態を管理する方法
・値の型のような単純な単位でアーキテクチャをモデル化する方法
・アプリケーションの各機能をモジュール化する。
・アプリケーションの副作用をモデル化する。
・各機能の包括的なテストを簡単に記述する方法

現在、ぼくは、TCAを実際に仕事で使っていて、上記の5つの問題に頭を悩ませていたので、 「これはいいものだ」 という確信を持っています。

book_hirameki_keihatsu_man.png

一方で、TCAには、 学習コストの高さや、ライブラリの開発体制が不安(※個人の感想です) というデメリットがあります。
ですが、デメリットはいったんは脇において、 メリットを享受するほうが大きい という判断に至りました。

本記事の内容は、あくまで、現在、「私はこう考えた」という意味であって、「この考えが絶対的に正しい」という主張ではありません。

人によって、見ているコードベースや、バックグラウンド、考え方、観点、解釈の違いによって、そのアーキテクチャの選択がよいのかどうか、様々だと思います。

時と場所が違えば、私はTCAの使用をやめて、MVVMに舞い戻るということが無きにしもあらずですから。
(現在、ぼくは複数の仕事をしていて、ある会社では、MVVMでバリバリViewModelを書きまくっています)

そもそも小中規模なアプリであれば、TCAを採用するのは、「大げさすぎるかなぁ」 と思います。

本記事では、MVVMとTCAのコードを比較しながら、MVVMをやめて、TCAを採用するメリット/デメリットについて、詳しく解説します。

実際に、あなたの現場で、TCAを導入することが本当によいとは限らないので、この記事に書いてある内容を鵜呑みにしないで、よく考えてアーキテクチャを採用することをおすすめします。

もしこの記事が、同じように MVVMから別のアーキテクチャに移行を考えている人 の参考になれば幸いです。

本記事で説明するコードは、以下のリポジトリに公開しています。
https://github.com/karamage/swiftui-with-mvvm-to-tca-counter

「The Composable Architecture(TCA)」を採用する「理由」

本記事は、SwiftUIでMVVMをやめて「The Composable Architecture(TCA)」を採用する 「理由」を 説明します。

一方で、TCAの詳しい使い方の説明は、省略させていただきます。
TCAの使い方や詳細な説明が知りたい方は、以下の記事やドキュメントが参考になりましたので、そちらを御覧ください。

宣言的UIの登場で、UIのコンポーネント化(部品化)がすすむ

SwiftUIの登場で、状態管理とデータフローがより重要 になってきています。

なぜかというと、SwiftUIで、UIの コンポーネント化(部品化)が容易 になるためです。

lego_block.png

SwiftUI/Jetpack Compose/ReactNative/Flutterなどの、宣言的UIを使うと、コンポーネントをより簡潔に部品化しやすくなります。

しかしながら、UIの部品化が進むと、今度は、その 部品を組み合わせて画面を構成する(Compose) という作業が必要になってきます。

それはあたかも、レゴブロックで、お城を組み上げる作業に似ています。

部品化されたUIを、組み立てやすいアーキテクチャが求められている

ですので、宣言的UI時代は、Composableな(部品を組み立て可能な)アーキテクチャ が求められています。

block_asobi_boy.png

つまり、「レゴブロックのような」「UIの部品化がしやすい」「その部品を組み合わせやすい」アーキテクチャ であれば、宣言的UIのメリットを 最大限享受できる ということです。

MVVMは、Composableではない

モバイル開発で、MVVMは デファクトスタンダード 的なアーキテクチャです。

しかし、MVVMは、Composableなアーキテクチャではありません。
(MVVMが「Composable」ではない理由は、後述します)

pose_puzzle_kamiawanai_business.png

つまり、MVVMを採用しても、UIがコンポーネント化しやすくなるわけでも、コンポーネントを組み合わせることが容易になるわけでもありません。

MVVMにおいて、ViewModelの状態管理に
@Environment、@EnvironmentObject、@StateObject, @ObservedObjectなど、
PropertyWrapperの使い分けるとともに、データフローがかなり複雑になっていることに気がつくことがあります。

特に、コンポーネント階層の下流にコンポーネントをどんどん埋め込んでいくと、より複雑になります。

ViewModelの設計と、コンポーネントの設計が合わなくなり、コンポーネント設計とViewModelのモデリングに頭を悩ませることが多くなります。

コンポーネントが増えれば増えるほど、さまざまな状況下で起こりうる状態や変化が増えます。

状態が増えれば増えるほど、状態変化トリガーや状態監視の管理も難しくなります。

どのコンポーネントが状態を保持し、その状態変更トリガーがどこから行われるかということが、コードを一見しただけではわからなくなります。

このため、予期せぬ事態のデバッグに長時間を要し、処理しきれない問題が多数発生する可能性があります。

つまり、コンポーネント化が進めば進むほど、ViewModelの状態管理は複雑になり、手に負えない状態になりやすいのです。

ですので、ぼくは、宣言的UIにはMVVMは合わないと考えて、採用を見送りました。

(※もちろんMVVMのままでも見通しの良いコードを保つことが可能だと思います)

そこで登場するのが、「The Composable Architecture(TCA)」です。

TCAは名前の通り「Composable」なアーキテクチャ です。

「The Composable Architecture(TCA)」とは

「The Composable Architecture (TCA)」 は、一言でいうと、

「SwiftUI版のRedux」 です。

Reduxは、Flux のアーキテクチャを、副作用のない純粋関数によって実現するアーキテクチャー であり、ライブラリです。

Reduxは、同じく宣言的UIのReactで使われていて、最も普及率が高いです。

network_blockchain_transaction.png

TCAを使うと、「副作用のない純粋関数」 を用いて 「人間が理解しやすい単方向の予測可能な状態変化のフロー」”でしか” コードを書けなくなります。
上記の”縛り”(レールの上)でしか、コードを書くことしかできないので、 「複雑怪奇で難解なコード」を生み出すリスクが大幅に下がり ます。

TCAは、「状態管理、Composable、テスト」 に重点を置いています。

「The Composable Architecture」を直訳すると
「複数の要素や部品などを結合して、構成や組み立てが可能なアーキテクチャ」
という意味になります。

pose_puzzle_kumiawaseru.png

「Composable」は、 「構成(しやすい), 組み立て(しやすい)」 という意味です。

言い換えると、

TCAは肥大化した一枚岩(モノシリック)なコードをコンポーネントとして分割することで、安全かつ迅速に分割し、見直しを可能とするアーキテクチャー

と言えます。

TCAは、Point-FreeのBrandon WilliamsとStephen Celisの二人によって開発されました。彼らは、関数型プログラミングとSwiftの開発に関する情報を提供する多くの動画を公開しています。

なぜ「The Composable Architecture(TCA)」採用するのか?

TCAは、宣言的UI時代に即したComposableなアーキテクチャです。
MVVMでは解決できない問題を解決してくれます。

以下に、TCAのメリット/デメリットをまとめます。

TCAのメリット

golf_uchippanashi_man.png

  • 宣言的UIにFitしたComposableなアーキテクチャを導入できる
  • 処理やデータの流れがシンプルになる
    • 異なるコンポーネントを通過するデータの流れが明確に定義され、一方向である。これは、コードや処理の理解を容易にする。
  • 大規模アプリになっても、コードが スケールする。(容易に分割できる)
  • ビジネスロジックの切り出しが容易
  • ロジック(reducer)やstateを合成できる。状態管理とロジックを組み合わせることが容易。
  • テストが書きやすい

TCAのデメリット

slump_bad_man_study.png

  • 学習コストが高い
    • かなり習得難易度が高いと思います。(Reduxよりも高い印象)
    • チームに一人もTCAに詳しい人がいない状態で、いきなり導入すると混乱が生まれるのは必至なので止めたほうがよさそうです。
    • コンポーネント階層が深くなると、Storeの設計や、Store情報の受け渡し方法に頭を悩ませる
      • Storeの情報(環境情報なども)をProps渡しするのが煩雑になる
      • Storeの分割をどうするか悩みどころ。
      • Sampleコードが、モジュールの分割の仕方など、ちょっとやりすぎ感がある。
  • TCAを作成しているPointFree社を全面的に信用して良いのか、考えてしまう(※個人の感想です。大きな企業のバックアップがあると安心なのですが…)
    • もし、PointFree社のメインの二人がいなくなってしまったりすることを考えると、正直、不安に感じました。(あくまで私の主観です)
      • いっぽうで、メインの二人の技術力や動画やドキュメントの充実ぶりは信頼できると思います。
  • できて日が浅い。(Ver 1.0に達していない)
    • 変更のキャッチアップが大変そう
    • プロダクションに使うにはまだ早い印象

SwiftUIにMVVMは、なぜ合わないのか

SwiftUIで、MVVMを使うデメリットを以下に挙げてみます。

  • Composableではない

    • 宣言的UIでUIの部品化が進むと、コンポーネント間の接続の問題(状態管理とその状態をどうやって運ぶか)が発生する
      • MVVMは、コンポーネント間の接続の問題を解決するアーキテクチャではない
      • コンポーネント(UIの部品化)の粒度に対してViewModelの粒度が合わなくなる
  • 異なるコンポーネントを通過するデータフローが明確に定義されず、フローの方向がぐちゃぐちゃになる。これは、コードの理解を難しくする。

    • PropertyWrapperの種類が多すぎて、ViewModelをどこに管理して、どうやって運ぶかという問題に、いちいち頭を悩ませる必要がある。
      • 大規模開発において、各人のPropertyWrapperの判断基準を統一するのは難しい
    • ViewModelの無秩序化によって、コードが複雑になりスケールしない。
    • ViewModelとModelのやりとりが煩雑になる。

MVVMとTCAのコードの比較

おそらく文章だけでは、MVVMのどこが駄目で、TCAのどこが良いのか伝わらないと思うので、
具体的に、MVVMとTCAのコードを比較しながら、MVVMの悪いところとTCAの良いところを解説します。

本記事で説明するコードは、以下のリポジトリに公開しています。
https://github.com/karamage/swiftui-with-mvvm-to-tca-counter

最初の例として、以下のようなシンプルなカウンターを作るとします。

Simple Counter Screen Shot 2022-04-23 at 10.33.30.png

・「+」を押せば、1カウントアップ
・「-」を押せば、1カウントダウン

Simple Counter(MVVM版)

これをMVVMで実装すると、以下のようなコードになります。

Model

まずは、Couterモデルを作成します。
countのInt値を保持して、incrementとdecrementメソッドを実装しています。

Counter.swift
struct Counter {
    var count = 0

    mutating func increment() {
        self.count += 1
    }

    mutating func decrement() {
        self.count -= 1
    }
}

ViewModel

続いて、ViewとModelの間を橋渡しするViewModelを作成します。
ObservableObjectに準拠したクラスをViewModelとして作成します。

CounterViewModel.swift
import SwiftUI

class CounterViewModel:ObservableObject {
    @Published var counter = Counter()

    var count: Int {
        return counter.count
    }

    func increment() {
        counter.increment()
    }
    
    func decrement() {
        counter.decrement()
    }
}

View

Viewでは、ViewModelを@StateObjectで状態管理&購読します。

CounterView_MVVM.swift
import SwiftUI

struct CounterView_MVVM: View {
    @StateObject private var counterViewModel = CounterViewModel()
    var label: String
    
    var body: some View {
        HStack {
            Text("\(label):")
                .padding()
                .font(.subheadline)
            Button("-") { counterViewModel.decrement() }
            Text("\(counterViewModel.count)").font(.body.monospacedDigit())
            Button("+") { counterViewModel.increment() }
        }
    }
}

struct CounterView_MVVM_Previews: PreviewProvider {
    static var previews: some View {
        CounterView_MVVM(label: "Counter")
    }
}

デモ画面

import SwiftUI

struct SimpleCounterPage_MVVM: View {
    private let readMe = "Single Counter with MVVM"
    var body: some View {
        Form {
            Section(header: Text(readMe)) {
                CounterView_MVVM(label:  "Counter")
                    .buttonStyle(.borderless)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
        .navigationBarTitle("SimpleCounter")
    }
}

struct SimpleCounterPage_MVVM_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            SimpleCounterPage_MVVM()
        }
    }
}

Simple Counter Screen Shot 2022-04-23 at 10.33.30.png

いい感じに実装できました。
(そもそも「ViewModelの存在って意味ないやん」ってツッコミもあるかと思います。そのことについては、前回の記事に詳しく書きましたので、合わせて御覧ください)

Simple Counter(TCA版)

続いて、同じことをTCAで実装してみます。

Store

まずは、Storeを実装します。
CounterStateでInt値を保持して、increment/decrementのアクションを定義しています。

CounterStore.swift
import ComposableArchitecture

struct CounterState: Equatable {
    var count = 0
}

enum CounterAction: Equatable {
    case decrementButtonTapped
    case incrementButtonTapped
}

struct CounterEnvironment {}

let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in
    switch action {
    case .decrementButtonTapped:
        state.count -= 1
        return .none
    case .incrementButtonTapped:
        state.count += 1
        return .none
    }
}

View

Viewは、Storeの状態を参照したり、Actionを呼び出したりします。

CounterView
import ComposableArchitecture
import SwiftUI

struct CounterView: View {
    let store: Store<CounterState, CounterAction>
    var label: String
    
    var body: some View {
        WithViewStore(self.store) { viewStore in
            HStack {
                Text("\(label):")
                    .padding()
                    .font(.subheadline)
                Button("-") { viewStore.send(.decrementButtonTapped)}
                Text("\(viewStore.count)").font(.body.monospacedDigit())
                Button("+") { viewStore.send(.incrementButtonTapped) }
            }
        }
    }
}

デモ画面

import ComposableArchitecture
import SwiftUI

struct SimpleCounterPage: View {
    private let readMe = "Single Counter with TCA"
    var body: some View {
        Form {
            Section(header: Text(readMe)) {
                CounterView(
                    store: Store(
                        initialState: CounterState(),
                        reducer: counterReducer,
                        environment: CounterEnvironment()
                    ),
                    label:  "Counter"
                )
                    .buttonStyle(.borderless)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
        .navigationBarTitle("SimpleCounter")
    }
}

struct SimpleCounterPage_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            SimpleCounterPage()
        }
    }
}

Screen Shot 2022-04-23 at 10.52.28.png

TCAもいい感じにできました。

Simple CounterのMVVMとTCAを見比べる

正直、MVVMでもTCAでも 「どちらでも大差ないな」 と思った方、たしかにそうです。
MVVMとTCAの違いは、「Model + ViewModel」だった部分が、「Store」に変わっただけのように見えます。

このように、シンプルなアプリでは、TCAを採用しても大した違いはありません。

だがしかし、ここからが本題です。

恐怖の仕様追加

「Simple Counter」を完成して安心していた私に、客先から仕様追加が言い渡されました。

man_55.png

「Counterを、もう一個追加してほしいんだよねー」
「もう一個追加するくらい簡単でしょ?」
「ただ同じものを追加するんじゃ面白くないから、もう一つのカウンターはランダムな値をカウントアップしてよ」

Two MVVM Screen Shot 2022-04-23 at 10.52.06.png

TwoCounter仕様

  • 2つのカウンター「Counter」「Random Counter」を表示する。
  • 「Counter」は、1ずつインクリメント/デクリメントする
  • 「Random Counter」は、押されるたびに1...10のランダムな値をインクリメント/デクリメントする

ぼくは、ちょっと悩みましたが、「簡単ですよ!」と答えて、実装に取り掛かりました。
これを読んでいるあなたも、どのように変更するべきか考えてみてください。

(※以下のMVVMの変更は、問題のある例として載せています)

TwoCounter(MVVM版)

Model

まずは「ランダムにカウントする」というロジックをModelに実装しました。

Counter.swift
struct Counter {
    var count = 0

    mutating func increment() {
        self.count += 1
    }

    mutating func decrement() {
        self.count -= 1
    }
    
+   mutating func incrementRandom10() {
+       self.count += random10()
+   }
+
+   mutating func decrementRandom10() {
+       self.count -= random10()
+   }
+    
+   private func random10() -> Int {
+       Int.random(in: 1 ... 10)
+   }
}

ViewModel

橋渡しするViewModelにも、「ランダムにカウントする」メソッドを追加しました。

CounterViewModel.swift
import SwiftUI

class CounterViewModel:ObservableObject {
    @Published var counter = Counter()

    var count: Int {
        return counter.count
    }

    func increment() {
        counter.increment()
    }
    
    func decrement() {
        counter.decrement()
    }
    
+   func incrementRandom10() {
+       counter.incrementRandom10()
+   }
+    
+   func decrementRandom10() {
+       counter.decrementRandom10()
+   }
}

View

続いて、RandomCounterのViewを新規に作りました。
このRandomCounterViewでは、「incrementRandom10」「decrementRandom10」の呼び出しを行うようにしています。

RandomCounterView_MVVM.swift
+ import SwiftUI
+
+ struct RandomCounterView_MVVM: View {
+    @StateObject private var counterViewModel = CounterViewModel()
+    var label: String
+    
+    var body: some View {
+        HStack {
+            Text("\(label):")
+                .padding()
+                .font(.subheadline)
+            Button("-") { counterViewModel.decrementRandom10() }
+            Text("\(counterViewModel.count)").font(.body.monospacedDigit())
+            Button("+") { counterViewModel.incrementRandom10() }
+        }
+    }
+ }
+
+ struct RandomCounterView_MVVM_Previews: PreviewProvider {
+    static var previews: some View {
+        RandomCounterView_MVVM(label: "Random Counter")
+    }
+ }

デモPage

最後に、2つのカウンターをページに配置して、完成です。

TwoCounterPage_MVVM.swift
import SwiftUI

struct TwoCounterPage_MVVM: View {
    private let readMe = "Two Counter with MVVM"
    var body: some View {
        Form {
            Section(header: Text(readMe)) {
                VStack {
                    CounterView_MVVM(label:  "Counter")
                        .buttonStyle(.borderless)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                    
+                   RandomCounterView_MVVM(label:  "Random Counter")
+                       .buttonStyle(.borderless)
+                       .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            }
        }
        .navigationBarTitle("TwoCounter")
    }
}

struct TwoCounterPage_MVVM_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            TwoCounterPage_MVVM()
        }
    }
}

やったー、ばっちり、うまく動作しています!

Two MVVM Screen Shot 2022-04-23 at 10.52.06.png

MVVMの問題点

以上の変更は、一見すると良い変更に思えますが、いくつかの問題をコードに紛れ込ませています。

figure_break_hammer.png

CounterViewのコンポーネント化を破壊している

最初の問題は、「CounterView」は、再利用可能なコンポーネント だったはずなのに、新たに 「RandomCounterView」という亜種 を生み出してしまったことです。

これで、「CounterView」は再利用できないコンポーネントに成り下がったので、以降の仕様変更では、「CounterView」の亜種を生み出し続けることになります。

また、CounterViewModelに

  • 「1ずつカウントアップする」
  • 「ランダムにカウントアップする」

という Viewに依存したロジックが、混在 しています。
これは、ビジネスロジックの切り出しに失敗しています。

ViewModelの性質上、ViewModelには Viewに依存した状態とロジックが一つのクラスに切り出されます。
これは、画面と1:1のViewModelを作成するという意味では上手くいくのですが、機能/コンポーネント単位にロジックを切り出したい場面にはフィットしません。
UIの部品化が進めば進むほど、コンポーネントの粒度に対してViewModelの粒度が合っていない 状況が頻発するようになります。

mark_business_vuca_complexity.png

  • 「CouterViewでは、1ずつカウントアップする」
  • 「RandomCouterViewでは、ランダムにカウントアップする」

という処理のフローが、暗黙知として隠され、ViewModelを一見しただけわからない状態になっています。

本来であれば、ViewとViewModelの関係は、1:1 が望ましいのですが、2:1 の関係になっています。(そもそもViewModelは画面単位で作成するものなのかコンポーネント単位で作成するものなのか?というところも曖昧です)

関係性が、3,4...と増えていくと、複雑で可読性の低いコードが簡単に書けてしまいます。

言い換えると、ViewModelにロジックを書くということは、UI固有のロジック を書くということに他なりません。

つまり、Viewからビジネスロジックを切り出すために、ViewModel(Model)にロジックを切り出すことは、UI固有のロジックを書くことになってしまい、ロジックの再利用性/変更容易性を妨げますし、 UIロジックとドメインロジックの境界線が曖昧 になります。

RandomCounterのViewから 「ランダムにカウントアップする」というビジネスロジックがCounterViewModelにincrementRandom というメソッドに実装されているのですが、じゃあ、別画面(別コンポーネント)からも「ランダムにカウントアップする」という機能が必要になった場合、AnotherViewModelにもincrementRandomメソッドを実装することになります。結果としてコピペロジックが散らばってViewModelがカオスな状況へ一歩踏み出してしまいます。

再利用されるビジネスロジックは、ドメインレイヤにカプセル化する必要がある と考えています。

TCAならば、Reducerにロジックが分離されており、ロジックの独立性が保たれています。よって任意の画面やコンポーネントでそのロジックが必要になったときに再利用がしやすいのです。

以上が、ぼくが 「MVVMがComposableではない」 と言っている理由です。

宣言的UIを使ってUIのコンポーネント化がやりやすくなったはずなのに、なぜかコンポーネントを組み合わせて画面を構成することがやりにくくなってしまっている のです。

これは、かなりもったいない状態だと思います。

追記(2022/04/25)

この記事の補足解説として、MVVMのままでもComposableな実装を保つやり方が、りずさんのブログに説明されています。
りずさんのおっしゃっているTCAへの懸念も、もっともだと思います。
合わせてご覧ください。

SwiftUIでのMVVM例 - 本当にMVVMはComposableではないのか
https://tech.caph.jp/swiftui-mvvm-is-composable/

その他のご意見

TwoCounter(TCA版)

一方、TCAでは、CounterViewを再利用するかたちで、状態管理とロジックを切り分けて、最小限の変更で実装できます。

まずは、CounterStoreに、「ランダムにカウントアップする」というロジックを切り出すために「randomCounterReducer」を作成します。

CounterStore.swift
import ComposableArchitecture

struct CounterState: Equatable {
    var count = 0
}

enum CounterAction: Equatable {
    case decrementButtonTapped
    case incrementButtonTapped
}

struct CounterEnvironment {}

let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in
    switch action {
    case .decrementButtonTapped:
        state.count -= 1
        return .none
    case .incrementButtonTapped:
        state.count += 1
        return .none
    }
}

+ let randomCounterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, _ in
+    switch action {
+    case .decrementButtonTapped:
+        state.count -= random10()
+        return .none
+    case .incrementButtonTapped:
+        state.count += random10()
+        return .none
+    }
+ }
+
+ private func random10() -> Int {
+    Int.random(in: 1 ... 10)
+ }

続いて、2つのカウンターのStoreを分離して管理するために、新たにCountersStoreを作成します。

CountersStore.swift
import ComposableArchitecture
import SwiftUI

struct CountersState: Equatable {
    var counter1 = CounterState()
    var counter2 = CounterState()
}

enum CountersAction {
    case counter1(CounterAction)
    case counter2(CounterAction)
}

struct CountersEnvironment {}

let countersReducer = Reducer<CountersState, CountersAction, CountersEnvironment>
    .combine(
        counterReducer.pullback(
            state: \CountersState.counter1,
            action: /CountersAction.counter1,
            environment: { _ in CounterEnvironment() }
        ),
        randomCounterReducer.pullback(
            state: \CountersState.counter2,
            action: /CountersAction.counter2,
            environment: { _ in CounterEnvironment() }
        )
    )

View

TCAでは、Viewの変更も新規に作成も行いません。
既存のCounterViewをそのまま再利用します。

デモページ

最後に、2つのCounterViewを並べて、完成です。

TwoCounterPage.swift
import ComposableArchitecture
import SwiftUI

struct TwoCounterPage: View {
    private let readMe = "Two Counter with TCA"
    let store: Store<CountersState, CountersAction>
    var body: some View {
        Form {
            Section(header: Text(readMe)) {
                VStack {
                    CounterView(
                        store: self.store.scope(state: \.counter1, action: CountersAction.counter1)
                        ,
                        label:  "Counter"
                    )
                        .buttonStyle(.borderless)
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                    
+                    CounterView(
+                        store: self.store.scope(state: \.counter2, action: CountersAction.counter2),
+                        label:  "Random Counter"
+                    )
+                        .buttonStyle(.borderless)
+                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                }
            }
        }
        .navigationBarTitle("TwoCounter")
    }
}

struct TwoCounterPage_Previews: PreviewProvider {
    static var previews: some View {
        NavigationView {
            TwoCounterPage(
                store: Store(
                    initialState: CountersState(),
                    reducer: countersReducer,
                    environment: CountersEnvironment()
                    )
            )
        }
    }
}

どうでしょう?
TCAであれば、簡単にCounterViewを再利用できる ことがわかると思います。
このようにTCAではロジック(reducer)やstateを合成できます。状態管理とロジックを組み合わせてViewに渡すことが容易に可能です。

まとめ

TCAは、 「複数の要素や部品などを結合して、構成や組み立てが可能な、Composableなアーキテクチャ」 です。
SwiftUIなどの宣言的UIでは、UIのコンポーネント化が容易になります。
よって、コンポーネント化が進めば進むほど、SwiftUIとTCAの相性は抜群によいです。

ですので、ぼくは、SwiftUIには、TCAを採用することをおすすめします。

しかしながら、TCAの学習コストの高さや、開発体制に不安 があるのもわかりますので、様子見する気持ちもまたわかります。(ぼくもTCAを導入する際に、長い間、二の足を踏んでいました)

SNSでも、この点にリスクを感じている人がいました。
その気持ちわかります!

TCAは、関数型プログラミングに触れてこなかった人には理解するのが難しいですし、モチベーション的にもキツイと思います。

ですが、まずはじめに、TCAをちょっとさわってみるだけでもよい のではないかなと思います。

食わず嫌いになっているだけかもしれませんし、ちょっとやって駄目だったらやめればよいだけの話です。

また、繰り返しになりますが、アーキテクチャを採用する際、そのアプリに合ったアーキテクチャを ”よく考えて” 採用することをオススメします。

この記事は、「TCAが絶対の正解だ」と言いたいわけではありません。

アメリカのことわざに 「ハンマーしか持っていなければ、すべてが釘のように見える」 というものがあります。
ひとつの手段に囚われると、 その手段が目的化してしまう ことへの戒めとして語られることが多い言葉です。

MVVMだけにとらわれると、MVVMすることが目的化しやすいのです。

同じことは、TCAにも言えます。

よって、複数のアーキテクチャパターンを理解し、選択肢を持った状態 で、アプリに合ったものを選ぶことが大事です。

仮に考えた結果が 「やっぱりMVVMを採用しよう」 であったとしても、それはそれで良いと考えて、その考えを否定するつもりは一切ありません。

ご理解いただけるとうれしいです。

最後までお読みいただき、ありがとうございました。

おまけ(※2022/04/24追記)

TCAの便利さを、もう少し説明したいため、追記します。

さらなる仕様変更

先程作ったTwoCounterに、またまた客先から仕様変更が飛んできました。

man_55.png

「Random Counterを押したときに、Counterもカウントアップしてほしいんだよねー」
「Counterを押したときは、今のままでよいからさ」
「ちょっと動作変えるだけだし簡単でしょ?」

Two MVVM Screen Shot 2022-04-23 at 10.52.06.png

TwoCounter仕様(Ver.2.0)

  • 2つのカウンター「Counter」「Random Counter」を表示する。
  • 「Counter」は、1ずつインクリメント/デクリメントする
  • 「Random Counter」は、押されるたびに1...10のランダムな値をインクリメント/デクリメントする、かつ「Counter」を、1ずつインクリメント/デクリメントする

TwoCounter Ver.2.0 (TCA版)

この変更をMVVMでやろうとすると、かなり大変で コードが複雑になるのは必至 です。
しかし、TCAで変更するなら、ちょー簡単です。

まずは、以下のExtentionをコピペして貼り付けます。
下記のresendingメソッドは、「あるReducerのAction実行後に、別のReducerのActionを実行したいとき」 に便利ですので、メモしておきましょう。
(※もちろん、resendingを使わずに、上位Storeで処理するのでもよいです)

resendingは、ここから拝借
https://forums.swift.org/t/send-an-action-from-one-reducer-to-another/36614/8

extension Reducer {
  func resending<Value>(
    _ extract: @escaping (Action) -> Value?,
    to embed: @escaping (Value) -> Action
  ) -> Self {
    .combine(
      self,
      .init { _, action, _ in
        if let value = extract(action) {
          return Effect(value: embed(value))
        } else {
          return .none
        }
      }
    )
  }

  func resending<Value>(
    _ `case`: CasePath<Action, Value>,
    to other: CasePath<Action, Value>
  ) -> Self {
    resending(`case`.extract(from:), to: other.embed(_:))
  }

  func resending<Value>(
    _ `case`: CasePath<Action, Value>,
    to other: @escaping (Value) -> Action
  ) -> Self {
    resending(`case`.extract(from:), to: other)
  }

  func resending<Value>(
    _ extract: @escaping (Action) -> Value?,
    to other: CasePath<Action, Value>
  ) -> Self {
    resending(extract, to: other.embed(_:))
  }
}

あとは、reducerに以下の 一行を加えれば完成 です!
TCAって便利でスゴイですね!

CountersStore.swift
let countersReducer = Reducer<CountersState, CountersAction, CountersEnvironment>
    .combine(
        counterReducer.pullback(
            state: \CountersState.counter1,
            action: /CountersAction.counter1,
            environment: { _ in CounterEnvironment() }
        ),
        randomCounterReducer.pullback(
            state: \CountersState.counter2,
            action: /CountersAction.counter2,
            environment: { _ in CounterEnvironment() }
        )
    )
    // RandomCounterのReducerのAction実行後に、CounterのActionを実行したい場合
+   .resending(/CountersAction.counter2, to: /CountersAction.counter1)
169
118
11

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
169
118