はじめに
本記事はTCA 1.9.2で検証を行っています。
TCA公式サンプルのTodosでは、リストの各アイテムに対してStoreを持たせる構造になっています。
TODOリストのようなアプリでは、ルートReducerで全てのアクションを受けるよりも、各アイテムがReducerを持ち、自身に関わるアクションを自ら処理できたほうが、責務も明確で実装がクリアになるでしょう。
しかし、リストの全てのアイテムがStoreを持つような実装をすると、アイテムの数が多くなるほど、アクション送信時のパフォーマンスが劣化していくという事象を経験しました。
ライブラリのコードを読んでボトルネックになっていそうな箇所を見つけたので、参考までに解説してみようと思います。
事象を再現してみる
まずは「Storeが多いとアクション送信のパフォーマンスが悪い」を再現します。
TCAのリポジトリには、ライブラリの性能を評価するためのベンチマークのコードがいくつか用意されています。
swift-benchmarkというツールを使って、Store等のパフォーマンスを計測しているようです。
このフォルダに以下のコードスニペットを追加し、ベンチマークを実行してみます。
import Benchmark
import ComposableArchitecture
@Reducer
private struct Parent {
@ObservableState
struct State: Equatable {
var children: IdentifiedArrayOf<Child.State> = []
}
enum Action {
case children(IdentifiedActionOf<Child>)
case tapped
}
var body: some ReducerOf<Self> {
EmptyReducer()
.forEach(\.children, action: \.children) { Child() }
}
}
@Reducer
private struct Child {
@ObservableState
struct State: Equatable, Identifiable {
let id: Int
}
enum Action {
case tapped
}
}
let manyStoresSuite = BenchmarkSuite(name: "Many stores") { suite in
func makeStore(num: Int) -> StoreOf<Parent> {
let children = IdentifiedArrayOf<Child.State>(
uniqueElements: (0...num).map { Child.State(id: $0) }
)
let store = Store(initialState: Parent.State(children: children)) { Parent() }
for i in 0...num {
_ = store.scope(state: \.children[i], action: \.children[id: i])
}
return store
}
let store10 = makeStore(num: 10)
let store100 = makeStore(num: 100)
let store1000 = makeStore(num: 1000)
suite.benchmark("10 stores") {
store10.send(.tapped)
}
suite.benchmark("100 stores") {
store100.send(.tapped)
}
suite.benchmark("1000 stores") {
store1000.send(.tapped)
}
}
コードの内容について簡単に説明します。
まず、ParentとChildという2つのFetureがあり、これらは親子の関係になっています。
Parentは複数のChildを保持することができます。
そして、Parentが保持するChildの数を10
、100
、1000
の3パターン用意し、その数だけStoreを生成します。
Storeを生成したら、ルートのStoreに対してアクションを送信し、その実行時間を計測します。
ベンチマークの実行方法ですが、まずmain.swift
ファイルを開いて以下の内容に編集します。
import Benchmark
import ComposableArchitecture
Benchmark.main([
manyStoresSuite
])
そして、ターミナルで以下のコマンドを実行すればOKです。
$ swift run -c release
...
name time std iterations
------------------------------------------------------------
Many stores.10 stores 24958.000 ns ± 8.12 % 55393
Many stores.100 stores 139667.000 ns ± 6.97 % 9632
Many stores.1000 stores 1283250.000 ns ± 1.57 % 1069
Storeの数が増えるほど、1回のアクション送信にかかる時間が増えていくことがわかります。
アクションを送信したときにTCA内部で起こっていること
store.send(.tapped)
をしたときに、TCA内部ではどのような処理が行われているのでしょうか?
ざっくりかいつまんで説明してみます。
まず、Store.send(_:)
を呼び出すと、Store.send(_:originatingFrom:)
に処理を委譲します。
Store.send(_:originatingFrom:)
はRootStore.send(_:originatingFrom:)
を呼び出します。
@_spi(Internals)
public func send(
_ action: Action,
originatingFrom originatingAction: Action?
) -> Task<Void, Never>? {
return self.rootStore.send(self.fromAction(action))
}
RootStoreは、アプリ全体のStateとReducerを保持する、Storeの親玉?です。
アクションが送信され、Stateが更新されたときにどういう処理が行われるかを理解するために、RootStoreのコードを抜粋しました(Effect周りのコードは無視しています)。
public final class RootStore {
let didSet = CurrentValueRelay(())
private let reducer: any Reducer
private(set) var state: Any {
didSet {
didSet.send(())
}
}
func send(_ action: Any, originatingFrom originatingAction: Any? = nil) -> Task<Void, Never>? {
// ...
var currentState = self.state as! State
// ...
defer {
// ...
self.state = currentState
// ...
}
// ...
let effect = reducer.reduce(into: ¤tState, action: action)
// ...
}
}
まず、現在のStateをcurrentState
という変数に代入しておきます。
次に、ActionとcurrentState
をReducerに渡し、currentState
を更新します。
そして、最後に自身のstate
プロパティをcurrentState
の値に更新します。
このとき、state
プロパティのdidSet
が呼ばれ、CurrentValueRelayというPublisherに準拠したクラスのsend()
メソッドが呼ばれます。
CurrentValueRelayは、アクションがReducerによって処理されたことを各Storeに通知する役割を持っています。
各Storeは通知を受け取り、自身が関知すべき範囲のStateに更新があったかどうかを確認し、更新があった場合はビューにそれを通知するという流れになっています。
Store.init(rootStore:toState:fromAction:)
を見ると、StoreがCurrentValueRelayをサブスクライブしていることがわかります。
このイニシャライザはStore.scope(state:action:)
を呼び出したときに内部で実行されているものです。
private init(
rootStore: RootStore,
toState: PartialToState<State>,
fromAction: @escaping (Action) -> Any
) {
defer { Logger.shared.log("\(storeTypeName(of: self)).init") }
self.rootStore = rootStore
self.toState = toState
self.fromAction = fromAction
#if canImport(Perception)
func subscribeToDidSet<T: ObservableState>(_ type: T.Type) -> AnyCancellable {
let toState = toState as! PartialToState<T>
return rootStore.didSet
.compactMap { [weak rootStore] in
rootStore.map { toState($0.state) }?._$id
}
.removeDuplicates()
.dropFirst()
.sink { [weak self] _ in
guard let self else { return }
self._$observationRegistrar.withMutation(of: self, keyPath: \.currentState) {}
}
}
if let stateType = State.self as? ObservableState.Type {
self.parentCancellable = subscribeToDidSet(stateType)
}
#endif
}
subscribeToDidSet(_:)
の内部で、rootStore.didSet
に対してsinkしています。
rootStore.didSet
はCurrentValueRelayです。
このように、Storeが生成されるたびにCurrentValueRelayのサブスクライバーは増えていきます。
最後に、CurrentValueRelay.send()
の処理を見てみましょう。
func send(_ value: Output) {
self.currentValue = value
for subscription in subscriptions {
subscription.forwardValueToBuffer(value)
}
}
Storeが生成されるたびに、CurrentValueRelay.subscriptions
が増えていきます。
その結果、ここのfor文の実行時間が長くなり、アクションの実行完了までにかかる時間が長くなっていくのではないかと思います。
CurrentValueRelay.didSetをサブスクライブしないようにしてみる
subscribeToDidSet(_:)
を呼び出しているところをコメントアウトしてベンチマークを実行してみると、Storeが増えてもアクション送信にかかる時間は変わらないことがわかります。
このことから、上述した考察は合っているんじゃないかなあと思います。
private init(
rootStore: RootStore,
toState: PartialToState<State>,
fromAction: @escaping (Action) -> Any
) {
// ...
// if let stateType = State.self as? ObservableState.Type {
// self.parentCancellable = subscribeToDidSet(stateType)
//}
}
$ swift run -c release
...
name time std iterations
---------------------------------------------------------
Many stores.10 stores 9833.000 ns ± 10.24 % 132118
Many stores.100 stores 9833.000 ns ± 10.55 % 141192
Many stores.1000 stores 9833.000 ns ± 9.20 % 140850
まとめ
TCAのパフォーマンスに関するドキュメントにも言及がありますが、TCAにおいてアクションを送信するという処理は、メソッド呼び出しのような軽い処理ではありません。
そして、本記事で示したように、Storeを不用意に生成してしまうと、アプリのパフォーマンスが劣化する原因となってしまいます。
TCAのDiscussionsでもこのあたりについて触れられている投稿がありました。
TCAの仕組み的に仕方ない部分なのかもしれませんが、アプリの要件によっては大量のStoreを生成したいケースもあったりするので、今後何か良いAPIが提供されないかと期待しています。