iOS
MVVM
Swift
SwiftUI
TheComposableArchitecture
44
どのような問題がありますか?

投稿日

更新日

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

はじめに

先月、 【「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がコンポーネント化しやすくなるわけでも、コンポーネントを組み合わせることが容易になるわけでもありません。

ですので、ぼくは、宣言的UIには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なアーキテクチャを導入できる
  • 処理やデータの流れがシンプルになる
    • 異なるコンポーネントを通過するデータの流れが明確に定義され、一方向である。これは、コードや処理の理解を容易にする。
  • 大規模アプリになっても、コードが スケールする。(容易に分割できる)
  • テストが書きやすい

TCAのデメリット

slump_bad_man_study.png

  • 学習コストが高い
  • PointFree社という、よくわからん人たちが作っていて不安です(※個人の感想です。ぼくが知らないだけかもしれません)
    • 私は、PointFree社が何をしている会社なのか知りませんし、メインの二人がいなくなってしまったりすることを考えると、正直、不安に感じました。
  • できて日が浅い。(Ver 1.0に達していない)
    • 変更のキャッチアップが大変そう

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

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

  • Composableではない

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

    • 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に依存したロジックが、混在 しています。

mark_business_vuca_complexity.png

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

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

本来であれば、ViewとViewModelの関係は、1:1 が望ましいのですが、2:1 の関係になっています。

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

以上が、ぼくが 「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は、 「複数の要素や部品などを結合して、構成や組み立てが可能な、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を実行したいとき」 に便利ですので、メモしておきましょう。

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)
ユーザー登録して、Qiitaをもっと便利に使ってみませんか。
  1. あなたにマッチした記事をお届けします
    ユーザーやタグをフォローすることで、あなたが興味を持つ技術分野の情報をまとめてキャッチアップできます
  2. 便利な情報をあとで効率的に読み返せます
    気に入った記事を「ストック」することで、あとからすぐに検索できます
ユーザー登録ログイン
karamage
モバイルアプリ開発が得意。「My感謝日記」など、人の心を豊かにするアプリを個人開発。TV番組でも紹介され累計20万DL突破。「エンジニアの心を整える技術」著者。「Focus Cafe」 https://focus-cafe.space を開発中
この記事は以下の記事からリンクされています
Qiita週間トレンド記事一覧からリンク
過去の2件を表示する

コメント

qiitaのトップページを見たらこの記事があって、デザインパターンについては最近考えることがあったのでswiftUIは使ったことがないどころかswiftも使ったこともないですが読んでみました。
気になったことがあるのですが、MVVMがComposableではない理由です。TwoCounterは僕だったら、Model、Viewmodelを二つに分けます。後はviewは記事でも分かれてるのでそれぞれ別のViewModelを使うようにすれば記事で書いている問題点はないように思えます。もしかしたらMVCを普段使っているのでMVVMじゃなくなっていたらすいません。

2

@takuro7021 さん

読んでいただき、ありがとうございます😊
Swift知らない人にも読んでもらえて嬉しいです。

TwoCounterは僕だったら、Model、Viewmodelを二つに分けます。

はい、分けてもよいです!的確なご意見だと思います。

後はviewは記事でも分かれてるのでそれぞれ別のViewModelを使うようにすれば記事で書いている問題点はないように思えます。

ただ、Model、Viewmodelを分けただけではRandomCouterViewを作る必要があるので、「Composableではない」という問題は残ります。
部品化されたコンポーネントを組み合わせるだけで画面を作りたいのに、それができないのです。
TwoCounterの例では、MVVMがコンポーネントの再利用を妨げている例として提示しています。
(CouterViewコンポーネントをそのまま再利用しつつ、ロジックだけを切り替えたい)

TCA版では、簡単にCouterViewコンポーネントをそのまま再利用することができています。

0

どうやらコンポーネントとComposableというのを勘違いしていたようです。自分の認識では、Model、ViewModel、Viewをセットでコンポーネントだと思ってました。そしてComposableというのは例えるならブログパーツみたいなものとして使えるというイメージでした。でも確かにTCA版だとViewを再利用しています。
となるとMVVMだと確かに難しいと思います。ジェネリクスを使うかViewmodelを引数にして使えるようにすればできなくもないですけどそこまでするならTCA使った方が楽そうです。ただ改めて読み直すとあくまでTCAだと簡単にできるように機能を提供されているだけのようにも思えますが。

1

Swiftは分からないので、それについてはなんとも言えませんが、

Model、Viewmodelを分けただけではRandomCouterViewを作る必要がある

については、
WPFでの、MVVMは、Viewの再利用は、Usercontrolなどを利用すれば、OKです。
自分も勉強になるとコードを書いてみました。

ユーザーコントロールのCounterCommonViewを、
MainViewに2つ配置し、
それぞれ、
CounterViewModel
RandomCounterViewModel
にBindingします。

また、機能の差が小さいなら、ViewModelを2つ利用しなくても、
例えば、CounterCommonViewModel にして、1つのViewModelにしても構わないと、考えます。

また、Modelも、
CounterMain
RandomCounterMain
の2つにしていますが、同様に、機能の差が小さいなら、1つのModelにまとめても構わないと考えます。

