概要
SwiftUI Advent Calendar 2023 の 21 日目です。
最近趣味で iOS の podcast クライアントを SwiftUI で作っているのですが、やってみると podcast クライアントはアプリとしてそれなりに難しいことがわかってきました。作っているうちにどんどん状態管理が複雑になってきて、個人開発でなぜこんなにがんばりが必要なんだと思って開発が止まっていたのですが、最近 iOS 17 の登場をきっかけにアプリを全般的に書き直すことにして、同時にアーキテクチャを変えてみました。これにより構成がシンプルになって開発効率が上がり、開発を再開することができました。具体的には、
- from: 1画面に1つ ViewModel(
ObservableObject
)を作り、 View から ViewModel を監視する MVVM - to: View から直接 Model (
@Observable
)を監視する MV
という変更しました。要は ViewModel を消したということです。
今まであまりモバイルアプリのアーキテクチャにこだわりがなかったのですが、アーキテクチャの変更で開発のしやすさやモチベーションに大きな影響が出てなるほど〜と思ったので、一つの事例としてこの記事で MV アーキテクチャについてまとめてみようと思います。
前提として、アプリにどのアーキテクチャが適しているかは要件や開発チームの規模などによって大きく変わると思っています。
参考
SwiftUI アプリにおける MV アーキテクチャのメリットはすでにいくつかリソースで解説されています。とくに参考になると思っているものを2つ貼っておきます。
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") } }
ここで面倒なのは CounterModel
の count
と AScreenViewModel
の count
が常に同じ値であるべきなのに別のクラスのプロパティとしてそれぞれ存在するため、同期を取るコードが必要になっていることです。
// ここで Model と ViewModel のデータを同期している counterModel .countPublisher .sink { [weak self] in self?.formattedCount = "\($0)" } .store(in: &cancellables)
今回は Combine の Publisher
を使って同期を行っていますが、他の方法を取る場合でも何かしら同じようなコードは存在することになるでしょう。ここでは考慮していませんが、適切なタイミングで購読をキャンセルすることを考えるとさらに追加で処理を書く必要があります。
ここまでの例だと同期すべきプロパティが1つで、かつ画面も1つなので大した手間ではないですが、これが他の画面からも CounterModel
を見るようになったり、逆に AScreenViewModel
が他の Model も見るようになったりすると同期を取るコードがどんどん増えていって大変になってきます。例えば、 BScreenViewModel
や CScreenViewModel
でも count
を表示したいとなってくると毎回同じかつ、あまり本質的とはいえないコードを書く必要があるので気が滅入ってくることになります。
MV にするとどうなるか
ということアプリ開発の厳しさを味わっていた中、 iOS 17 で登場した @Observable
を使って、アーキテクチャも含めてアプリを書き直してみようと思いました。大変さの根源は Model の情報を View が表示する、というフローの間に ViewModel が介在することだったので、 ViewModel を排して View から Model を直接見る、いわゆる MV アーキテクチャにしてみます。
Model の更新を View に反映できるようにしたいので、 CounterModel
自体を @Observable
にします。 @Observable
の仕組みにより、 Combine.Publisher
や AsyncStream
などを使わずとも、ただのプロパティである 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
プロパティが更新されることで ObservableObject
の objectWillChange
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
では CounterModel
の count
をそのまま表示していましたが、 BScreen
では count
を FizzBuzz で表示したいとします。このロジックは 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
の中にさらに @Observable
の CounterModel
がネストしているところが注目ポイントです。このように @Observable
がネストした状況であっても、内側の @Observable
である CounterModel
の更新に合わせて外側の @Observable
の BScreenViewModel
を監視している View の再描画が走ってくれます。この仕組みを利用すれば、基本的には View は直接 Model を見るが、場合によっては途中に ViewModel を挟むということもやりやすいと思います。
実は、 ObservableObject
では、ネストした場合に内側の ObservableObject
が更新されても View が更新されないという問題があったのでこの方法は取れませんでした。以下のリンクのような工夫をすればネストした ObservableObject
を動作させることはできるということになっていますが、 @Observable
でそういったハックなしにうまく動作するようになって最高です。
@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 アーキテクチャに移行したら開発がしやすくなったという話でした。 @Observable
は iOS 17 以上でしか使えないのでまだ多くのユーザがいるアプリで使うのは厳しいとは思いますが、そのうち使えるのを楽しみにしておきましょう。
- ViewModel がなくなることで ViewModel と Model のデータの同期を取るコードを書かなくてよくなりうれしい
- ViewModel に書いていたようなプレゼンテーションロジックは専用のオブジェクトや関数にまとめることで単体テストすることが可能
ObservableObject
から@Observable
の進化も MV アーキテクチャでアプリを書いていくための助けになった- オブジェクト単位からプロパティ単位での監視になったので、使っていないプロパティの更新によって無駄に View の再描画が走るということがなくなった
- ネストした
@Observable
もうまく動作するようになったので、万が一必要になったら ViewModel 層を差し込むということもできる
@FetchRequest
などの SwiftUI 組み込みのプロパティラッパーも冷静に考えると便利なのでどんどん使っていきたい