🍣

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

2023/08/24に公開

サンプル

単純なViewとReducerを用意します。ボタンを押したらviewStoreでsendするだけの簡単な構成です。Viewを起点にしてまずStoreから見ていきましょう。

import SwiftUI
import ComposableArchitecture

struct AppReducer: Reducer {
    
    struct State: Equatable {
        var count: Int = .zero
        public init() {}
    }
    
    enum Action: Equatable {
        case buttonTap
    }
    
    func reduce(into state: inout State, action: Action) -> ComposableArchitecture.Effect<Action> {
        switch action {
        case .buttonTap:
            state.count += 1
            return .none
        }
    }
}


struct ContentView: View {
    private let store: StoreOf<AppReducer> = .init(initialState: .init(), reducer: { AppReducer() })
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Button("カウント") {
                    viewStore.send(.buttonTap)
                }
                Text(String(viewStore.count))
            }
            .padding()
        }
    }
}

Store

StoreOf

まず最初に目につくのはStoreOfです。StoreOf<R>はStoreのtypealiasで、StateとActionを明示的に指定せずReducerを指定すれば良いだけになっています。

public typealias StoreOf<R: Reducer> = Store<R.State, R.Action>

A convenience type alias for referring to a store of a given reducer's domain.

Store

次にsendを読み解くのに必要なStoreのメンバ変数を見ていきましょう。
Storeはreducerの他に以下の状態変数を持っています。

  1. アクションを制御するための変数
  2. 副作用を処理するための変数
  3. Scopeなどの親子関係を維持するための変数

イニシャライザを確認しつつ簡単にそれぞれのメンバ変数を見ていきます。

public final class Store<State, Action> {
  private var bufferedActions: [Action] = []
  @_spi(Internals) public var effectCancellables: [UUID: AnyCancellable] = [:]
  var _isInvalidated = { false }
  private var isSending = false
  var parentCancellable: AnyCancellable?
  private let reducer: any Reducer<State, Action>
  @_spi(Internals) public var state: CurrentValueSubject<State, Never>

bufferedActions

viewStore.send(.buttonTap)などのようにactionをsendすると、このbufferdActionに溜まっていきます。isSendingとセットで使用され、このisSendingがtrueの場合、送られたきたactionを実行せずにこのbufferdActionに溜めていきます。

effectCancellables

effectCancellablesは、reduceのreturnにおいて、.publisherで返すような場合に、そのcancellableを保持しておきます。.runや.noneにおいては使用されません。

    func reduce(into state: inout State, action: Action) -> ComposableArchitecture.Effect<Action> {
        switch action {
        case .buttonTap:
            state.count += 1
            return .publisher {
                Just(.increment)
                    .delay(for: 1, scheduler: DispatchQueue.main)
            }
        case .increment:
            state.count += 1
            return .none
        }
    }

なお、副作用を表すEffect型はoperationとしてrun、publisher、noneの3つしかありません。

public struct Effect<Action> {
  @usableFromInline
  enum Operation {
    case none
    case publisher(AnyPublisher<Action, Never>)
    case run(TaskPriority? = nil, @Sendable (_ send: Send<Action>) async -> Void)
  }

  @usableFromInline
  let operation: Operation

  @usableFromInline
  init(operation: Operation) {
    self.operation = operation
  }
}

_isInvalidated

これはifLetやPresentatinoStateに使用されているようです。今回の例ではあまり絡みはないので割愛します。

isSending

bufferedActionsと一緒に使用されます。使用用途は同上です。

parentCancellable

Scopeなどで子のReuducerを管理する場合、resopeなどでこのparentCancellableに親のstateを紐づけます。

@inlinable
func rescope<ScopedState, ScopedAction, RescopedState, RescopedAction>(
    _ store: Store<ScopedState, ScopedAction>,
    state toRescopedState: @escaping (ScopedState) -> RescopedState,
    action fromRescopedAction: @escaping (RescopedState, RescopedAction) -> ScopedAction?,
    removeDuplicates isDuplicate: ((RescopedState, RescopedState) -> Bool)?
  ) -> Store<RescopedState, RescopedAction> {
	...
	// ↓ここ
	childStore.parentCancellable = store.state
	      .dropFirst()
	      .sink { [weak childStore] newValue in
	guard !reducer.isSending, let childStore = childStore else { return }
        let newValue = toRescopedState(newValue)
        guard isDuplicate.map({ !$0(childStore.state.value, newValue) }) ?? true else {
          return
        }
        childStore.state.value = newValue
      }
	...
  }

reducer

storeを生成する際に渡したreducerです。

private let store: StoreOf<AppReducer> = .init(initialState: .init(), reducer: { AppReducer() })

なんでclosureで渡してるんだっていうところですが、ここはReducerを実装する際にreduceとbodyを選択できますがそこで見ようかと思います。今はとりあえずreducerが渡されると考えます。

