SwiftUI + MVVMでの実装調査

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

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230408/20230408210939.gif" title="MVVMを使った実装例"> 

<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230519/20230519095723.png" title="3分作曲-musicLine- Androidアプリ"> 
</a>

<br>
<br>

# MVVMとは

ソースコードを

* Model
* View
* ViewModel

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

https://qiita.com/Sossiii/items/c535d54d8379f1162fe2/

<br>

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

1. ViewからViewModelへコマンドを送信
2. ViewModelがModelを操作
3. Modelの変更をViewModelが監視
4. ViewModelが変更をViewへ通知  

の流れでModelを操作し、Modelの状態でViewを更新します。

<br>

ちなみに、SwiftUIはViewModelとしての役割も担えるため、MVVMよりも相性の良いアーキテクチャを選択すべきか迷いました。その辺りの話は元記事へ

https://musicline-developer.hatenablog.com/entry/2023/04/10/084110/

<br>
<br>

# 実装例

タイマーを実装してみました。  

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230408/20230408210939.gif" width="500px" title="タイマー"> 

<br>

フォルダ構成とクラス図です。

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230409/20230409120014.png" width="250px" title="フォルダ構成"> 


<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230410/20230410102143.png" width="350px" title="クラス図"> 

<br>

画面とクラスを対応させた図です。

<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230408/20230408235227.png" width="450px" title="画面とViewクラス"> 


<br>
ソースコード等の詳細は元記事へ

https://musicline-developer.hatenablog.com/entry/2023/04/10/084110/

<br>
<br>

## ポイント

SwiftでMVVMパターンを実装するポイントを4点紹介します。

* ViewからViewModelへコマンドを投げる
* ModelからViewModelへ通知する
* ViewModelからViewへ通知する
* Modelの状態によりViewの状態を変更

<br>

### ViewからViewModelへコマンドを投げる

View`PlayerView`とViewModel`PlayerViewModel`を使ってコマンドを投げるポイントを解説します。  

<br>

　**1. ViewModelでコマンドを作成する**

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

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

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


<br>
<br>

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

```swift: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)
}
```

**Publisher`tapped`からの配信をSubscriber（購読者）に届けています。`.store(in: &subscriptions)`で講読リストに保管することで購読を維持**します。`AnyCancellable`の型で保管することで`.cancel()`で講読をキャンセルできます。また、購読リストに保管しなかったり、リストから破棄することでもキャンセルになります。  

`.sink{}`の中に購読者の振る舞い（イベントハンドラー）を書きます。なお、Operator `throttle`を使うことでPublisherからの値を制御し、予期せぬダブルタップを防止します。この設定だとイベント発生後に1秒間はイベントが無効になります。

<br>
<br>

　**3. Viewからコマンドを投げる（イベントを発生させる）**

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

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

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

<br>
<br>

### ModelからViewModelへ通知する

ViewModel`DisplayViewModel`とModel`CountDownTimer`を使って通知するポイントを解説します。  

<br>

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

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


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

<br>
<br>

　**2. ViewModelでModelを購読する**

```swift:DisplayViewModel.swift
private var subscriptions = Set<AnyCancellable>()
...
    
private func bindOutput(){
    timer.$count
        .sink{[weak self] _ in
            // 処理
        }
        .store(in: &subscriptions)
}
```

`timer.$count.store(in: &subscriptions)`とすることで、Model`timer`のプロパティ`count`値を購読（変更がないか監視）します。値に変更があった時に`.sink{}`に入ります。

<br>
<br>


　**3. ViewModelへ通知する**

```swift: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()`を使用でき、プロパティラッパーが使えない時でも直接通知を送れます。**

<br>
<br>

### ViewModelからViewへ通知する

View`DisplayView`とViewModel`DisplayViewModel`を使って通知するポイントを解説します。  

<br>

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

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

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

<br>
<br>

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

```swift:DisplayView.swift
@ObservedObject var viewModel: DisplayViewModel
```

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

<br>
<br>


　**3. Viewへ変更を通知する**

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


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

<br>
<br>

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

```swift: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を出力してアラートを表示するようにしています。

<br>
<br>

# おわりに

SwiftUIとCombineについてまだまだ学ぶことが多く、改善の余地がありそうです。  
とりあえずこの考え方で実装してみて最適化していきます。

<br>
<br>

https://musicline-developer.hatenablog.com/entry/2023/04/10/084110/

<a href="https://play.google.com/store/apps/details?id=jp.gr.java.conf.createapps.musicline">
<img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/musicline_developer/20230519/20230519095723.png" title="3分作曲-musicLine- Androidアプリ"> 
</a>