📑

Performance から学ぶ TCA のベストプラクティス

2022/12/21に公開約11,600字

この記事は The Composable Architecture Advent Calendar 2022 の 22 日目の記事になります。

本記事では TCA をある程度理解している方向けに、TCA の Performance というドキュメントから学べる話について紹介しようと思います。

この記事では以下を理解できるようになることを目指しています。

  • Performance ドキュメントに大まかに何が記載されているのか
  • ViewState による局所的な state の observe の実現方法と、その利用用途での ViewState の利用方針
  • Reducer でロジックを共有する際のベストプラクティス

Performance のドキュメントから学べること

Performance というドキュメントのリンクはこちらです。

このドキュメントには以下の 5 項目が現時点で記載されています。

  • View stores
  • Sharing logic with actions
  • CPU-intensive calculataions
  • High-frequency actions
  • Compiler performance

「Peformance」というドキュメント名の通り、上記の 5 つの観点から TCA を利用する際にパフォーマンスを損なわないためのテクニックが記載されているドキュメントになっています。
ただ、このドキュメントを読んでみたところ、パフォーマンスに関わる話だけではなく、よりベターな TCA の使い方のようなものについても記載されている印象を個人的には受けました。

そこで、本記事では Performance に書かれている内容を一部簡単に説明した上で、個人的な感想も交えながら TCA のベストプラクティスのようなものを探っていきたいと思います。

View stores

まず View stores です。

この項目には、WithViewStore を利用する際に気をつけるべき話について記載されています。

以前まで WithViewStore は以下のような形で扱うことができていました。

WithViewStore(self.store) { viewStore in
  // ...
}

しかし現在この形式は deprecated となっており、以下のように observe を明示することが推奨されています。 (経緯はこちら)

WithViewStore(self.store, observe: { $0 }) { viewStore in
  // ...
}

元々どの state を observe するのかが暗黙的でしたが、現在は何の state を observe するのかが明示的になっています。
ちなみに observe: { $0 } という形式であれば、全ての state を observe することになるため、何らかの state が変更されたタイミングで WithViewStore 利用部分の View が再計算されます。
そのため WithViewStore を利用して state の observe を行わなくても良い部分では、WithViewStore を利用しないようにするのがベターです。

// 例えば以下のような State を持っている時、
struct AppState {
  var activity: Activity.State
  var search: Search.State
  var profile: Profile.State
}

// AppView で state の変更を observe する必要がない場合は、
// WithViewStore を利用せずに、単純に store を渡すだけで良い
struct AppView: View {
  let store: StoreOf<AppReducer>

  var body: some View {
    TabView {
      ActivityView(
        store: self.store
          .scope(state: \.activity, action: AppAction.activity)
      )
      SearchView(
        store: self.store
          .scope(state: \.search, action: AppAction.search)
      )
      ProfileView(
        store: self.store
          .scope(state: \.profile, action: AppAction.profile)
      )
    }
  }
}

上記は AppView 自体では state の変更を observe する必要がない例となっています。
しかし、AppView で何らかの state の変更を observe しようとした場合に問題が起きます。

struct AppState {
  var activity: ActivityState
  var search: SearchState
  var profile: ProfileState
  // selectedTab という AppView で変更を observe したい state が追加された
  var selectedTab: Tab
  enum Tab { case activity, search, profile }
}

struct AppView: View {
  let store: Store<AppState, AppAction>

