個人開発の SwiftUI アプリのアーキテクチャを MVVM から MV にした

概要

SwiftUI Advent Calendar 2023 の 21 日目です。

最近趣味で iOSpodcast クライアントを SwiftUI で作っているのですが、やってみると podcast クライアントはアプリとしてそれなりに難しいことがわかってきました。作っているうちにどんどん状態管理が複雑になってきて、個人開発でなぜこんなにがんばりが必要なんだと思って開発が止まっていたのですが、最近 iOS 17 の登場をきっかけにアプリを全般的に書き直すことにして、同時にアーキテクチャを変えてみました。これにより構成がシンプルになって開発効率が上がり、開発を再開することができました。具体的には、

  • from: 1画面に1つ ViewModel(ObservableObject)を作り、 View から ViewModel を監視する MVVM
  • to: View から直接 Model (@Observable)を監視する MV

という変更しました。要は ViewModel を消したということです。

今まであまりモバイルアプリのアーキテクチャにこだわりがなかったのですが、アーキテクチャの変更で開発のしやすさやモチベーションに大きな影響が出てなるほど〜と思ったので、一つの事例としてこの記事で MV アーキテクチャについてまとめてみようと思います。

前提として、アプリにどのアーキテクチャが適しているかは要件や開発チームの規模などによって大きく変わると思っています。

参考

SwiftUI アプリにおける MV アーキテクチャのメリットはすでにいくつかリソースで解説されています。とくに参考になると思っているものを2つ貼っておきます。

azamsharp.com

www.youtube.com

MVVM の大変さ

MVVM でアプリを作っていて大変に感じていたのは、画面ごとに Model と ViewModel のデータを同期するコードを書かなければいけないことに尽きます。

例として、カウンターアプリを考えます。単純に考えるとカウンターの Model は以下のようにしたい...

final class CounterModel {
    static let shared: CounterModel = .init()

    var count: Int = 0

    func increment() {
        count += 1
    }
}

が、 count の更新をきっかけに View の再描画をしたいという要件が発生したとします。 Model が持つ状態の更新を View にリアルタイムに反映したいというのはかなりよくある状況のはずです。そうすると、なんらかの形で外部から Model の更新を購読できるようにしたいので、例えば以下のようになるでしょう。

final class CounterModel {
    static let shared: CounterModel = .init()
    
    var countPublisher: some Publisher<Int, Never> { countSubject }
    private var countSubject: CurrentValueSubject<Int, Never> = .init(0)

    func increment() {
        countSubject.send(countSubject.value + 1)
    }
}

ViewModel と View は以下のような感じになりそうです。

final class AScreenViewModel: ObservedObject {
    @Published var formattedCount: String = "0"

    private var cancellables: Set<AnyCancellable> = .init()
    private let counterModel: CounterModel

    init(counterModel: CounterModel) {
        self.counterModel = counterModel

        counterModel
            .countPublisher
            .sink { [weak self] in self?.formattedCount = "\($0)" }
            .store(in: &cancellables)
    }

    func increment() {
        counterModel.increment()
    }
}

struct AScreen: View {
    @StateObject private var viewModel: AScreenViewModel = .init(counterModel: CounterModel.shared)

    var body: some View {
        VStack {
            Text(viewModel.formattedCount)

            Button("Increment") {
                viewModel.increment()
            }
        }
        .navigationTitle("A")
    }
}

ここで面倒なのは CounterModelcountAScreenViewModelcount が常に同じ値であるべきなのに別のクラスのプロパティとしてそれぞれ存在するため、同期を取るコードが必要になっていることです。

        // ここで Model と ViewModel のデータを同期している
        counterModel
            .countPublisher
            .sink { [weak self] in self?.formattedCount = "\($0)" }
            .store(in: &cancellables)

今回は Combine の Publisher を使って同期を行っていますが、他の方法を取る場合でも何かしら同じようなコードは存在することになるでしょう。ここでは考慮していませんが、適切なタイミングで購読をキャンセルすることを考えるとさらに追加で処理を書く必要があります。

ここまでの例だと同期すべきプロパティが1つで、かつ画面も1つなので大した手間ではないですが、これが他の画面からも CounterModel を見るようになったり、逆に AScreenViewModel が他の Model も見るようになったりすると同期を取るコードがどんどん増えていって大変になってきます。例えば、 BScreenViewModelCScreenViewModel でも count を表示したいとなってくると毎回同じかつ、あまり本質的とはいえないコードを書く必要があるので気が滅入ってくることになります。

MV にするとどうなるか

ということアプリ開発の厳しさを味わっていた中、 iOS 17 で登場した @Observable を使って、アーキテクチャも含めてアプリを書き直してみようと思いました。大変さの根源は Model の情報を View が表示する、というフローの間に ViewModel が介在することだったので、 ViewModel を排して View から Model を直接見る、いわゆる MV アーキテクチャにしてみます。

