はじめに
今回は、2025 年 2 月時点で自分が一番良いと思う SwiftUI での ViewModel の書き方・使い方を記事として残しておこうと思います。
新しいアプリを作り始めるときに、UIKit か SwiftUI かで迷う時期が続きましたが、私自身は、iOS17 の Observation マクロの登場以降は確実に SwiftUI を採用するようになりました。なぜなら Observation マクロでの ViewModel の書き方がとてもシンプルでスッキリとし使いやすくなったからです。
今回の記事のコードは github で公開していますので、参考にしてみてください。
そもそも ViewModel は必要か?
ViewModel を作る目的は、一言で言うと、View との役割分担のためです。View には画面に表示する部分のみを担当してもらい、ViewModel はその View で使用するデータだったり、他モジュール等との連携を担当してもらいます。
このようにビューロジックとビジネスロジックを分けることで、よりコードを簡潔に見やすくすることができます。これは UIKit であっても SwiftUI であっても同じです。
ただ、SwiftUI が出た当初は ViewModel は必要なのか?という議論があったように思います。確かに、SwiftUI では@State prroperty
として View の中に値を保持しておくと、その値を変更するだけでその値を使用している View 部分も自動で表示を更新してくれます。シンプルな View であればそれで問題ないですし、今でも画面表示のみに特化した@State property
を View 自身に保持させておくこともあります。
一つシンプルな例を見てみましょう。
import SwiftUI
struct SimpleView: View {
@State private var count: Int = 0
@State private var showPopup: Bool = false
var body: some View {
VStack(spacing: 32) {
Text("\(count)")
.font(.title)
.fontWeight(.bold)
Button("Increment") {
count += 1
}
Button("Show Popup") {
showPopup.toggle()
}
}
.sheet(isPresented: $showPopup) {
Text("Pupup Showed")
}
}
}
#Preview {
SimpleView()
}
例えばcount
という値をincrement
するだけのシンプルな例であればわざわざ ViewModel を作成する必要はないでしょう。実際にはそんなシンプルなことはないので、別 View を表示非表示するproperty
を追加しました。
例えば、@State private var showPopup: Bool = false
みたいな値は、ユーザーの操作に応じて別の View を表示・非表示させるだけ、という場合は、ViewModel に書かずに、View に保持させておくこともあります。これは割と現実的ですし、実際のプロダクトでもこのように私は書いています。
話がそれましたが、シンプルな View の場合はそれで問題ないかと思います。ただ、例えば API でデータを取得してきてそのデータを表示させる、というようなロジックが一つでも入ってきた場合、その時点でそれらのコードを View の中に書くのは避けたいところです。それらのコードは ViewModel に持たせたいです。
Observable Object を使用した ViewModel
Combine の登場の登場により、SwiftUI での ViewModel の書き方は Observable Object を使用した形式が一般的になりました。iOS13 以降ぐらいかと思うので、4-5 年前?でしょうか。そしてこの ViewModel の書き方が Observation マクロの登場までは、ほぼ一択でした。
サンプルコードを見てみましょう。
import SwiftUI
struct BeforeiOS17View: View {
@StateObject private var viewModel = BeforeiOS17ViewModel()
var body: some View {
VStack {
Text("\(viewModel.count)")
.font(.title)
.fontWeight(.bold)
Button("Increment") {
viewModel.count += 1
}
}
}
}
#Preview {
BeforeiOS17View()
}
final class BeforeiOS17ViewModel: ObservableObject {
@Published var count: Int = 0
}
ViewModel に ObservableObject という protocol を継承させ、Combine の機能を通じて値の変更を購読するモデルです。View 側で購読したい値は、@Published
というプロパティラッパーを付与します。View 側では、@StateObject
というプロパティラッパーを viewModel のプロパティに付与する必要があります。これに加えて、もともとの@State
というプロパティラッパーを組み合わせたりと複雑でした。
これが Observation マクロの登場により、さらにシンプルに書けるようになりました。
Observation マクロを使用した ViewModel
まずはコードを記載します。
import SwiftUI
import Observation
struct AfteriOS17View: View {
@State private var viewModel = AfteriOS17ViewModel()
var body: some View {
VStack {
Text("\(viewModel.count)")
.font(.title)
.fontWeight(.bold)
Button("Increment") {
viewModel.count += 1
}
}
}
}
#Preview {
AfteriOS17View()
}
@Observable
final class AfteriOS17ViewModel {
var count: Int = 0
}
まず、import Observation
が必要です。ViewModel には、@Observable
というプロパティラッパーを付与するだけで、その中の値を自動的に View 側で監視できるようになります。
そして View 側は@State
だけでこの ViewModel の変更を即座に反映できるようになります。それは、@Observationマクロ
が自動的に購読用のコードを付与してくれているからです。
ちなみに、@Observable
の部分を右クリックでExpand Macro
し、どのようなコードが付与されたのかを見ることが可能です。
@Observable
final class AfteriOS17ViewModel {
@ObservationTracked
var count: Int = 0
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
internal nonisolated func access<Member>(
keyPath: KeyPath<AfteriOS17ViewModel, Member>
) {
_$observationRegistrar.access(self, keyPath: keyPath)
}
internal nonisolated func withMutation<Member, MutationResult>(
keyPath: KeyPath<AfteriOS17ViewModel, Member>,
_ mutation: () throws -> MutationResult
) rethrows -> MutationResult {
try _$observationRegistrar.withMutation(of: self, keyPath: keyPath, mutation)
}
}
extension AfteriOS17ViewModel: Observation.Observable {
}
Expand Macro
でのコード内容を確認する必要はないですが、まあ View も ViewModel もコードがよりシンプルになりました。ViewModel 内の@Published
も必要ないし、View 側の@StateObject
も@State
のみで良くなったので、使うべきプロパティラッパーの種類が減ったので、よりとっつきやすくなりました。
Observation マクロは iOS17 以降です。。とはいえ、個人開発では自分のコードの書きやすさとかも優先できると思うので、個人開発ではガッツリ使っていって良いかなと思います。
堅牢な ViewModel
上記のObservationマクロ
を使用した ViewModel は書きやすくはなりましたが、もう少し完成度を高めたいと思います。今のままでは、View 側からも count の値を操作できてしまいます。
先にコードを記載します。
import SwiftUI
import Observation
struct RobustView: View {
@State private var viewModel: RobustViewModelType
init() {
self._viewModel = State(initialValue: RobustViewModel())
}
var body: some View {
VStack {
Text("\(viewModel.outputs.count)")
.font(.title)
.fontWeight(.bold)
Button("Increment") {
viewModel.inputs.increment()
}
}
}
}
#Preview {
RobustView()
}
protocol RobustViewModelType {
var inputs: RobustViewModelInput { get }
var outputs: RobustViewModelOutput { get }
}
protocol RobustViewModelInput {
func increment()
}
protocol RobustViewModelOutput {
var count: Int { get }
}
@Observable
final class RobustViewModel: RobustViewModelType, RobustViewModelInput, RobustViewModelOutput {
@ObservationIgnored var inputs: RobustViewModelInput { return self }
@ObservationIgnored var outputs: RobustViewModelOutput { return self }
// MARK: - outputs
var count: Int = 0
// MARK: - inputs
func increment() {
count += 1
}
}
まずは、ViewModelType、ViewModelInput、ViewModelOutput という protocol を定義し、Input には、主にユーザーの操作による ViewModel へのインプットを書きます。今回のケースであれば increment ボタンがタップされたときなので、func increment()
を定義しています。(ユーザーの操作以外では、例えば、onAppear など画面が表示されたら、などをインプットとして定義することもあります。)
次に、View 側で使用する property をアウトプットに定義します。今回であれば、count
という Int 型の値を設定しています。
あとは、これらの protocol を ViewModel で継承するようにすれば、ViewModel ではインプット・アウトプットでの定義どおりに書かなければビルドエラーとなりますし、View 側からもインプット・アウトプットに定義されたものしか参照できなくなります。
View 側での viewModel の型を RobustViewModelType としているのがポイントです。
複数人での開発などでは特に、このように書くことでこの ViewModel の役割が明確になりますなります。(どのようなものをインプットしてどのようなものをアウトプットする ViewModel なのかということです。)
今回のようなシンプルなケースではもちろんこのような書き方は不要ですが、複数人かつ複雑な ViewModel を書く場合には、この形を採用することが多いです。
ちなみに、この ViewModelType の protocol は KickStarter が公開している ViewModel の書き方です。
この ViewModel の書き方は、Observableマクロ
の部分を省けば、UIKit でも使えます。私は実際の UIKit のプロダクトでも採用しています。
今回紹介した堅牢な ViewModel はこの Kickstarter の ViewModel と Observable マクロを組み合わせた、私が考える SwiftUI での現時点での最強の ViewModel です。
これらのコードは github で公開しています。
参考になりましたら幸いです。