[SwiftUI][Swift] MVVM と Swift Concurrency を組み合わせる(2: View/ViewModel から actor にアクセスする)

     
MVVM と Concurrency を組み合わせたアーキテクチャを考えています。actor をつかった Model と組み合わせた時の ViewModel/View を考えます。

環境&対象

以下の環境で動作確認を行なっています。

  • macOS Monterey 12.3 beta4
  • Xcode 13.2.1
  • iOS 15.2

1回目の記事は以下です。
SwiftUI2021[SwiftUI][Swift] MVVM と Swift Concurrency を組み合わせる(1: Model を actor に)

View と ViewModel を作る

何らかのデータを保持する ViewModel と ViewModel からのデータを表示する View を作ります。

よくあるコードで、ViewModel の保持している値を表示しているだけです。Concurrency と関係なく、よく使っているパターンですので、説明は省略です。なお、ViewModel は、EnvironmentObject として渡しています。

ViewModel

class ViewModel: ObservableObject {
    let countdown = Countdown(60)
    var count: Int
    
    init() {
        count = 0
    }
}

View

//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/01
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var viewModel: ViewModel
    var body: some View {
        VStack {
            Text("Count: \(viewModel.count)")
        }
        .padding()
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

App

//
//  CountdownTimerApp.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/01
//  © 2022  SmallDeskSoftware
//

import SwiftUI

@main
struct CountdownTimerApp: App {
    @StateObject var viewModel: ViewModel = ViewModel()
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(viewModel)
        }
    }
}

以下のような表示になります。

ValueFromViewModel

Model が actor なケースで気をつける点

Model が class/struct で定義されていると、そのプロパティに対して 同期的(sync) に アクセスすることができます。

ですので、Model が actor でないときには、ViewModel をバイパスしてしまって、View から直接に Model へアクセスし 以下のようなコードを使用して表示要素を作成することができました。


Text(viewModel.countdown.count) // countdown が actor だとエラー

ところが、Model が actor で定義されると 上記のようにはできなくなります。

actor の プロパティに sync なアクセスはできず async でアクセスしなければいけないのですが、
View としては、View の作成途中に suspend されることは想定しない(と思われる)ので、同期的なアクセスを使用して、View を構築する必要があります。

つまり、actor を Model にもつと、View 構築時に actor から sync で情報を取得できないので、async なメソッドを経由して取得できる情報で、(後から) View を更新する必要が発生します。

この用途にぴったりなのが、ViewModel 内に @Published で定義するプロパティです。
async なメソッドで、@Published なプロパティを更新することで、View への更新と続けられることになります。

@Published の出番です

以下のように、表示に必要な情報を ViewModel で @Published を付与して定義しておくことで、async でアップデートされた情報が、View にも反映されることになります。

async で Model からの情報に応じて、count をアップデートするメソッド updateAync() と 外部から count を decrement できるようのメソッド decrement()も用意しておきます。

View 側にも、decrement を実行するためのボタンを付与します。


//
//  ViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/02
//  © 2022  SmallDeskSoftware
//

import Foundation

class ViewModel: ObservableObject {
    let countdown = Countdown(60)
    @Published var count: Int = 0
    
    init() {
        count = 0
    }
    
    func updateAsync() async {
        self.count = await countdown.count
    }

    func decrement() async {
        await countdown.decrement()
        await updateAsync()
    }
}

View は、表示された時(.onAppear)に、updateAsync を呼び出して表示更新のためのデータ更新を依頼することになります。どちらも内部で async なメソッドコールが必要となりますので、メソッド自身も async にしています。


//
//  ContentView.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/01
//  © 2022  SmallDeskSoftware
//

import SwiftUI

