MVVM を実装する方法は様々あります。View-ViewModel の接続に関していうと、リアクティブ系ライブラリを使ってそれぞれの入力と出力を定義し接続することがおそらく最も一般的でしょう。
最近は SwiftUI と Swift Concurrency が登場して、リアクティブ系ライブラリで解決したい課題の多くに対応しています。ただし、乗り換えてみても実装が一筋縄ではいかないことが多いです。
V-VM の接続の課題を分解すると、ViewModel の状態をどのように View にバインドするのかと、View のイベントを検知してどのように ViewModel に渡すのかの2つに分けることができます。
前者は ViewModel を @ObservableObject
に準拠して状態を @Published
で修飾すると大体解決するが、後者に関してはどうでしょう。
アクションごとに ViewModel に関数を置くべきか?それらの内どれが async
になるべきか?同期的はコンテキストから非同期処理を開始するのは View か ViewModel か?タスクキャンセルはどう処理する?ユニットテストが書けるようには、アクション処理の完了をどうやって検知する?
要件
私自身が上記の質問を含め V-VM の接続に関して次のように考えています。
- 状態の更新は ViewModel の状態に反映されるので直接アクションハンドラーから戻すことが要らない。そのため、アクションハンドラーの関数は1つでいい。
- 同期でできる処理ならわざわざ非同期を開始する必要がない。
- アクションを処理する際、非同期処理の開始は ViewModel の責任である。
- View からは、アクションが同期的に処理されるか、非同期的に処理されるかは、知る必要がない。
- アクションの処理が完了したことが、例えばユニットテストを書くとき、View 側でロード中表示をするときなどには必要。
- ViewModel の生存期間が View の生存期間に紐づく。View が消えたら処理途中のアクションがキャンセルされるべき。
- アクションごとのキャンセルも可能な構成がいい。
インターフェースから考える
この要件を考慮して呼び出し元 (View)で使うインターフーェスは次のように想定しています。
View が ViewModel を自分のライフサイクルに紐づきます。SwiftUI でいうと、.task
モディファイアが一番便利でしょう。そうするとビューが表示されたら ViewModel がそのイベントに購読し、ビューが消えたタイミングでその購読がキャンセルされます。
.task {
await viewModel.connect()
}
何らかのイベントで、View が ViewModel にアクションを渡します。アクションの処理が完了するまで待つこともできて、待たないこともできる。
// アクションを渡して完了までは待たない
viewModel.handle(.buttonTap)
// アクションを渡して完了まで待つ
await viewModel.handle(.buttonTap).completion
// アクションを渡して完了はコールバックで検知する
viewModel.handle(.buttonTap).onCompletion { }
実装
いざ上記のインターフェースを実装してみるとかなり沼にハマります。以下は私が考え出した答えになります。これが最高のやり方とまで言える自信がないので、同じインターフェースでもっとスマートに書ける方法があればぜひ聞かせてください!
いくつかの部品が必要になります。まずは、アクション完了を抽象化します。
enum Effect {
/// アクションが同期的に完了した
case none
/// アクションが非同期のタスクを生成した
case task(Task<Void, Never>)
}
完了の通知はここで処理します。
enum Effect {
/// クロージャで完了の通知を受ける
func onCompletion(_ completion: @escaping () -> Void) {
switch self {
case .none:
completion()
case let .task(task):
Task {
_ = await task.value
completion()
}
}
}
/// 完了まで待つ。
/// もし呼び出し元がキャンセルされると Effect のタスクもキャンセルされる。
var completion: Void {
get async {
switch self {
case .none:
return
case let .task(task):
await withTaskCancellationHandler {
_ = await task.value
} onCancel: {
task.cancel()
}
}
}
}
}
コンカレンシー対応のメソッド (completion
) は、それを呼び出しているタスクのキャンセルを伝搬しアクション処理のタスクもキャンセルします。これでアクションごとのキャンセルに対応しています。
.task {
// この task がキャンセルされると、以下のアクション処理もキャンセルされる
await viewModel.handle(.buttonTap).completion
}
次は ViewModel を実装してみましょう。
final class ViewModel: ObservableObject {
enum Action {
case buttonTap
}
func connect() async {
// ??
}
@discardableResult
func handle(_ action: Action) -> Effect {
switch action {
case .buttonTap:
// return ??
}
}
}
定義だけはすぐも書けるが、中身に関しては、handle
で発生するタスクを connect
のライフサイクルに紐づく必要があるので後回しにします。
ここでヘルパークラスを登場させます。
final class TaskEffectHandler {
private typealias OperationStream = AsyncStream<@Sendable () async -> Void>
private var operationStream: OperationStream?
private var operationStreamContinuation: OperationStream.Continuation?
func connect() async {
operationStreamContinuation?.finish()
let stream = OperationStream.makeStream() // Swift 5.9
(operationStream, operationStreamContinuation) = stream
await withDiscardingTaskGroup { group in // Swift 5.9
for await operation in stream.stream {
group.addTask {
await operation()
}
}
}
(operationStream, operationStreamContinuation) = (nil, nil)
}
タスク同士のコミュニケーションには AsyncStream
が適切なのでそれを利用して OperationStream
を作っています。connect
関数で、ストリームの continuation
に流している処理クロージャを受け取って await で実行しています。continuation
は関数の外から使えるにはクラスのプロパティに保存しています。
最初でやっている処理は、もしも呼ばれたときにすでに購読中のストリームがあればそれを完了しています。それは同時に複数の場所から connect
されることは非対応しているからです。
AsyncStream
生成の便利関数とwithDiscardingTaskGroup
、Swift 5.9 (Xcode 15.0) で登場した機能の2つを使っています。
withDiscardingTaskGroup
の中でストリーム購読して流したクロージャの一つ一つにグループタスクを生成しています。
connect
呼び出し元がキャンセルされると、このグループがキャンセルされて進行中のタスクとストリームもキャンセルされます。一つの注意ポイントは、AsyncStream
は一回キャンセルされるとストリームが完了したと見なされそれ以降は購読しても要素が流れません。そのため connect
が呼びされた都度に作り直しています。
最後に残っているのは、add
関数の実装です。
final class TaskEffectHandler {
func add(_ operation: @escaping @Sendable () async -> Void) -> Effect {
guard let operationStreamContinuation else {
print("⚠️ Warning: adding tasks before connecting")
return .task(
Task {
await operation()
}
)
// ...
}
}
動作の想定として、connect
する前に add
が呼ばれたらロジックエラーになります。その場合は一旦はワーニングを出力して普通にタスクを生成することにしています。
実装の続きに行きましょう。
final class TaskEffectHandler {
func add(_ operation: @escaping @Sendable () async -> Void) -> Effect {
// ...
// 1
let (cancelStream, cancelContinuation) = AsyncStream<Void>.makeStream()
return .task(
// タスク1
Task {
// 2
await withTaskCancellationHandler {
// 3
await withCheckedContinuation { continuation in
// 4
operationStreamContinuation.yield {
// タスク2
let operationTask = Task {
await operation()
cancelContinuation.finish()
}
for await _ in cancelStream {
operationTask.cancel()
}
if Task.isCancelled {
operationTask.cancel()
}
continuation.resume()
}
}
} onCancel: {
cancelContinuation.yield()
}
}
)
}
}
かなり難解だけど分解してみましょう。
間にコミュニケーションするタスクは2個生成されます。おかしく見えるかもしれないが connect
関数のキャンセルとアクション単位のキャンセルの両方をサポートするためです。
- タスク1: すぐ開始されタスク2が完了するまで待つ
- タスク2:
OperationStream
を通ってからconnect
内でwithDiscardingTaskGroup
で開始され実際の処理を実行する
その2個を接続するためにいくつかのステップが必要です。
- もう1つ、タスク1とタスク2のコミュニケーション用の
AsyncStream
を生成する - タスク1がキャンセルされたらタスク2に伝搬する
- タスク1をタスク2の完了まで待機させる
- タスク2を生成して
OperationStream
に流す
これで完成!できたものを ViewModel に突っ込むだけです。
final class ViewModel: ObservableObject {
private let tasks = TaskEffectHandler()
func connect() async {
await tasks.connect()
}
@discardableResult
func handle(_ action: Action) -> Effect {
switch action {
case .buttonTap:
return tasks.add {
try? await Task.sleep(for: .seconds(2))
}
}
}
}
最終的なのコードは Gist で公開しています。