😺

パート5 : TCAでactionをsendして実行されるまで

2023/09/09に公開

はじめに

パート1 ~ 4までViewStore.sendから出発して、stateの更新が行われるまでを確認しました。
最後に副作用の処理を見てactionをsendして実行されるまでを終わりにしようと思います。

副作用の処理

まずは、Store.sendがどんな関数だったかを思い出しましょう。

  @_spi(Internals)
  public func send(
    _ action: Action,
    originatingFrom originatingAction: Action?
  ) -> Task<Void, Never>? {

今回着目するのは、戻り値のTask<Void, Never>?です。

reducerからはこのように副作用が返されます。

    let effect = self.reducer.reduce(into: &currentState, action: action)

effectはOperation Enumに定義された、none、publisher、runの三つで場合分けされます。

switch effect.operation {
      case .none:
        ...
      case let .publisher(publisher):
        ...
	
      case let .run(priority, operation):
        ...
    }

.noneの処理

まずは、noneからみましょう。

      ...
      case .none:
        break
      ...

noneの場合は何も起きません。
副作用がないため、このsendの戻り値はnilとなります。

    guard !tasks.wrappedValue.isEmpty else { return nil }

この場合sendの処理はこれで終わりです。

.publisherの処理

次はもう少し複雑です。

      case let .publisher(publisher):
        var didComplete = false
        let boxedTask = Box<Task<Void, Never>?>(wrappedValue: nil)
        let uuid = UUID()
        let effectCancellable = withEscapedDependencies { continuation in
          publisher
            .handleEvents(
              receiveCancel: { [weak self] in
                self?.threadCheck(status: .effectCompletion(action))
                self?.effectCancellables[uuid] = nil
              }
            )
            .sink(
              receiveCompletion: { [weak self] _ in
                self?.threadCheck(status: .effectCompletion(action))
                boxedTask.wrappedValue?.cancel()
                didComplete = true
                self?.effectCancellables[uuid] = nil
              },
              receiveValue: { [weak self] effectAction in
                guard let self = self else { return }
                if let task = continuation.yield({
                  self.send(effectAction, originatingFrom: action)
                }) {
                  tasks.wrappedValue.append(task)
                }
              }
            )
        }

        if !didComplete {
          let task = Task<Void, Never> { @MainActor in
            for await _ in AsyncStream<Void>.never {}
            effectCancellable.cancel()
          }
          boxedTask.wrappedValue = task
          tasks.wrappedValue.append(task)
          self.effectCancellables[uuid] = effectCancellable
        }

この処理を考えるのに、一つ適当な.publisherを発行するReducerを考えてみましょう。

VideoManagerの実装

動画をロードして、ロードしたことを伝えるためにPassthroughSubjectを渡すVideoManagerを考えます。

struct VideoManager {
    var load: () -> Void
    var videoLoaded: PassthroughSubject<Void, Never>
}

Dependencyでこれを再現すると次のようになります。

extension VideoManager: DependencyKey {
    static let liveValue: Self = {
        let subject = PassthroughSubject<Void, Never>()
        return .init(
            load: { subject.send() },
            videoLoaded: subject
        )
    }()
}

忘れずに使えるようにしておきます。

extension DependencyValues {
  var videoManager: VideoManager {
    get { self[VideoManager.self] }
    set { self[VideoManager.self] = newValue }
  }
}

Reducerの実装

VideoReducerとしましょう。

struct VideoReducer: Reducer {

}

StateにisLoadedというビデオがロードされたどうかのフラグをおきます。

    struct State: Equatable {
        var isLoaded: Bool = false
    }

画面上に動画のロードボタンをおきます。
ロードボタンが押されると、動画のロードが行われ、ロードが完了するとログイン状態を変更します。

    enum Action: Equatable {
        case doLoad
        case loadButtonTapped
        case changeState
    }

処理的には次の通りとします。

    @Dependency(\.videoManager) var videoManager
    
    func reduce(into state: inout State, action: Action) -> Effect<Action> {
        switch action {
        case .doLoad:
            videoManager.load()
            return .none
            
        case .loadButtonTapped:
            return .merge(
                .send(.doLoad),
                .publisher {
                    videoManager
                        .videoLoaded
                        .map { _ in .changeState }
                }
            )
            
        case .changeState:
            state.isLoaded = true
            return .none
        }
    }

loadButtonTappedの処理を次のようにして、明示的にdoLoadをするようにしてみましょう。そうすると.publisherの処理がわかりやすくなります。

        case .loadButtonTapped:
            return .publisher {
                    videoManager
                        .videoLoaded
                        .map { _ in .changeState }
            )

テスト

これが実際どういう動きをするのか、テストを書いてみましょう。

空のテストを用意します。

final class videoManagerTests: XCTestCase {
    @MainActor
    func testVideoManager() async throws {
    
    }
}

今回はテストとは言っても、実際のStore.sendの仕組みを知るのが目的ですのでTestStoreを使用しないでおきます。

Storeを用意して

    @MainActor
    func testVideoManager() async throws {
        let store = Store(
            initialState: VideoReducer.State(),
            reducer: { VideoReducer() }
        )
    }

VideoReducerの処理をシミュレートします。

    @MainActor
    func testVideoManager() async throws {
        ...
        let viewStore = ViewStore(store, observe: { $0 })
        let task = viewStore.send(.loadButtonTapped)
        viewStore.send(.doLoad)
        
        XCTAssertTrue(viewStore.isLoaded)
        
        task.cancel()
    }

おっ、失敗します。Dependencyにtest実装がないと怒られます。

今回のVideoManagerはそもそもがテスト的な実装ですので、そのまま使用しても良いはずです。

withDependenciesでcontextを.liveに指定します。すると、liveValueが使用されるようになりテストが通ります。

    func testVideoManager() async throws {
        let store = withDependencies {
            $0.context = .live
        } operation: {
            Store(
                initialState: VideoReducer.State(),
                reducer: { VideoReducer() }
            )
        }
        ...
    }

StoreTaskとsend

今観察したいのはこの処理です。

       let task = viewStore.send(.loadButtonTapped)

sendで返っているtaskのはStoreTaskです。
これはTask<Void, Never>?をメンバーとしてもっていますが、このTask<Void, Never>?は見覚えがあります。

public struct StoreTask: Hashable, Sendable {
  internal let rawValue: Task<Void, Never>?
  ...
}

先ほど見たStore.sendの戻り値です。

ViewStore.sendの実装を確認すると、_sendからの戻り値をStoreTaskにこめていますが、_sendはStore.sendの呼び出しです。

  public func send(_ action: ViewAction) -> StoreTask {
    .init(rawValue: self._send(action))
  }

このTask<Void, Never>?が何者かということを念頭に続きの処理をみていきましょう。

すぐに完了するpublsiherの処理

まず、case letで受けているpublsiherは先ほどReducerで実装したpublisherが返ってきます。

今回の場合だと、ロードしたらPassThroughSubjectからsendされて.changeStateアクションを呼びます。

      case let .publisher(publisher):

中心となる処理を考える前に.publisherの後半の処理に、didCompleteで弾いている処理がありました。

        if !didComplete {
            ...
        }

今回はPassThroughSubjectを使用しましたが、.publisherでJustが使用されていたら、処理はすぐさま終わりこのブロックには到達しません。

          let task = Task<Void, Never> { @MainActor in
            for await _ in AsyncStream<Void>.never {}
            effectCancellable.cancel()
          }
          boxedTask.wrappedValue = task
          tasks.wrappedValue.append(task)
          self.effectCancellables[uuid] = effectCancellable

receiveCompletionが呼ばれて.noneと同じようにnilが返ります。

    guard !tasks.wrappedValue.isEmpty else { return nil }

待機時間の長いpublisherの処理

今回はPassThroughSubjectで受けており、このような長命なpublisherの場合は処理が異なります。

先ほどからコードに何度か現れているこのAsyncStrema.neverを内包したtaskがStoreTaskのrawValueとして返ります。

          let task = Task<Void, Never> { @MainActor in
            for await _ in AsyncStream<Void>.never {}
            effectCancellable.cancel()
          }

この何も発行しないAsyncStreamがあるおかげで、StoreTaskは副作用のライフサイクルを表現することができ、cancelやfinishなどのハンドルとしての機能を果たすことができます。

/// The type returned from ``Store/send(_:)`` that represents the lifecycle of the effect
/// started from sending an action.

寿命を保っている間receiveValueでactionを受けると再度そのactionでsendします。

              receiveValue: { [weak self] effectAction in
                guard let self = self else { return }
                if let task = continuation.yield({
                  self.send(effectAction, originatingFrom: action)
                }) {
                  tasks.wrappedValue.append(task)
                }
              }

このAsyncStream.neverにを内包するtaskがキャンセルされた場合は、その中にあるAsyncStream.neverもキャンセルされてpublisherの処理も終了します。

receiveCancelによってクリーンアップされてsendの処理はここで終わります。

            .handleEvents(
              receiveCancel: { [weak self] in
                self?.threadCheck(status: .effectCompletion(action))
                self?.effectCancellables[uuid] = nil
              }
            )

問題はpublisherの処理が完了した場合ですが、この場合、このAsyncStream.neverを止めるものが必要になります。

taskがtasks以外にboxedTaskにも入れられているのはこのためで

         let boxedTask = Box<Task<Void, Never>?>(wrappedValue: nil)
	 ...

          let task = Task<Void, Never> { @MainActor in
            for await _ in AsyncStream<Void>.never {}
            effectCancellable.cancel()
          }
          boxedTask.wrappedValue = task

完了時はこのboxedTaskを使用してキャンセル & クリーンアップしてsendの処理を終えます。

             receiveCompletion: { [weak self] _ in
                self?.threadCheck(status: .effectCompletion(action))
                boxedTask.wrappedValue?.cancel()
                didComplete = true
                self?.effectCancellables[uuid] = nil
              }

これで、.publisherの処理は見終わりました。

.runの処理

最後に.runの処理をみます。

こちらも長いですね。

	withEscapedDependencies { continuation in
          tasks.wrappedValue.append(
            Task(priority: priority) { @MainActor in
              #if DEBUG
                let isCompleted = LockIsolated(false)
                defer { isCompleted.setValue(true) }
              #endif
              await operation(
                Send { effectAction in
                  #if DEBUG
                    if isCompleted.value {
                      runtimeWarn(
                        """
                        An action was sent from a completed effect:

                          Action:
                            \(debugCaseOutput(effectAction))

                          Effect returned from:
                            \(debugCaseOutput(action))

                        Avoid sending actions using the 'send' argument from 'Effect.run' after \
                        the effect has completed. This can happen if you escape the 'send' \
                        argument in an unstructured context.

                        To fix this, make sure that your 'run' closure does not return until \
                        you're done calling 'send'.
                        """
                      )
                    }
                  #endif
                  if let task = continuation.yield({
                    self.send(effectAction, originatingFrom: action)
                  }) {
                    tasks.wrappedValue.append(task)
                  }
                }
              )
            }
          )
        }

見通しをつけるために、DEBUGを取り除いてみましょう。

	withEscapedDependencies { continuation in
          tasks.wrappedValue.append(
            Task(priority: priority) { @MainActor in
              await operation(
                Send { effectAction in
                  if let task = continuation.yield({
                    self.send(effectAction, originatingFrom: action)
                  }) {
                    tasks.wrappedValue.append(task)
                  }
                }
              )
            }
          )
        }

MainActorで実行される何らかのTaskをtasksとして返しています。

Task(priority: priority) { @MainActor in
   ...
}

こちらも順番に見ていきましょう。

	      await operation(
                Send { effectAction in
                  if let task = continuation.yield({
                    self.send(effectAction, originatingFrom: action)
                  }) {
                    tasks.wrappedValue.append(task)
                  }
                }
              )

operationは.runのassociated Valueです

      case let .run(priority, operation):

それはこの通り、@Sendableなasyncクロージャーです。

    case run(TaskPriority? = nil, @Sendable (_ send: Send<Action>) async -> Void)

例えば、次のような処理を書いた場合はこのユーザー情報の更新を指します。

.run { send in
    let user = await apiClient.getUser()
    send(.updateUser(user))
}

operationにはこの渡されているsendの具体的な処理が書かれています。sendはActionを引数に指定された処理を行うようですので

@MainActor
public struct Send<Action>: Sendable {
  let send: @MainActor @Sendable (Action) -> Void

  public init(send: @escaping @MainActor @Sendable (Action) -> Void) {
    self.send = send
  }
  ...

その処理を確認します。

先ほどのユーザー情報の更新でいうと、このeffectActionは.updateUser(user)が渡されます。
ここで副作用がなければsendの処理はここで終了します。

                  if let task = continuation.yield({
                    self.send(effectAction, originatingFrom: action)
                  }) {
                    tasks.wrappedValue.append(task)
                  }

しかし、もし.updateUser(user)が別の副作用を発行していた場合はどうなるのでしょうか?

その場合はtasksにもう一個taskが積まれます。

最初のtaskを評価すると、副作用が増えてtasksのcountは2になります。
deferでindexが一つ上がりますが、whileを評価する際にendIndexは2となって、次のtask内でチェーンしたアクションが処理されます。

    return Task {
      await withTaskCancellationHandler {
        var index = tasks.wrappedValue.startIndex
        while index < tasks.wrappedValue.endIndex {
          defer { index += 1 }
          await tasks.wrappedValue[index].value
        }
      } onCancel: {
        var index = tasks.wrappedValue.startIndex
        while index < tasks.wrappedValue.endIndex {
          defer { index += 1 }
          tasks.wrappedValue[index].cancel()
        }
      }
    }

まとめ

長かったactionをsendしてから終わるまで一通り見終わりました。
これでstateの更新から副作用の処理までどのように行われるのかがわかりました。

特に今回は副作用を確認し、publisherにおいては寿命の短いpublisherと長いpublisherで処理が異なるとことを見ました。寿命が長い場合にはAsyncStream.neverを利用しており、receiveValueの場合やタスクがキャンセルされた場合、完了した場合のそれぞれの処理を確認しました。

runの処理については、DEBUG文を取り除いて構造を確認し、tasksのループでチェーンされたアクションでも問題なく動くことを確認できました。

TCAで最も身近なsendの処理も中を見ると非常に高度な仕組みに成り立っていて勉強になります。

次はPresentationStateをメインとしつつ、処理を外観するために飛ばしたいくつかの項目を見返しつつ進めます。

Discussion