パート3 : TCAでactionをsendして実行されるまで
前回はStore.sendの処理を上から確認し再入可能なactionの処理ついて見ました。
今回はその続きのbufferdActionsからactionを取り出しこれをreducerに渡すところからです。
このreducerは私たちが実装したreducerではなくScopedReducerと呼ばれるreducerです。
今回は現状のScopedReducerの処理を見るのにその実装経緯などいくつか考古学していきます。
@_spi(Internals)
public func send(
_ action: Action,
originatingFrom originatingAction: Action?
) -> Task<Void, Never>? {
self.threadCheck(status: .send(action, originatingAction: originatingAction))
// bufferedActionsへの溜め込み
self.bufferedActions.append(action)
guard !self.isSending else { return nil }
// 再入可能actionの処理
self.isSending = true
var currentState = self.state.value
let tasks = Box<[Task<Void, Never>]>(wrappedValue: [])
defer {
withExtendedLifetime(self.bufferedActions) {
self.bufferedActions.removeAll()
}
self.state.value = currentState
self.isSending = false
if !self.bufferedActions.isEmpty {
if let task = self.send(
self.bufferedActions.removeLast(), originatingFrom: originatingAction
) {
tasks.wrappedValue.append(task)
}
}
}
// ↓ここから
var index = self.bufferedActions.startIndex
while index < self.bufferedActions.endIndex {
defer { index += 1 }
let action = self.bufferedActions[index]
let effect = self.reducer.reduce(into: ¤tState, action: action)
...
ScopedReducer
さてself.reducer.reduceは一見すると私たちが実装したReducerのreduceが呼ばれるように見えます。ここでreduce関数から帰ってきた副作用がeffectとして返されているのがわかります。
しかし、実際にはこのreducer.reduceはこのScopedReducerを介してコールされます。
これは、WithViewStoreを介した際にrescopeという処理が行われるためです。
現在のScopedReducerに至るまでの経緯を振り返ってふたたび現代の実装を見比べることにします。
Stateのチェーン
TCAリリース初期の実装ではScopedReducerは存在せず、単にドメインを絞ったStoreを作成し、それを親のStoreとチェーンしている実装でした。
public func scope<LocalState, LocalAction>(
state toLocalState: @escaping (State) -> LocalState,
action fromLocalAction: @escaping (LocalAction) -> Action
) -> Store<LocalState, LocalAction> {
let localStore = Store<LocalState, LocalAction>(
initialState: toLocalState(self.state),
reducer: { localState, localAction in
self.send(fromLocalAction(localAction))
localState = toLocalState(self.state)
return .none
}
)
localStore.parentCancellable = self.$state
.sink { [weak localStore] newValue in localStore?.state = toLocalState(newValue) }
return localStore
}
ScopedReducerの誕生
現在と同様のScopedReducerが誕生したのはこのPRですが
最初にそのアイディアが出たのはこのDiscussionです。
当時の挙動の問題についてはこのDiscussionを提起した方がサンプルを置いてくれていたのでそれで確認することができます(今でもそのまま落としてきて普通に動いて確認することができます)。
当時のTCAはv0.39で、scopeの実装はこんな感じでした。
// scopeによって新たに生成されたstore
let localStore = Store<LocalState, LocalAction>(
initialState: toLocalState(self.state.value),
reducer: .init { localState, localAction, _ in
isSending = true
defer { isSending = false }
let task = self.send(fromLocalAction(localAction))
localState = toLocalState(self.state.value)
if let task = task {
return .fireAndForget { await task.cancellableValue }
} else {
return .none
}
},
environment: ()
)
仮に今ChildStoreA、ChildStoreB、ChildStoreCがあったとすると、ChildStoreAがscopeしてChildStoreBにこのlocalStoreを渡し、ChildStoreBがscopeしてChildStoreCにさらにlocalStoreを渡します。
ChildStoreCでactionをsendすると、ChildStoreCはChildStoreBのsendを呼び出し、ChildStoreBはChildStoreAのsendを呼び出しまします。Viewの階層が深くなり、D、E、Fとstoreのネストが深くなるとsendのrecursionとなり、このような長大なスタックトレースとなります。
このような呼び出しを回避するために@iampatbrownさんによってScopedReducerが提案されました。
scope関数は次のようになり、reducer.rescopeによってchildStoreが返るようになりました(初回はrescopeせずに後続の処理でScopedReducerを返します)。
public func scope<ChildState, ChildAction>(
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: @escaping (ChildAction) -> Action
) -> Store<ChildState, ChildAction> {
self.threadCheck(status: .scope)
#if swift(>=5.7) && !DEBUG
if let childStore = self.reducer.rescope(self, state: toChildState, action: fromChildAction) {
return childStore
}
#endif
let reducer = ScopedReducer(store: self, state: toChildState, action: fromChildAction)
let childStore = Store<ChildState, ChildAction>(
initialState: toChildState(self.state.value),
reducer: reducer
)
childStore.parentCancellable = self.state
.dropFirst()
.sink { [weak childStore] newValue in
guard !reducer.isSending else { return }
childStore?.state.value = toChildState(newValue)
}
return childStore
}
rescopeはReducerProtocolのextensionに定義されています。自身が_ScopedReducerに準拠している場合にrescopeします。
extension ReducerProtocol {
func rescope<ChildState, ChildAction>(
_ store: Store<State, Action>,
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: @escaping (ChildAction) -> Action
) -> Store<ChildState, ChildAction>? {
(self as? any _ScopedReducer)?.rescope(store, state: toChildState, action: fromChildAction)
}
}
protocol _ScopedReducer {
func rescope<ChildState, ChildAction, NewChildState, NewChildAction>(
_ store: Store<ChildState, ChildAction>,
state toNewChildState: @escaping (ChildState) -> NewChildState,
action fromNewChildAction: @escaping (NewChildAction) -> ChildAction
) -> Store<NewChildState, NewChildAction>?
}
rescopeの実装はこのようになっていおり、reducerのstoreを再利用することで先ほどの再起的なsendが起こらないようになっています。
extension ScopedReducer: _ScopedReducer {
func rescope<ChildState, ChildAction, NewChildState, NewChildAction>(
_ store: Store<ChildState, ChildAction>,
state toNewChildState: @escaping (ChildState) -> NewChildState,
action fromNewChildAction: @escaping (NewChildAction) -> ChildAction
) -> Store<NewChildState, NewChildAction>? {
guard
let toChildState = self.toChildState as? (ParentState) -> ChildState,
let fromChildAction = self.fromChildAction as? (ChildAction) -> ParentAction
else { return nil }
let reducer = ScopedReducer<ParentState, ParentAction, NewChildState, NewChildAction>(
store: self.store,
state: { toNewChildState(toChildState($0)) },
action: { fromChildAction(fromNewChildAction($0)) }
)
let childStore = Store<NewChildState, NewChildAction>(
initialState: toNewChildState(store.state.value),
reducer: reducer
)
childStore.parentCancellable = store.state
.dropFirst()
.sink { [weak childStore] newValue in
guard !reducer.isSending else { return }
childStore?.state.value = toNewChildState(newValue)
}
return childStore
}
}
ScopedReducerは名前こそReducerProcolの実装の際に生成され存在していましたが、実装は@iampatbrownさんの実装前までは、根本的にはチェーンは同じままでした。ScopedReducerのstoreにselfを渡すため同じようにsend呼び出しが長くなる可能性がありました。
public func scope<ChildState, ChildAction>(
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: @escaping (ChildAction) -> Action
) -> Store<ChildState, ChildAction> {
self.threadCheck(status: .scope)
let reducer = ScopedReducer(store: self, state: toChildState, action: fromChildAction)
let childStore = Store<ChildState, ChildAction>(
initialState: toChildState(self.state.value),
reducer: reducer
)
childStore.parentCancellable = self.state
.dropFirst()
.sink { [weak childStore] newValue in
guard !reducer.isSending else { return }
childStore?.state.value = toChildState(newValue)
}
return childStore
}
private final class ScopedReducer<State, Action, ChildState, ChildAction>: ReducerProtocol {
let store: Store<State, Action>
let toChildState: (State) -> ChildState
let fromChildAction: (ChildAction) -> Action
private(set) var isSending = false
@inlinable
init(
store: Store<State, Action>,
state toChildState: @escaping (State) -> ChildState,
action fromChildAction: @escaping (ChildAction) -> Action
) {
self.store = store
self.toChildState = toChildState
self.fromChildAction = fromChildAction
}
@inlinable
func reduce(into state: inout ChildState, action: ChildAction) -> Effect<ChildAction, Never> {
self.isSending = true
defer {
state = self.toChildState(self.store.state.value)
self.isSending = false
}
if let task = self.store.send(self.fromChildAction(action)) {
return .fireAndForget { await task.cancellableValue }
} else {
return .none
}
}
}
最終的な実装ではこのstoreがrootという変数に集約されてマージされたようです。
ReducerProcolを変更しないように書き換えているのでAnyScopeとなっています。
ただ、これで現在のScopedReducerに近い形になり、またrootStoreなどの変数が存在する理由なども明らかになりました。
private final class ScopedReducer<RootState, RootAction, State, Action>: Reducer {
let rootStore: Store<RootState, RootAction>
let toScopedState: (RootState) -> State
private let parentStores: [Any]
let fromScopedAction: (State, Action) -> RootAction?
private(set) var isSending = false
ScopedReducerの説明でちょうど切りが良いので続きは後続のパートで。
次はreducer.bodyを見ます。
Discussion