    @ReducerBuilder<State, Action> reducer: () -> R,

state

parentCancellableで見たように、このstateを介して親の状態変更を子に伝えます。

WithViewStore

実際にsendするときは、WithViewStoreが返すviewStoreからsendしています。
こいつは何者でしょうか?

struct ContentView: View {
    private let store: StoreOf<AppReducer> = .init(initialState: .init(), reducer: { AppReducer() })
    
    var body: some View {
        WithViewStore(store, observe: { $0 }) { viewStore in
            VStack {
                Button("カウント") {
                    viewStore.send(.buttonTap)
                }
                Text(String(viewStore.count))
            }
            .padding()
        }
    }
}

WithViewStoreのコメントにも書かれている通り、storeの状態管理を実際には手動でこのように行うことができます。WithViewStoreはこのヘルパーです。

struct ProfileView: View {
   let store: StoreOf<Profile>
   @ObservedObject var viewStore: ViewStoreOf<Profile>

   init(store: StoreOf<Profile>) {
     self.store = store
     self.viewStore = ViewStore(store, observe: { $0 })
   }

   var body: some View {
     Text("\(self.viewStore.username)")
     // ...
   }
 }

Debug用のコードを除いた状態でみるとその様子が分かりやすいかと思います。

public struct WithViewStore<ViewState, ViewAction, Content: View>: View {
  private let content: (ViewStore<ViewState, ViewAction>) -> Content
  @ObservedObject private var viewStore: ViewStore<ViewState, ViewAction>

ViewStore

WithViewStoreがメンバとして持っているViewStoreを見ていきます。

ViewStoreはstateの管理とactionのsendを司ります。ViewStoreはカスタムのObservableObjectです。

public final class ViewStore<ViewState, ViewAction>: ObservableObject {
  public private(set) lazy var objectWillChange = ObservableObjectPublisher()
  let _isInvalidated: () -> Bool
  private let _send: (ViewAction) -> Task<Void, Never>?
  fileprivate let _state: CurrentValueRelay<ViewState>
  private var viewCancellable: AnyCancellable?

これによって何をやりたいかはイニシャライザをみるとわかります。
Viewの更新をstateで監視したい変数に限って行いたいためにカスタムのObservableObjectとしてViewStoreが定義されています。

  public init<State>(
    _ store: Store<State, ViewAction>,
    observe toViewState: @escaping (_ state: State) -> ViewState,
    removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool
  ) {
    self._send = { store.send($0, originatingFrom: nil) }
    self._state = CurrentValueRelay(toViewState(store.state.value))
    self._isInvalidated = store._isInvalidated
    self.viewCancellable = store.state
      .map(toViewState)
      .removeDuplicates(by: isDuplicate)
      .sink { [weak objectWillChange = self.objectWillChange, weak _state = self._state] in
        guard let objectWillChange = objectWillChange, let _state = _state else { return }
        objectWillChange.send()
        _state.value = $0
      }
  }

PublisherにはremoveDuplicatesという便利な関数が用意されており、これによって前回の値と異なる場合のみにpublishしてしてくれます。

    public func removeDuplicates(by predicate: @escaping (Self.Output, Self.Output) -> Bool) -> Publishers.RemoveDuplicates<Self>

デフォルトでは次のように==が指定されています。

  public init<State>(
    _ store: Store<State, ViewAction>,
    observe toViewState: @escaping (_ state: State) -> ViewState,
    @ViewBuilder content: @escaping (_ viewStore: ViewStore<ViewState, ViewAction>) -> Content,
    file: StaticString = #fileID,
    line: UInt = #line
  ) {
    self.init(
      store: store.scope(state: toViewState, action: { $0 }),
      removeDuplicates: ==,
      content: content,
      file: file,
      line: line
    )
  }

別途指定できるイニシャライザもあるので、下の事例のようにidが変更した時だけviewの更新をかけたい場合(不用意なViewの更新を抑制したい場合)は使ってみた方が良さそうです。

https://github.com/pointfreeco/swift-composable-architecture/issues/420#issuecomment-907507731

肝心のsendは次のとおりで、_sendは、Storeのsendを呼び出しますので、再度Storeを見にいきます。

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

self._send(action)はここを見ます。

public final class ViewStore<ViewState, ViewAction>: ObservableObject {
  ...
  private let _send: (ViewAction) -> Task<Void, Never>?
  ...

  public init<State>(
    _ store: Store<State, ViewAction>,
    observe toViewState: @escaping (_ state: State) -> ViewState,
    removeDuplicates isDuplicate: @escaping (_ lhs: ViewState, _ rhs: ViewState) -> Bool
  ) {
    self._send = { store.send($0, originatingFrom: nil) }
   ...
  }
}

これでsendに至るまでの前準備が終わりました。後編からはこのStoreのsendメソッドを見てみます。

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

Discussion