struct ContentView: View {
    @EnvironmentObject var viewModel: ViewModel
    var body: some View {
        VStack {
            Text("Count: \(viewModel.count)")
            Button(action: {
                Task {
                    await viewModel.decrement()
                }
            }, label: {
                Text("-1")
            })
        }
        .padding()
        .onAppear {
            Task {
                await viewModel.updateAsync()
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

View 操作向け @MainActor

上記のコードを実行すると以下のワーニングが表示されることがあります。


[SwiftUI] Publishing changes from background threads is not allowed; make sure to publish values from the main thread (via operators like receive(on:)) on model updates.

View の更新(につながる変更)は、MainThread から行う必要があります。上記では、Task として生成したスレッドから実行していますので、ワーニング表示につながっています。

実際の動作としては、画面更新が不安定になります。(更新タイミングにより変更が反映されたりされなかったりするということです)

このようなケースでは、これまでは 該当コードを MainThread から実行するように DispatchQueue.main.async{ } で囲むことで対応していました。

Swift の Concurrency サポートでは、上記のような記述をせず直感的に記述できるようになりました。(上記も直感的ですが・・・)

メソッドが MainThread 上で実行する必要があるならば、@MainActor とメソッドに付与することで、メソッドを特定の context で実行するよう指定することができます。

ということで、最終的に ViewModel は以下のようになります。


//
//  ViewModel.swift
//
//  Created by : Tomoaki Yagishita on 2022/03/02
//  © 2022  SmallDeskSoftware
//

import Foundation

class ViewModel: ObservableObject {
    let countdown = Countdown(60)
    @Published var count: Int
    
    init() {
        count = 0
    }
    
    @MainActor func updateAsync() async {
        self.count = await countdown.count
    }
    
    func decrement() async {
        await countdown.decrement()
        await updateAsync()
    }

}

ViewModel のメソッドは、async/sync?

MVVM のうち、actor を使うと Model は、async アクセスになります。View は、基本的に sync です。(更新は、MainThread に依頼するため async です。)

Model が async で、View が sync な時に、この中間でやり取りを成立させるのが、ViewModel の役割と言えます。
Model がもつ情報を View 向けに変換するのが ViewModel の大きな役割の1つでしたが、この 時制(?) の変換も ViewModel の役割になりそうです。

ViewModel は、基本的に sync/async のどちらかで統一する方が良いのか考えてみました。

基本的に、sync なメソッドを async に変換することの意味は少ないので、async な処理を外部向けに sync に変換するかどうかがポイントになります。

ケースバイケースなのですが、以下の基準で判断するのが良さそうです。
・呼び出し側で処理終了を待つ必要があるか?
・呼び出し側で Task を生成するべきか?可能か?

Swift の concurrency では、async なメソッド呼び出しに2つのパターンがあります。


let result = await methodA()   // (1)
async let result2 = methodA()   // (2)
....
return await result2  // (3)

(1) では、 methodA() が終わるまで、次の行に処理は遷移しません。その時点で suspend されるということです。
(2) では、methodA() の処理は、別スレッドで実行され始めますが、suspend はされません。返り値である result2 が使用される (3) まで処理は継続されます。

このように async なメソッドは 呼び出し側でその処理を待つのか 並行して実行させるか を選択することができます。
終了タイミングを必要とするケースでは、内部の async 呼び出しを sync でラップされたメソッドとして提供されると async 処理が終了したことを判断できず困るケースがありそうです。

言い方を変えると ViewModel のメソッドは 内部が async であるならば 外部に公開するメソッドは async を設定するのが良いように思えます。
このことは、ViewModel のメソッドを呼び出す時に、View 側で Task を生成することが必要になることを意味します。
View 側で Task を生成することのメリットとしては、自動キャンセルが挙げられます。
Swift の Concurrency では、Task は親子関係があり、親 Task がキャンセルされると 子 Task も自動でキャンセルされます。このことを View に当てはめると、View が表示された時に生成された Task (例えば、.onAppear 内で生成した Task ) は、View がなくなった時に (その Task が処理途中であっても)自動でキャンセルされるということです。
内部が sync なメソッドは sync のまま、async な処理を内包するメソッドは、async で外部に見せるのが良いように思えます。

actor をモデルに持つアプリの動作

特にすごいことはありませんが、きちんと動いたということで。

まとめ

MVVM で actor を使用した Model を使う時の ViewModel/View について考察しました。

actor を使用した Model を使う時の ViewModel/View
  • View は sync
  • ViewModel は ケースバイケース
  • async な処理を内包するメソッドは、async のままにするのがおすすめ
  • View 更新に関わる ViewModel のメソッドは、@MainActor と付与すると MainThread で実行される

説明は以上です。
不明な点やおかしな点ありましたら、こちらまで。

コメントを残す

メールアドレスが公開されることはありません。