TwoCounter仕様(Ver.2.0) について
また私がこの機能を実装するなら、ViewModel上で処理しないでしょう。理由は、
・Modelで行うべき処理だと考えます。
CounterViewModelRandomCounterViewModelとが、どちらかに依存することは無い。無関係がセオリーのため。
従って、CounterMain RandomCounterMainで機能を実装します。

MVVMは、人によって考え方が異なり、従って実装の方法も異なり、何が正解かは難しいのですが、現時点の私の理解によれば、WPFでは、以上のように構築しますね。

MainView

image.png

<Window (省略)
>
    <Window.DataContext>
        <local:MainViewModel />
    </Window.DataContext>
    <Grid>
        <StackPanel Orientation="Vertical">
            <Label>TWO COUNTER WITH MVVM</Label>
            <local:CounterCommonView DataContext="{Binding CounterViewModel}" />
            <local:CounterCommonView DataContext="{Binding RandomCounterViewModel}" />
        </StackPanel>
    </Grid>
</Window>

MainViewModel

public class MainViewModel : NotifyBase
{
    public CounterViewModel CounterViewModel { get; set; } = new();
    public RandomCounterViewModel RandomCounterViewModel { get; set; } = new();
    public MainViewModel()
    {
    }
}

CounterCommonView
image.png

<UserControl (省略)>
    <Grid>
        <StackPanel Height="30" Orientation="Horizontal">
            <Label
                Width="100"
                d:Content="Count"
                Content="{Binding Label.Value}" />
            <Button
                Width="30"
                Height="30"
                Command="{Binding DecrementCommand}"
                Content="-" />
            <Label d:Content="1" Content="{Binding Count.Value}" />
            <Button
                Width="30"
                Height="30"
                Command="{Binding IncrementCommand}"
                Content="+" />
        </StackPanel>
    </Grid>
</UserControl>

ファイル構成
image.png

2

@hiro_t さん

詳細な補足説明ありがとうございます。
WPFについて、恥ずかしながら無知なのですが、
ViewModelがキレイに分割され、コンポーネントの再利用もできて、いいですね。

ありがとうございます、WPF MVVMの理解が深まりました。

0
(編集済み)

ライブラリの開発体制に信用が置けない

PointFreeの技術力は公開しているライブラリやTCAのソースコードを読めば技術力がないとは言えないと思いますし、ブログなどでも積極的に多くの情報公開していますし、issueやdiscussions、forumでも頻繁に関係者がコメントしてますし、多くの人に利用されているライブラリで下手のOSSよりも信用が置けると思うのですが、具体的にどんな理由で信用が置けないのでしょうか?
本当に自身が知らない会社ってだけで、信用ができないライブラリという烙印を押したってことではないですよね…?
個人的にはこの記事を読む限り、karamageさんがTCAを本格的に利用したことがないように見受けられたので、TCAを少し触ったことあるだけの人が有用なライブラリを根拠なくネガティブに評価しているのはちょっとどうかなと思いコメントしました(間違っていたらすみません)。

1

@akemi-mizuki さん。

PointFreeの技術力は公開しているライブラリやTCAのソースコードを読めば技術力がないとは言えない

ご指摘ありがとうございます。
はい、メインのお二人の技術力は、コードや動画を見て、その点は信用しています。
ですので、記事の方も表現を修正しました。

本当に自身が知らない会社ってだけで、信用ができないライブラリという烙印を押したってことではないですよね…?

私は、PointFree社が何をしている会社なのか知りません。
(大量の動画があるPointFreeのサイトを運営していることは知っています。これがメイン事業(?)なのでしょうか)
どこを見れば、PointFree社が信用できるとわかりますか。
教えていただけるとありがたいです。

もしメインの二人がいなくなってしまったり、この会社がなくなったりした場合のことを考えると、正直、不安に感じました。

多くの人に利用されているライブラリで下手のOSSよりも信用が置ける

「多くの人に利用されている」というのは、知りませんでした。
私は、大手アプリでのTCA採用実績を聞いたことがありません。
もし、採用しているアプリがあるのであれば、具体的に教えていただけませんか。

安心材料になると思います。

0

少しはご自身で調べたらいかがでしょうか…

もしメインの二人がいなくなってしまったり、この会社がなくなったりした場合のことを考えると、正直、不安に感じました。

個人で開発されている有名で有用なOSSなどいくらでもあるかと思いますが、そういったOSSも信用されていないんですね。
信用には絶対的な指標はないと思っていて、OSSの場合は極論ですがアウトプットから判断するしかないのかなと思いますが、karamageさんの場合、どんな企業が作っているかが最重要なんですね。
その一方で resending のコードは他のサイトからのコピペで、他人の書いたコードを信用されていて、自分が書いたコードのように見せて他人に薦めるのはすごいメンタルですね。

0
どのような問題がありますか?
あなたもコメントしてみませんか :)
ユーザー登録
すでにアカウントを持っている方はログイン
記事投稿イベント開催中
新人プログラマ応援 - みんなで新人を育てよう!
~
データに関する記事を書こう!
~
44
どのような問題がありますか?
ユーザー登録して、Qiitaをもっと便利に使ってみませんか

この機能を利用するにはログインする必要があります。ログインするとさらに下記の機能が使えます。

  1. ユーザーやタグのフォロー機能であなたにマッチした記事をお届け
  2. ストック機能で便利な情報を後から効率的に読み返せる
ユーザー登録ログイン
ストックするカテゴリー