SwiftUIでのMVVM例 - 本当にMVVMはComposableではないのか

Photo by Vika Strawberrika / Unsplash

Qiitaに【SwiftUI】なぜ、MVVMをやめて、The Composable Architecture(TCA)を採用するのか?という記事が上がっていました。

趣旨は以下のようです。

  • MVVMはComposableではない
  • TCAはComposableである
  • MVVMよりTCAがおすすめ

本当にMVVMはComposableではないのでしょうか?この記事のMVVMのコードにはいろいろ問題があるので、修正してSwiftUIでのMVVMのコード例を作成してみましょう。

当該記事のコードの問題点

当該記事のMVVMのコードはCounterViewに対するViewModelをRandomCounterViewのViewModelに流用していることです。

RandomCounterViewにはRandomCounterViewModelを作成するのが一般的な実装です。

もう一つの実装方法はモデルレベルで抽象化してしまう方法です。この記事ではこの方法を採用します。

TCA版ではCounterViewをCounter/RandomCounterに利用しているのでMVVMでもそのように実装してみましょう。

修正版MVVM

修正してみましょう。

まずモデルです。

protocol Countable {
    var count: Int { get }

    func increment()
    func decrement()
}

class Counter: Countable, ObservableObject {
    @Published var count: Int = 0

    func increment() {
        count += 1
    }

    func decrement() {
        count -= 1
    }
}

class RandomCounter: Countable, ObservableObject {
    @Published var count: Int = 0

    func increment() {
        count = Int.random(in: 1...10)
    }

    func decrement() {
        count = Int.random(in: 1...10)
    }
}

CounterとRandomCounterはモデルにおける状態を保持するのでクラスとして実装しています。この状態の変化をViewModelが受け取るためにObservableObjectに適合させています。

そしてCountableで抽象化しています。

次はこのモデルを利用するViewModelです。

class CounterViewModel: ObservableObject {
    init<C>(counter: C) where C: Countable, C: ObservableObject {
        switch counter {
        case is Counter:
            self.label = "Counter"
        case is RandomCounter:
            self.label = "Random Counter"
        default:
            self.label = "Unknown"
        }

        self.counter = counter
        self.objectWillChange = counter.objectWillChange.map { _ in () }.eraseToAnyPublisher()
    }

    private let counter: Countable

    let objectWillChange: AnyPublisher<(), Never>

    let buttonStyle = BorderlessButtonStyle()

    let label: String

    let labelFont = Font.subheadline

    var count: String { String(counter.count) }

    let countFont = Font.body.monospacedDigit()

    func increment() { counter.increment() }

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

objectWillChangeをcounter.objectWillChangeに差し替えています。複数のモデルを監視する場合には独自のSubjectで受けることになるでしょう。

フォントやスタイルも保持しています。よくViewModelはUIKitやSwiftUIをimportしないようにと言われますが、ViewModelはViewから状態とロジックを切り離したものなので、参照しても問題ないのです。これについてはModel View ViewModel - Wikipediaでわかりやすく説明されていました。

そしてこのViewModelを利用するViewです。

struct CounterView: View {
    @EnvironmentObject var viewModel: CounterViewModel

    var body: some View {
        HStack {
            Text("\(viewModel.label):")
            .padding()
            .font(viewModel.labelFont)

            Button("-") { viewModel.decrement() }

            Text(viewModel.count).font(viewModel.countFont)

            Button("+") { viewModel.increment() }
        }
        .buttonStyle(viewModel.buttonStyle)
    }
}

EnvironmentObjectとしてViewModelを受けるようになっています。

CounterPageViewは以下のようにないります。

struct CounterPageView: View {
    var viewModel = CounterPageViewModel()

    var body: some View {
        Form {
            Section(header: Text(viewModel.readMe)) {
                VStack {
                    CounterView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .environmentObject(CounterViewModel(counter: viewModel.firstCounter))

                    CounterView()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                    .environmentObject(CounterViewModel(counter: viewModel.secondCounter))
                }
            }
        }
        .navigationBarTitle("TwoCounter")
    }
}

いかがでしょうか。これでComposableになったと言えるのではないでしょうか。

MVVMがComposableでないのではなく、実装がComposableではなかったのです。なぜでしょうか。それはモデルの実装が原因です。モデルレイヤーレベルで設計ができていれば、ViewModelはそれを抽象化して利用することができます。件の記事ではTCAのReducer、つまりモデルレイヤーに相当する実装がCounte/RandomCounterで分離されていますが、MVVMの例ではCounterにすべての機能を詰め込み十分設計されていない状態です。

モデリングができているか否かでViewModelの出来が左右され、またViewModelの実装はViewに影響を与えてしまうのです。

アーキテクチャパターンに注力することも大切ですが、(もしかしたらそれ以上に)モデリングに注力することが必要です。

TCAへの懸念

TCAのStateは一般的にモデルレベルのStateです。実は同じ関数型アーキテクチャパターンであるReduxはStateにUIのStateをもたせることが多いのですが、それは結局Viewの状態はどこかで生成・保持しなくてはならないからです。TCAでもStateにUI Stateを保持するか、View内で生成する必要が出てきます。そうするとViewに表示ロジックが含まれViewが重くなっていきます。ちなみにStateにUI Stateを持たせるのは正規化が必要で結構面倒だったりします。

また、この記事のMVVMの例ではEnvironmentObjecが活用されていますが、TCAではViewごとのEnvironmentObjecやEnvironmentを活用することができないので、TCAのenvironmentを利用することになりSwiftUIから離れていってしまいます。

そして私が最も重要だと考えているのが、先日上げた記事である宣言的UIフレームワークのアーキテクチャパターン考察に書いたように

よって関数型アーキテクチャパターンを採用する場合にはプロジェクトメンバーが
関数型プログラミング
関数型モデリング
に精通する必要があります。これは結構ハードルが高いと思われます。

という点です。

TCAを採用する場合にはこれらの点に注意して採用を検討されると良いと思います。

Ryoichi Izumita

Ryoichi Izumita