Swift Concurrency時代のViewModelのアクション処理を考え直す

MVVM を実装する方法は様々あります。View-ViewModel の接続に関していうと、リアクティブ系ライブラリを使ってそれぞれの入力と出力を定義し接続することがおそらく最も一般的でしょう。

最近は SwiftUI と Swift Concurrency が登場して、リアクティブ系ライブラリで解決したい課題の多くに対応しています。ただし、乗り換えてみても実装が一筋縄ではいかないことが多いです。

V-VM の接続の課題を分解すると、ViewModel の状態をどのように View にバインドするのかと、View のイベントを検知してどのように ViewModel に渡すのかの2つに分けることができます。

前者は ViewModel を [`@ObservableObject`](https://developer.apple.com/documentation/combine/observableobject) に準拠して状態を [`@Published`](https://developer.apple.com/documentation/combine/published/) で修飾すると大体解決するが、後者に関してはどうでしょう。

アクションごとに ViewModel に関数を置くべきか？それらの内どれが `async` になるべきか？同期的はコンテキストから非同期処理を開始するのは View か ViewModel か？タスクキャンセルはどう処理する？ユニットテストが書けるようには、アクション処理の完了をどうやって検知する？

## 要件

私自身が上記の質問を含め V-VM の接続に関して次のように考えています。

- 状態の更新は ViewModel の状態に反映されるので直接アクションハンドラーから戻すことが要らない。そのため、アクションハンドラーの関数は1つでいい。
- 同期でできる処理ならわざわざ非同期を開始する必要がない。
- アクションを処理する際、非同期処理の開始は ViewModel の責任である。
- View からは、アクションが同期的に処理されるか、非同期的に処理されるかは、知る必要がない。
- アクションの処理が完了したことが、例えばユニットテストを書くとき、View 側でロード中表示をするときなどには必要。
- ViewModel の生存期間が View の生存期間に紐づく。View が消えたら処理途中のアクションがキャンセルされるべき。
- アクションごとのキャンセルも可能な構成がいい。

## インターフェースから考える

この要件を考慮して呼び出し元 （View）で使うインターフーェスは次のように想定しています。

View が ViewModel を自分のライフサイクルに紐づきます。SwiftUI でいうと、[`.task`](https://developer.apple.com/documentation/swiftui/view/task(priority:_:)) モディファイアが一番便利でしょう。そうするとビューが表示されたら ViewModel がそのイベントに購読し、ビューが消えたタイミングでその購読がキャンセルされます。

```swift
.task {
  await viewModel.connect()
}
```

何らかのイベントで、View が ViewModel にアクションを渡します。アクションの処理が完了するまで待つこともできて、待たないこともできる。

```swift
// アクションを渡して完了までは待たない
viewModel.handle(.buttonTap)

// アクションを渡して完了まで待つ
await viewModel.handle(.buttonTap).completion

//　アクションを渡して完了はコールバックで検知する
viewModel.handle(.buttonTap).onCompletion { }
```

## 実装

いざ上記のインターフェースを実装してみるとかなり沼にハマります。以下は私が考え出した答えになります。これが最高のやり方とまで言える自信がないので、同じインターフェースでもっとスマートに書ける方法があればぜひ聞かせてください！

いくつかの部品が必要になります。まずは、アクション完了を抽象化します。

```swift
enum Effect {
  /// アクションが同期的に完了した
  case none

  /// アクションが非同期のタスクを生成した
  case task(Task<Void, Never>)
}
```

完了の通知はここで処理します。

```swift
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`) は、それを呼び出しているタスクのキャンセルを伝搬しアクション処理のタスクもキャンセルします。これでアクションごとのキャンセルに対応しています。

```swift
.task {
  // この task がキャンセルされると、以下のアクション処理もキャンセルされる
  await viewModel.handle(.buttonTap).completion
}
```

次は ViewModel を実装してみましょう。

```swift
final class ViewModel: ObservableObject {
  enum Action {
    case buttonTap
  }
  
  func connect() async {
    // ??
  }
  
  @discardableResult
  func handle(_ action: Action) -> Effect {
    switch action {
    case .buttonTap:
      // return ??
    }
  }
}
```

定義だけはすぐも書けるが、中身に関しては、`handle` で発生するタスクを `connect` のライフサイクルに紐づく必要があるので後回しにします。

ここでヘルパークラスを登場させます。

```swift
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`](https://developer.apple.com/documentation/swift/asyncstream) が適切なのでそれを利用して `OperationStream` を作っています。`connect` 関数で、ストリームの [`continuation`](https://developer.apple.com/documentation/swift/asyncstream/continuation) に流している処理クロージャを受け取って await で実行しています。`continuation` は関数の外から使えるにはクラスのプロパティに保存しています。

最初でやっている処理は、もしも呼ばれたときにすでに購読中のストリームがあればそれを完了しています。それは同時に複数の場所から `connect` されることは非対応しているからです。

[`AsyncStream` 生成の便利関数](https://github.com/apple/swift-evolution/blob/main/proposals/0388-async-stream-factory.md)と[`withDiscardingTaskGroup`](https://github.com/apple/swift-evolution/blob/main/proposals/0381-task-group-discard-results.md)、Swift 5.9 (Xcode 15.0) で登場した機能の2つを使っています。

`withDiscardingTaskGroup` の中でストリーム購読して流したクロージャの一つ一つにグループタスクを生成しています。

`connect` 呼び出し元がキャンセルされると、このグループがキャンセルされて進行中のタスクとストリームもキャンセルされます。一つの注意ポイントは、`AsyncStream` は一回キャンセルされるとストリームが完了したと見なされそれ以降は購読しても要素が流れません。そのため `connect` が呼びされた都度に作り直しています。

最後に残っているのは、`add` 関数の実装です。

 ```swift
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` が呼ばれたらロジックエラーになります。その場合は一旦はワーニングを出力して普通にタスクを生成することにしています。

実装の続きに行きましょう。

```swift
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つ、タスク1とタスク2のコミュニケーション用の `AsyncStream` を生成する
2. タスク1がキャンセルされたらタスク2に伝搬する
3. タスク1をタスク2の完了まで待機させる
4. タスク2を生成して `OperationStream` に流す

これで完成！できたものを ViewModel に突っ込むだけです。

```swift
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](https://gist.github.com/auramagi/46a3cce49ddbb9a33a7c6ff114ee0406) で公開しています。