  var body: some View {
    // この場合 `selectedTab` の変更を observe する必要があるため、
    // `observe: { $0 }` とする必要が出てくる
    WithViewStore(self.store, observe: { $0 }) { viewStore in
      TabView(
        selection: viewStore.binding(state: \.selectedTab, send: AppAction.tabSelected
      ) {
        ActivityView(
          store: self.store.scope(state: \.activity, action: AppAction.activity)
        )
        .tag(AppState.Tab.activity)
        SearchView(
          store: self.store.scope(state: \.search, action: AppAction.search)
        )
        .tag(AppState.Tab.search)
        ProfileView(
          store: self.store.scope(state: \.profile, action: AppAction.profile)
        )
        .tag(AppState.Tab.profile)
      }
    }
  }
}

このように、AppView で何らかの state を observe する必要が出てきた場合に observe: { $0 } としてしまうと、本来 AppView の再計算に必要となる selectedTab 以外の状態の変更も observe することになってしまい、パフォーマンス的に無駄が生じてしまいます。

上記の例の場合、AppView の再計算に必要な state は selectedTab だけであるため、以下のように KeyPath を使って簡単に修正することができます。

WithViewStore(self.store, observe: \.selectedTab) { viewStore in
  TabView(selection: viewStore.binding(send: AppAction.tabSelected)) {
    // ...
  }
}

上記のような方法は View の再計算のために必要な state が 1 つのみである場合は問題ないですが、2 つ以上になってくるとタプルを利用したりする必要が出てきて、冗長になってしまいます。

WithViewStore(
  self.store, 
  observe: { (selectedTab: $0.selectedTab, unreadActivityCount: $0.activity.unreadCount) },
  removeDuplicates: ==
) { viewStore in 
  // ...
}

上記では、タプルは Equatable ではないため removeDuplicates に明示的に == を提供する必要が出てきています。
このように必要な state が 2 つ以上になってきた場合は、ViewState のようなものを作ってそれを利用することが推奨されています。

struct AppView: View {
  let store: StoreOf<AppReducer>
  
  struct ViewState: Equatable {
    let selectedTab: AppState.Tab
    let unreadActivityCount: Int
    init(state: AppReducer.State) {
      self.selectedTab = state.selectedTab
      self.unreadActivityCount = state.activity.unreadCount
    }
  }
  
  var body: some View {
    WithViewStore(self.store, observe: ViewState.init) { viewStore in
      // ...
    }
  }
}

このように ViewState を用意すれば、observe に必要な state が増えても ViewState に property を追加すれば問題なく対応することができるようになります。

ただ気をつけたいのが、このように state を絞って observe する手法は、アプリの Root node であればあるほど効果的で、逆に Leaf node (末端) であればあるほど効果は薄くなるという話がドキュメントにも記載されています。
ViewState のような struct を導入することによって、何らかの state を TCA における State に追加したり減らしたりしたいとなった場合の改修コストは少しだけ膨らみますし (実際 Leaf node の方にも ViewState を導入しようとしたことがありますが、結構面倒でした)、可読性も少しだけ落ちてしまうとは思うため、基本的にアプリの Root node に近い機能では積極的に ViewState を導入して View の過剰な再計算を防ぐようにしつつ、Leaf node の方では observe: { $0 } を利用していくのが良いとされています。

Sharing logic with actions

次に Sharing logic with actions です。
個人的にはこの項目に書かれていることは非常に重要だと感じていて、Reducer の実装を行う上で気をつけたいポイントについて記載されていたので、それについて触れつつ紹介しようと思います。

ここでは、Reducer でロジックを共有する際に気をつけるべきことが記載されています。

Reducer でロジックを共有する際の一つの方法として、共有したいロジックのための Action を作成して、その Action を呼び出すという方法があります。

struct Feature: ReducerProtocol {
  struct State {
    // ...
  }
  enum Action {
    // ...
  }

  func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
    case .buttonTapped:
      state.count += 1
      // 各 Action からは共有 Action を return する
      return EffectTask(value: .sharedComputation)

    case .toggleChanged:
      state.isEnabled.toggle()
      return EffectTask(value: .sharedComputation)

    case let .textFieldChanged(text):
      state.description = text
      return EffectTask(value: .sharedComputation)

    // 共有したいロジックを記述する用の Action
    case .sharedComputation:
      return .run { send in
        // ...
      }
    }
  }
}

