musicLineアプリ開発日記

作曲を誰でも楽しく簡単に♪

SwiftUI + MVVMでの実装調査

musicLineのiOS版を開発する時に、MVVMパターンの構造を選択した話。
iOSアプリ開発におけるSwiftUIとMVVMを使った実装方法を調査しました。


また実際にSwiftでタイマーを作ってみて、MVVMパターンの実装例を紹介します。

MVVMを使った実装例(後述)


はじめに

MVVMとは

ソースコード

  • Model
  • View
  • ViewModel

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

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

  1. ViewからViewModelへコマンドを送信
  2. ViewModelがModelを操作
  3. Modelの変更をViewModelが監視
  4. 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パターンでも可読性・再利用性が高く、テストもしやすい柔軟なコードにできると考えています。


実装

以下のページとサンプルコードを参考に実装しました。

実装例

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

タイマー


フォルダ構成
クラス図

画面とViewクラス

全コードを表示

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を更新します。

@StateObjectとの違い Viewが再作成された時に初期化するかの挙動に違いがあります。基本的にはトップ画面(TimerView)は`@StateObject`を使用し、サブ画面(DisplayView、PlayerView)は`@ObservedObject`を使用することで意図した挙動になります。

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