iOSアプリ開発におけるSwiftUIとMVVMを使った実装方法を調査しました。
また実際にSwiftでタイマーを作ってみて、MVVMパターンの実装例を紹介します。
MVVMとは
ソースコードを
- Model
- View
- ViewModel
に分類して開発しやすくする構造です。
例えば、ユーザーがViewのボタンを押した時
- ViewからViewModelへコマンドを送信
- ViewModelがModelを操作
- Modelの変更をViewModelが監視
- ViewModelが変更をViewへ通知
の流れでModelを操作し、Modelの状態でViewを更新します。
ちなみに、SwiftUIはViewModelとしての役割も担えるため、MVVMよりも相性の良いアーキテクチャを選択すべきか迷いました。その辺りの話は元記事へ
実装例
タイマーを実装してみました。
フォルダ構成とクラス図です。
画面とクラスを対応させた図です。
ソースコード等の詳細は元記事へ
ポイント
SwiftでMVVMパターンを実装するポイントを4点紹介します。
- ViewからViewModelへコマンドを投げる
- ModelからViewModelへ通知する
- ViewModelからViewへ通知する
- Modelの状態によりViewの状態を変更
ViewからViewModelへコマンドを投げる
ViewPlayerView
とViewModelPlayerViewModel
を使ってコマンドを投げるポイントを解説します。
1. ViewModelでコマンドを作成する
// タップした時のイベント
let tapped = PassthroughSubject<Command, Never>()
...
// コマンド
enum Command{
case start
case pause
case stop
}
例えば、タイマーを操作するコマンドとして「開始、一時停止、停止」を定義します。PassthroughSubject
のPublisherを使うことで.send()
でイベントを発生させることができます。また、PassthroughSubject
の型を定義したコマンドにすることで、イベント発生時に一緒にコマンドを送ることができます。
2. ViewModelでイベントを購読する
private var subscriptions = Set<AnyCancellable>()
...
private func bindInput(){
// ボタンタップした時、タイマーの処理をする。
tapped
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: false)
.sink{[weak timer] command in
// イベント発生した時の処理(購読者の振る舞い)
}
.store(in: &subscriptions)
}
Publishertapped
からの配信をSubscriber(購読者)に届けています。.store(in: &subscriptions)
で講読リストに保管することで購読を維持します。AnyCancellable
の型で保管することで.cancel()
で講読をキャンセルできます。また、購読リストに保管しなかったり、リストから破棄することでもキャンセルになります。
.sink{}
の中に購読者の振る舞い(イベントハンドラー)を書きます。なお、Operator throttle
を使うことでPublisherからの値を制御し、予期せぬダブルタップを防止します。この設定だとイベント発生後に1秒間はイベントが無効になります。
3. Viewからコマンドを投げる(イベントを発生させる)
@ObservedObject var viewModel: PlayerViewModel
...
Button{
viewModel.tapped.send( .start)
} label: {
Text("Start")
}
Viewでボタンがタップされた時、イベントを発生させます。
ViewからViewModelの関数を直接呼ぶこともできますが、Publisherを挟むことでユーザーの予期せぬ動作を制御することができます。
ModelからViewModelへ通知する
ViewModelDisplayViewModel
とModelCountDownTimer
を使って通知するポイントを解説します。
1. 監視するプロパティを設定する
class CountDownTimer: ObservableObject{
@Published private(set) var count = 0
...
}
プロパティcount
の変更を監視したい場合、@Published
のプロパティラッパーを付与します。また、クラスの変更を監視したい場合、クラスにObservableObject
を継承します。
2. ViewModelでModelを購読する
private var subscriptions = Set<AnyCancellable>()
...
private func bindOutput(){
timer.$count
.sink{[weak self] _ in
// 処理
}
.store(in: &subscriptions)
}
timer.$count.store(in: &subscriptions)
とすることで、Modeltimer
のプロパティcount
値を購読(変更がないか監視)します。値に変更があった時に.sink{}
に入ります。
3. ViewModelへ通知する
class CountDownTimer: ObservableObject{
@Published private(set) var count = 0
...
func pause(){
...
self.objectWillChange.send()
}
private func countUp(){
count += 1
...
}
}
@Published
を付けているプロパティcount
の値を変更すると通知されます。また、ObservableObject
を継承する事でobjectWillChange.send()
を使用でき、プロパティラッパーが使えない時でも直接通知を送れます。
ViewModelからViewへ通知する
ViewDisplayView
とViewModelDisplayViewModel
を使って通知するポイントを解説します。
1. 監視するプロパティを設定
class DisplayViewModel: ObservableObject{
...
}
ModelからViewModelへ通知する「1. 監視するプロパティを設定する」と同様。
この場合はDisplayViewModelを監視対象オブジェクトに設定してます。
2. ViewでViewModelの変更を監視する
@ObservedObject var viewModel: DisplayViewModel
@ObservedObject
を付けることによってViewModelを監視し、通知があった時にViewを更新します。
3. Viewへ変更を通知する
private func bindOutput(){
timer.$count
.sink{[weak self] _ in
self?.objectWillChange.send()
}
.store(in: &subscriptions)
}
objectWillChange.send()
でViewModelオブジェクトの変更を通知します。この場合timer.count
の値に変更があった時に.sink{}
に入り、Viewへ変更を通知します。
Modelの状態によりViewの状態を変更
// Viewの状態
@Published var isShowAlert = false
...
private func bindOutput(){
// タイマー終了時のイベント
timer.$count
.map{[weak self] _ in
self?.timer.remainingTime == 0
}
.assign(to: &$isShowAlert)
}
isShowAlert
はViewにアラートの表示状態を保持するフラグです。.assign
でtimer.countを購読して、Modelの状態に応じてViewの状態(isShowAlert
フラグの値)を更新します。
なお、.map
でModelの値を変換します。この場合だと、残り時間が0秒の時、TRUEを出力してアラートを表示するようにしています。
おわりに
SwiftUIとCombineについてまだまだ学ぶことが多く、改善の余地がありそうです。
とりあえずこの考え方で実装してみて最適化していきます。