この方法だとロジックを共有するために Action を余分に一つ send する必要があります。
TCA において、Action を余分に send するという行為は、軽微なものではあるもののパフォーマンスに無駄を生じさせてしまいます。

そしてロジックをこの方法で共有することについて、パフォーマンスの他にもいくつかの問題があることがドキュメントで指摘されていました。(個人的にはこれらの問題が Reducer を書く上で非常に重要なものだと感じています)

  • 設計的に柔軟ではない
    • ロジックの共有用の Action は、コアロジックを実行する他の Action の処理の後に呼び出されることが想定されて作成されている
    • そのため、ロジックの共有用の Action を他の Action の処理の前に呼び出したいとなった場合、対応することが難しい
  • テストコードを複雑にさせる
    • Action を次から次へと呼び出すことになるため、テストで Assert しなければいけない Action が増える
    • そもそも共有用の Action によって実行されるロジックは内部的な処理である = テストで詳細を気にしなくても良いものであるため、それを Assert しなければいけなくなってしまうとテストコードが読みにくくなってしまう

テストコードの話については、以下のコードを見るとわかりやすいと思います。

let store = TestStore(
  initialState: Feature.State(), 
  reducer: Feature()
)

store.send(.buttonTapped) {
  $0.count = 1
}
store.receive(.sharedComputation) {
  // Assert on shared logic
}
store.send(.toggleChanged) {
  $0.isEnabled = true
}
store.receive(.sharedComputation) {
  // Assert on shared logic
}
store.send(.textFieldChanged("Hello") {
  $0.description = "Hello"
}
store.receive(.sharedComputation) {
  // Assert on shared logic
}

buttonTapped, toggleChanged, textFieldChanged("Hello") というユーザーによる Action が発火する度に sharedComputation の Action が発火してしまっていることがわかります。
このテストでは、内部のロジックの詳細をテストしなければいけなくなっているのと、テストコードが純粋なユーザーの Action 以外でまみれてしまっているので、トップダウンでテストコードを読むことも難しくなっているという問題があります。

これらのような問題を解決する方法として提案されているのが、以下のように Reducer にロジック共有用のメソッドを定義するという方法です。 (ReducerProtocol のおかげで簡単にこのようなメソッドを切り出せるようになっています)

struct Feature: ReducerProtocol {
  struct State {
    // ...
  }
  enum Action {
    // ...
  }

  func reduce(into state: inout State, action: Action) -> EffectTask<Action> {
    switch action {
    case .buttonTapped:
      state.count += 1
      // ロジック共有用のメソッドを呼び出す
      return self.sharedComputation(state: &state)

    case .toggleChanged:
      state.isEnabled.toggle()
      return self.sharedComputation(state: &state)

    case let .textFieldChanged(text):
      state.description = text
      return self.sharedComputation(state: &state)
    }
  }

  // メソッドにロジックを切り出す
  // state を変更したい場合は `inout` にすれば良い
  // Action を return することもできる
  func sharedComputation(state: inout State) -> EffectTask<Action> {
    // Some shared work to compute something.
    return .run { send in
      // A shared effect to compute something
    }
  }
}

ロジック共有用のメソッドを切り出したことによって、不要な Action を呼び出さなくても良い設計になり、パフォーマンス的な無駄もなくなりました。

また、前述した二つの問題もこの方法で解決することができます。

まず、ロジック共有用の Action をコアロジックの前に実行することが難しいという問題がありましたが、メソッドに切り出したことによって、簡単にコアロジックの前に共有ロジックを実行することが可能となっています。

case .buttonTapped:
  // コアロジックの前に共有ロジックを実行できる
  let sharedEffect = self.sharedComputation(state: &state)
  state.count += 1
  return sharedEffect

また、テストの問題も解決することができて、ロジック共有用の Action がなくなったことによって、その Action の Assert を行う必要がなくなり、テストコードが読みにくい問題も解消することができます。

let store = TestStore(
  initialState: Feature.State(), 
  reducer: Feature()
)

store.send(.buttonTapped) {
  $0.count = 1
  // Assert on shared logic
}
store.send(.toggleChanged) {
  $0.isEnabled = true
  // Assert on shared logic
}
store.send(.textFieldChanged("Hello") {
  $0.description = "Hello"
  // Assert on shared logic
}

この「Sharing logic with actions」の項目から、個人的な経験を含めた TCA で Reducer を実装する際のベストプラクティス的なものを改めて以下にまとめてみます。(主観がだいぶ入っているかもしれません)

  • Reducer でロジックを共有したい場合は Action を作るのではなくメソッドを切り出すようにするべき
    • これはここまでの話で十分に説明されていると思っています
  • Action はできるだけ増やさないようにした方が良い
    • ここまでの説明で Action の数を増やすと「パフォーマンスへの影響・テストコードの複雑化」に繋がることはおおよそ理解できるかなと思います
    • また、個人的な経験からも Action を無闇に増やすとコード自体も複雑になりがちだと感じています
      • 増やした Action が意図していない部分から呼ばれるようになってコードが複雑になったりしたこともありました
      • TCA を使う際は、良い感じに Action を分割して見通しを良くするよりも、一つの Action でどんな変更が起こるかわかるようになっていた方が結果としてコードの見通しが良く、テストコードもわかりやすいものになっていきそうという気持ちがあります (個人の主観がだいぶ含まれていそうですが)
  • Action は使い回さないようにした方が良い
    • 使い回すと、「増やした Action が意図していない部分から呼ばれるようになってコードが複雑になったりする可能性」が高まると思っています
    • API Request を行う場合、以下のコードのように、ユーザー起因の Action ごとに Request が行われるような形にすると、アプリケーションのコード・テストコードもシンプルになって良いと感じています
case .〇〇ButtonTapped:
  // ...
  return .task { .〇〇Request }
case .〇〇Request:
  // ...

// ↑ ではなく ↓

case .〇〇ButtonTapped:
  // ...
  // ここで API Request
  • (主に) ユーザー起因の Action 以外がテストに漏れ出すようになったら、コードベースに改善の余地がありそう
    • 〇〇Request や例に出ていた sharedComputation もそうですが、(主に) ユーザー起因の Action 以外がテストに漏れ出すようになっていたら、何らかの方法で改善できる余地がありそうだと感じています
    • 〇〇Response<TaskResult<...>> のように Effect を受けるための Action はある程度必要だとは思いますが、削れる Action はどんどん削っていった方が良さそうという考えです

CPU-intensive calculations

こちらについては、ドキュメントに書かれている通りで、CPU に負荷のかかりそうな処理は、できるだけ EffectTask 内で扱うようにしつつ、適宜 Task.yield() を利用して、パフォーマンス問題を解決しましょうという話になっています。

追加で言及することは思いつかなかったので、詳しくはドキュメントをご参照ください。

High-frequency actions

こちらについてもドキュメントに書かれている通りで、TCA における Action の send は多少コストがかかる処理なので、Action を send しまくっている部分があれば、処理を工夫できないか検討しようという話になっています。

こちらについても追加で言及することは思いつかなかったので、詳しくはドキュメントをご参照ください。

Compiler performance

こちらでは、大きな SwiftUI アプリケーションにおいて WithViewStore を用いている際に、コンパイラのパフォーマンスが低下したりする場合の解決策について書かれています。

こちらについても大きく言及することはないのですが、公式から viewStore@ObservedObject として保持しても問題ないという話が述べられているドキュメントであり、今後場合によって viewStore@ObservedObject として保持する際の参考ドキュメントとすることができそうです。

おわりに

この記事では TCA の Performance というドキュメントから学べることについて、自分の解釈も踏まえて書いてみました。
元のドキュメントの内容を省略している部分があったり、自分の解釈が誤っている可能性もあるので、ぜひ元ドキュメントも見ていただけたらと思います!

Discussion

ログインするとコメントできます