musicLineのiOS版を開発する時に、MVVMパターンの構造を選択した話。
iOSアプリ開発におけるSwiftUIとMVVMを使った実装方法を調査しました。
また実際にSwiftでタイマーを作ってみて、MVVMパターンの実装例を紹介します。
はじめに
MVVMとは
- Model
- View
- ViewModel
に分類して開発しやすくする構造です。
例えば、ユーザーがViewのボタンを押した時
- ViewからViewModelへコマンドを送信
- ViewModelがModelを操作
- Modelの変更をViewModelが監視
- ViewModelが変更をViewへ通知
の流れでModelを操作し、Modelの状態でViewを更新します。
ViewModelの必要性
SwiftUIではViewModelが不要なのではないかという流れがあるようなので、MVVMの構造にするか迷いました。
そもそもViewModelは
- Viewでの操作をModelへ伝達(コマンド)
- Modelの変更をViewへ伝達(通知)
- Viewに表示するデータへModelを変換
- Viewの状態を保持
の役割があります。
従来のStoryBoardでは命令的UIであり、Viewは画面配置のみの制御しかできませんでした。なのでModelを操作する時は上記の役割をViewModelが引き受け、Modelの役割が肥大化しないようにしていました。
以上の理由からViewModelは必要であり、 MVVMパターンは多く使われてきましたが、SwiftUIが登場します。
宣言的UIのSwiftUIでは既にViewModelの役割を担えるため、ViewModelが不要になり、MVVMよりも相性の良いアーキテクチャ(TCA)を選択することも考えられます。
しかし、musicLineではAndroid版でもMVVMを使用しており、構造を大きく変更したくない理由からMVVMを採用することにしました。
また、MVVMパターンでも可読性・再利用性が高く、テストもしやすい柔軟なコードにできると考えています。
実装
以下のページとサンプルコードを参考に実装しました。
実装例
タイマーを実装してみました。
全コードを表示
import SwiftUI @main struct MvvmSampleApp: App { var body: some Scene { WindowGroup { TimerView(viewModel: .init(timer: .init(fullCount: 5))) } } }
View 3ファイル
import SwiftUI struct TimerView: View { @StateObject var viewModel: TimerViewModel var body: some View { VStack(spacing:30.0){ DisplayView(viewModel: viewModel.createDisplayViewModel()) PlayerView(viewModel: viewModel.createPlayerViewModel()) } .alert(isPresented:$viewModel.isShowAlert){ Alert(title: Text("fin"), message:Text("timer is fin"), dismissButton: .default(Text("OK"))) } } }
import SwiftUI struct DisplayView: View { @ObservedObject var viewModel: DisplayViewModel var body: some View { Text("残り \(viewModel.remainingTime) 秒") .font(.largeTitle) } }
import SwiftUI struct PlayerView: View { @ObservedObject var viewModel: PlayerViewModel var body: some View { HStack(spacing:30.0){ playButton().font(.largeTitle) stopButton().disabled(!viewModel.isStopEnabled) } } // 状態によって、Start/Pauseが切り替わるボタン private func playButton() -> some View{ if viewModel.isPlayTimer { return Button{ viewModel.tapped.send(.pause) } label: { Text("Pause") } }else{ return Button{ viewModel.tapped.send(.start) } label: { Text("Start") } } } // 状態によって、色が変わるボタン private func stopButton() -> some View{ Button{ viewModel.tapped.send(.stop) } label: { Text("Stop") .foregroundColor(viewModel.isStopEnabled ? Color.red : Color.gray) } } }
ViewModel 3ファイル
import Foundation class TimerViewModel: ObservableObject{ // Viewの状態 @Published var isShowAlert = false private var timer: CountDownTimer init(timer: CountDownTimer){ self.timer = timer bindOutput() } func createPlayerViewModel() -> PlayerViewModel { .init(timer: timer) } func createDisplayViewModel() -> DisplayViewModel { .init(timer: timer) } // 出力を結合する private func bindOutput(){ // タイマー終了時のイベント timer.$count .map{[weak self] _ in self?.timer.remainingTime == 0 } .assign(to: &$isShowAlert) } }
class DisplayViewModel: ObservableObject{ var remainingTime: Int{ timer.remainingTime } private var timer: CountDownTimer private var subscriptions = Set<AnyCancellable>() init(timer: CountDownTimer){ self.timer = timer bindOutput() } // 出力を結合する private func bindOutput(){ timer.$count .sink{[weak self] _ in self?.objectWillChange.send() } .store(in: &subscriptions) } }
import Foundation import Combine class PlayerViewModel: ObservableObject{ var isStopEnabled: Bool{ timer.remainingTime < timer.fullCount } var isPlayTimer: Bool{ timer.isDoing } // イベント let tapped = PassthroughSubject<Command, Never>() private var subscriptions = Set<AnyCancellable>() private let timer: CountDownTimer init(timer: CountDownTimer){ self.timer = timer bindInput() bindOutput() } // 入力を結合する private func bindInput(){ // ボタンタップした時、タイマーの処理をする。 tapped .throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: false) .sink{[weak timer] command in switch command { case .start: timer?.start() case .pause: timer?.pause() case .stop: timer?.stop() } } .store(in: &subscriptions) } // 出力を結合する private func bindOutput(){ // タイムによってプロパティが変更されるため、タイマーを監視する timer.objectWillChange .sink{[weak self] _ in self?.objectWillChange.send() } .store(in: &subscriptions) } // コマンド enum Command{ case start case pause case stop } }
Model 1ファイル
import Foundation class CountDownTimer: ObservableObject{ var remainingTime: Int { // 残り時間 fullCount - count } var isDoing: Bool{ // タイマーが進んでいるか handler?.isValid == true } let fullCount: Int // タイマーの完了時間 @Published private(set) var count = 0 @Published private var handler :Timer? init(fullCount: Int) { self.fullCount = fullCount } func start(){ if isDoing { return } handler = Timer.scheduledTimer(withTimeInterval: 1, repeats: true){ [weak self] _ in self?.countUp() } } func pause(){ if !isDoing { return } handler?.invalidate() self.objectWillChange.send() } func stop(){ pause() reset() } func reset(){ count = 0 } private func countUp(){ count += 1 // 0 以下の時、タイマーストップする if remainingTime <= 0 { stop() } } }
ポイント
SwiftでMVVMパターンを実装するポイントを4点紹介します。
- ViewからViewModelへコマンドを投げる
- ModelからViewModelへ通知する
- ViewModelからViewへ通知する
- Modelの状態によりViewの状態を変更
ViewからViewModelへコマンドを投げる
PlayerViewとPlayerViewModelを使ってコマンドを投げるポイントを解説します。
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へ通知する
DisplayViewModelとCountDownTimerを使って通知するポイントを解説します。
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へ通知する
DisplayViewとDisplayViewModelを使って通知するポイントを解説します。
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についてまだまだ学ぶことが多く、改善の余地がありそうです。
とりあえずこの考え方で実装してみて最適化していきます。