Model の更新を View に反映できるようにしたいので、 CounterModel 自体を @Observable にします。 @Observable の仕組みにより、 Combine.PublisherAsyncStream などを使わずとも、ただのプロパティである count を View から監視することが可能です。

@Observable
final class CounterModel {
    var count: Int = 0

    func increment() {
        count += 1
    }
}

CounterModel はアプリ全体で共有したいため .environment に入れておきます。

@main
struct CounterApp: App {
    private let counterModel: CounterModel = .init()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(counterModel)
        }
    }
}

これを View から取得して利用します。

struct AScreen: View {
    @Environment(CounterModel.self) private var counterModel

    var body: some View {
        VStack {
            Text(counterModel.count, format: .number)

            Button("Increment") {
                counterModel.increment()
            }
        }
        .navigationTitle("A")
    }
}

以上です。データが Model にしか存在しないことで、アプリ全体のコードがすごくシンプルになっていることがわかります。この方法なら、他の画面が CounterModel を参照したいとなっても、 @Environment で取得してただ利用すればいいので、 MVVM と比較するとかなり手間が減ります。

@Observable はプロパティベースの監視

Model は ViewModel と違ってアプリ全体で共有されています。これは、 Model が各 View 専用に作られていない、つまり Model が View が必要としないプロパティも持ってしまっていることを意味しています。そのため、 View から直接 Model を見ることで必要ないプロパティが更新されたときにも View の body の再評価が走ってパフォーマンスの問題が出るという懸念があるかもしれません。極端な例ですが、例えば以下の Model を画面 A / B / C から @Environment を介して監視したときに、 propertyForA が更新された際に画面 A だけではなく B / C も無駄に再描画が走ってしまうのではないかということです。

final class Model {
    // 画面 A だけで使われる
    var propertyForA: Int = 0
    // 画面 B だけで使われる
    var propertyForB: Int = 0
    // 画面 C だけで使われる
    var propertyForC: Int = 0
}

実際に ObservableObject ではこの問題が存在していました。ObservableObject の更新をきっかけに SwiftUI の View が再描画されるのは、 @Published プロパティが更新されることで ObservableObjectobjectWillChange publisher が発火するためです。つまり、 @Published プロパティがどれか1つでも更新されることで ObservableObject 全体に紐づく publisher が発火するオブジェクト単位の監視となっていて、監視している ObservableObject のうち View が使っていないプロパティが更新されても View の body が再評価されてしまうことになります。どれくらいの規模のアプリになると実際にパフォーマンスの問題が出てくるのかは検証が難しいですが、避けられるなら避けたい現象です。

この問題は @Observable では解消しています。というのも @Observable では監視がプロパティ単位になっているため、 View が使っていないプロパティが更新されても body の再評価がされないようになっているためです。これにより、 Model を @Environment でいろいろな View から監視するという一見すごく乱暴そうなことをしても必要なタイミングにのみ再描画が走ることになります(と思っているのですが、もし勘違いしていたら教えてください)。 ObservableObject を使っている場合は、無駄な再描画が走らないように Model から View が使うデータのみを抽出するという意味でも ViewModel を挟むメリットがありましたが、 @Observable ではその点に関しては Model を View から直接監視しても問題がなくなったと思っています。

プレゼンテーションロジックをどうするか

ViewModel を使いたくなる理由の一つにプレゼンテーションロジックを置きたいというものがあります。 View に紐づくロジックが複雑な場合、単体テストを書いたり、 View を煩雑にしないために ViewModel にまとめたくなってきます。

例えば、 AScreen では CounterModelcount をそのまま表示していましたが、 BScreen では countFizzBuzz で表示したいとします。このロジックは BScreen にしか関係ないので CounterModel には書きたくないですが、 View に直接書くにしては複雑という考えもありそうだし、単体テストがやりづらくなります。

struct BScreen: View {
    @Environment(CounterModel.self) private var counterModel

    var body: some View {
        VStack {
            Text(counterModel.count, format: .number)

            Button("Increment") {
                counterModel.increment()
            }
        }
        .navigationTitle("B")
    }

    private var formattedCount: String {
        if counterModel.count == 0 { return "0" }

        return switch (counterModel.count % 5, counterModel.count % 3) {
        case (0, 0):
            "FizzBuzz"
        case (0, _):
            "Fizz"
        case (_, 0):
            "Buzz"
        default:
            "\(counterModel.count)"
        }
    }
}

もちろんこのようなロジックのために ViewModel を作るという選択肢もあるのですが、あえて画面に紐づく ViewModel としなくても単にロジックをオブジェクトや関数にまとめてもよさそうです。

