3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

SwiftUI + MVVMでの実装調査

Posted at

iOSアプリ開発におけるSwiftUIとMVVMを使った実装方法を調査しました。
また実際にSwiftでタイマーを作ってみて、MVVMパターンの実装例を紹介します。



MVVMとは

ソースコードを

  • Model
  • View
  • ViewModel

分類して開発しやすくする構造です。


例えば、ユーザーがViewのボタンを押した時

  1. ViewからViewModelへコマンドを送信
  2. ViewModelがModelを操作
  3. Modelの変更をViewModelが監視
  4. 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でコマンドを作成する

PlayerViewModel.swift
// タップした時のイベント
let tapped = PassthroughSubject<Command, Never>()
...

// コマンド
enum Command{
    case start
    case pause
    case stop
}

例えば、タイマーを操作するコマンドとして「開始、一時停止、停止」を定義します。PassthroughSubjectのPublisherを使うことで.send()でイベントを発生させることができます。また、PassthroughSubjectの型を定義したコマンドにすることで、イベント発生時に一緒にコマンドを送ることができます。



 2. ViewModelでイベントを購読する

PlayerViewModel.swift
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からコマンドを投げる(イベントを発生させる)

PlayerView.swift
@ObservedObject var viewModel: PlayerViewModel    
...

Button{
    viewModel.tapped.send( .start)
} label: {
    Text("Start")
}

Viewでボタンがタップされた時、イベントを発生させます。
ViewからViewModelの関数を直接呼ぶこともできますが、Publisherを挟むことでユーザーの予期せぬ動作を制御することができます。



ModelからViewModelへ通知する

ViewModelDisplayViewModelとModelCountDownTimerを使って通知するポイントを解説します。


 1. 監視するプロパティを設定する

CountDownTimer.swift
class CountDownTimer: ObservableObject{
    
    @Published private(set) var count = 0
    ...
}

プロパティcountの変更を監視したい場合、@Publishedのプロパティラッパーを付与します。また、クラスの変更を監視したい場合、クラスにObservableObjectを継承します。



 2. ViewModelでModelを購読する

DisplayViewModel.swift
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へ通知する

CountDownTimer.swift
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. 監視するプロパティを設定

DisplayViewModel.swift
class DisplayViewModel: ObservableObject{
    ...
}

ModelからViewModelへ通知する「1. 監視するプロパティを設定する」と同様。
この場合はDisplayViewModelを監視対象オブジェクトに設定してます。



 2. ViewでViewModelの変更を監視する

DisplayView.swift
@ObservedObject var viewModel: DisplayViewModel

@ObservedObjectを付けることによってViewModelを監視し、通知があった時にViewを更新します。



 3. Viewへ変更を通知する

DisplayViewModel.swift
private func bindOutput(){
    timer.$count
        .sink{[weak self] _ in
            self?.objectWillChange.send()
        }
        .store(in: &subscriptions)
}

objectWillChange.send()でViewModelオブジェクトの変更を通知します。この場合timer.countの値に変更があった時に.sink{}に入り、Viewへ変更を通知します。



Modelの状態によりViewの状態を変更

TimerViewModel.swift
// 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についてまだまだ学ぶことが多く、改善の余地がありそうです。
とりあえずこの考え方で実装してみて最適化していきます。



3
4
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?