11
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Posted at

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

11
11
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
11

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?