struct ContentView: View {
    @Environment(CounterModel.self) private var counterModel
    private let countFormatter: CountFormatter = .init()

    var body: some View {
        VStack {
            Text(countFormatter.fizzBuzz(count: counterModel.count))

            Button("Increment") {
                counterModel.increment()
            }
        }
        .navigationTitle("B")
    }
}

struct CountFormatter {
    func fizzBuzz(count: Int) -> String {
        if count == 0 { return "0" }

        return switch (count % 5, count % 3) {
        case (0, 0):
            "FizzBuzz"
        case (0, _):
            "Fizz"
        case (_, 0):
            "Buzz"
        default:
            "\(count)"
        }
    }
}

CountFormatter#fizzBuzz は単なるメソッドなので簡単にテストすることができます。このように、ある程度以上の複雑さを持ったプレゼンテーションロジックが存在する場合でも、必ずしもそのために ViewModel を作る必要はなさそうです。

ViewModel を作りたい場合

なんらかの理由で、やはり ViewModel が必要ということがあるかもしれません。基本的には MV アーキテクチャで画面を作り、どうしても必要な箇所のみ MVVM にするということも可能です。

例えば、以下のように BScreen 用の ViewModel を作り、プレゼンテーションロジックを持たせることもできます。

@Observable
final class BScreenViewModel {
    var counterModel: CounterModel

    var formattedCount: String {
        if counterModel.count == 0 { return "0" }

        return switch (counterModel.count % 5, counterModel.count % 3) {
        case (0, 0):
            "FizzBuzz"
        case (0, _):
            "Fizz"
        case (_, 0):
            "Buzz"
        default:
            "\(counterModel.count)"
        }
    }

    func increment() {
        counterModel.increment()
    }
}

ここで、 @Observable である BScreenViewModel の中にさらに @ObservableCounterModel がネストしているところが注目ポイントです。このように @Observable がネストした状況であっても、内側の @Observable である CounterModel の更新に合わせて外側の @ObservableBScreenViewModel を監視している View の再描画が走ってくれます。この仕組みを利用すれば、基本的には View は直接 Model を見るが、場合によっては途中に ViewModel を挟むということもやりやすいと思います。

実は、 ObservableObject では、ネストした場合に内側の ObservableObject が更新されても View が更新されないという問題があったのでこの方法は取れませんでした。以下のリンクのような工夫をすればネストした ObservableObject を動作させることはできるということになっていますが、 @Observable でそういったハックなしにうまく動作するようになって最高です。

stackoverflow.com

@FetchRequest などのプロパティラッパー

Core Data の @FetchRequest や UserDefaults を見る @AppStorage などデータ層を SwiftUI に統合しやすくするプロパティラッパーがいろいろと用意されていますが、個人的にはこれまではあまり使おうと思っていませんでした。 View 層から import CoreData するのは気持ち悪い感じがするし、データ関連のテストがしづらいためです。そのため、これまでは ViewModel から NSFetchedResultsController を使い、データを ObservableObject@Published プロパティに流すことで Core Data の更新を View に反映させるということをやっていました。

しかし、いざ @FetchRequest を使ってみると、 Apple がちゃんと作ってくれているはずの @FetchRequest がデータ関連の全てをやってくれるのですごく使用感がよかったです。もちろんテストは書きづらくなるのですが、今まで自分で書いていたデータ関連のロジックをほとんど @FetchRequest がやってくれるのでそもそもテストを書く必要性自体がなくなると感じることが多かったです。ViewModel を介さないことで Appleフレームワークの SwiftUI 統合がそのまま使えるという良さもあります。

まとめ

あくまで個人開発かつ N=1 の話ではありますが、 ObservableObject と MVVM アーキテクチャで作っていたアプリを @Observable と MV アーキテクチャに移行したら開発がしやすくなったという話でした。 @ObservableiOS 17 以上でしか使えないのでまだ多くのユーザがいるアプリで使うのは厳しいとは思いますが、そのうち使えるのを楽しみにしておきましょう。

  • ViewModel がなくなることで ViewModel と Model のデータの同期を取るコードを書かなくてよくなりうれしい
  • ViewModel に書いていたようなプレゼンテーションロジックは専用のオブジェクトや関数にまとめることで単体テストすることが可能
  • ObservableObject から @Observable の進化も MV アーキテクチャでアプリを書いていくための助けになった
    • オブジェクト単位からプロパティ単位での監視になったので、使っていないプロパティの更新によって無駄に View の再描画が走るということがなくなった
    • ネストした @Observable もうまく動作するようになったので、万が一必要になったら ViewModel 層を差し込むということもできる
  • @FetchRequest などの SwiftUI 組み込みのプロパティラッパーも冷静に考えると便利なのでどんどん使っていきたい