11
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【TCA】Storeの数が多くなるほどアクション送信のパフォーマンスが劣化していく

Last updated at Posted at 2024-02-18

はじめに

本記事は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の数を101001000の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: &currentState, 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が提供されないかと期待しています。

11
3
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
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?