コンテンツへスキップ

The Composable Architecture (TCA) 1.0への移行

大幅にモダナイズされたTCA 1.0のAPIへアプリを移行した際の学びと、移行後のコード構成を共有します。

The Composable Architecture (TCA) 1.0への移行

はじめに・結果

The Composable Architecture(TCA)のバージョン0.35.0で構築していた自分のアプリRemafoXを、新しい1.0スタイルのAPIに移行しました。0.35.0のリリースから現在の1.0ベータまでの期間は1年未満ですが、その短い期間に大きな変更を含むフィーチャーリリースが27回も行われたことは注目に値します。Point-Freeチームは本当にSwift開発者のためのアプリ開発改善に全力を注いでおり、TCAはその集大成です。彼らの優れた上級Swiftビデオシリーズで取り上げるほぼすべてのトピックがTCAに何らかの影響を与えています。現在のバージョン0.52.0に至るまで、ほぼソース互換を維持してきましたが、1.0リリースでは、しばらくの間非推奨とマークされていた多くの古いスタイルのAPIを廃止する予定です。

アプリのアーキテクチャやコーディング規約について常に過去の判断を見直すのは非常に非効率だと考えているため、TCAの最近の改善についてキャッチアップするのにあまり時間を割いていませんでした。ただし、全体的な方向性は把握するよう注意はしていました。しかし、ライブラリのマイルストーンバージョン1.0のリリースが近づいていること、そしてRemafoXの大きな機能の開発を次に計画していることから、今後のアプリ構成のベストプラクティスを再検討し学ぶ良いタイミングだと判断しました。

ありがたいことに、TCAの基本的なコンセプトはまったく変わっていません。しかし、フィーチャーの接続方法、ナビゲーションの仕組み、非同期処理の宣言方法、さらには依存関係の受け渡し方法に至るまで、すべて最新のSwift機能を活用してより良い方向に大幅に変更されています。そのため、把握すべきことや移行すべきことが多く、これらの変更領域をすべて一度に取り組みました。ただし管理しやすくするために、SwiftPMでモジュール化された33のUIフィーチャーをモジュールごとに変更を適用していきました。

移行プロセスの主なポイントを先にお伝えします:

  1. 移行完了までに丸1週間(約5日間)かかりました。

  2. コードベースが2,500行削減され、約7%の削減になりました。

  3. いくつかのナビゲーションバグ、スレッドの問題、SwiftUIのグリッチが修正されました。

  4. コードがはるかに理解しやすく、ナビゲートしやすく、推論しやすくなりました。

テストについてですが、すべてのテストは実際にはまだパスしており、テストコードに変更を加える必要はありませんでした。その理由は、現在のテストがデータの解析、ファイルの検索、Stringsファイルへの変更といった非UIフィーチャーのみを対象としているためです(一部はかなり広範なテストがあります)。UIのテストも書くことを検討しましたが、アプリの初回リリースの予定からすでに数ヶ月遅れており、さらにTCAの非網羅的テストのサポートまでまだ数週間かかる状態でした。そのため、UIテストの追加は見送りました。UIレイヤーのリファクタリングで一般的な動作は変わっていないのに、テストの書き直しが必要になるような感覚が気に入らなかったからです。ただ、非網羅的テストが利用可能になった今、UIテストを段階的に書いていく予定です。最も重要なものから始めます:ビジネスロジックが最も多いフィーチャーと、すべてのオンボーディングフィーチャーです。これについては将来の記事で書くかもしれません。

では、アプリのコードベースの移行にどう取り組んだかに焦点を当てましょう。

移行前(事例)

変更内容とさらに効率化するために行った追加変更を説明するには、実際のコードを見せるのが最良の方法だと思います。以下では、アプリの最もシンプルなフィーチャーが移行前にどのように見えていたかを示し、それを新しいTCA 1.0スタイルにどう進化させたかをお見せします。

このフィーチャーはコードベースでAppInfoという名前で、アプリ内ではこのように見えます:

The

「About RemafoX」画面(Cmd+I)

移行前、フィーチャーのコードは7つのファイルに分割されていました:

Feature parts: Action, ActionHandler, Error, Event, Reducer, State, and View.

フィーチャーの構成要素:ActionActionHandlerErrorEventReducerStateView

AppInfoStateAppInfoActionがデータと可能なインタラクションを定義しています:

import AppFoundation
import AppUI

public struct AppInfoState: Equatable {
   public typealias Action = AppInfoAction
   public typealias Error = AppInfoError

   @BindingState
   var showEnvInfoCopiedToClipboard: Bool = false
   var selectedAppIcon: AppIcon

   var errorHandlingState: ErrorHandlingState?

   public init() {
      self.selectedAppIcon = Defaults[.selectedAppIcon]
   }
}

AppInfoState.swift

import AppFoundation
import AppUI

public enum AppInfoAction: Equatable, BindableAction {
   public typealias State = AppInfoState
   public typealias Error = AppInfoError

   case onAppear
   case onDisappear
   case selectedAppIconChanged
   case copyEnvironmentInfoPressed

   case binding(BindingAction<State>)

   case errorOccurred(error: Error)
   case setErrorHandling(isPresented: Bool)
   case errorHandling(action: ErrorHandlingAction)
}

AppInfoAction.swift

フィーチャー内のどこかで参照する可能性のある関連パーツ用に、実際に使っていなくても常にtypealiasを定義していました。また、後でシートで表示したい子ビューがあるたびに、追加のアクションset<子の名前>(isPresented:)を用意していました。AppFoundationAppUIのインポートが何かと思われるかもしれませんが、この記事で説明しています。アプリ内のインポート数を減らすのに役立ちます。

次に、AppInfoViewファイルの内容です:

import AppFoundation
import AppUI

public struct AppInfoView: View {
   public typealias State = AppInfoState
   public typealias Action = AppInfoAction

   let store: Store<State, Action>

   public init(store: Store<State, Action>) {
      self.store = store
   }

   public var body: some View {
      WithViewStore(self.store) { viewStore in
         VStack(alignment: .leading, spacing: 20) {
            VStack(alignment: .center, spacing: 10) {
               viewStore.selectedAppIcon.image
                  .resizable()
                  .aspectRatio(contentMode: .fit)
                  .frame(width: 128, height: 128)
                  .onChange(of: Defaults[.selectedAppIcon]) { newValue in
                     viewStore.send(.selectedAppIconChanged)
                  }

               Text(Constants.appDisplayName)
                  .font(.system(size: 33, weight: .light, design: .rounded))

               Text("Copyright © 2022 Cihat Gündüz")
                  .font(.footnote)
                  .foregroundColor(.secondary)
            }
            .frame(maxWidth: .infinity)

            Divider()

            VStack(alignment: .center, spacing: 10) {
               Text("Environment Info")
                  .font(.headline)

               Text("Provide these info when reporting bugs or use Help menu.")
                  .frame(maxWidth: .infinity, alignment: .leading)
                  .font(.subheadline)
                  .padding(.bottom, 5)

               HStack {
                  Text("App Version:").foregroundColor(.secondary)
                  Spacer()
                  Text(Bundle.main.versionInfo)
               }

               HStack {
                  Text("System Version:").foregroundColor(.secondary)
                  Spacer()
                  Text(ProcessInfo.processInfo.operatingSystemVersionString.replacingOccurrences(of: "Version ", with: ""))
               }

               HStack {
                  Text("System CPU:").foregroundColor(.secondary)
                  Spacer()
                  Text(KernelState.getStringValue(for: .cpuBrandString))
               }

               HStack {
                  Text("Tier:").foregroundColor(.secondary)
                  Spacer()
                  Text(Plan.loadCurrent().tier.displayName)
               }

               Button {
                  viewStore.send(.copyEnvironmentInfoPressed)
               } label: {
                  Label("Copy", systemSymbol: .docOnClipboard)
               }
               .padding(.top, 10)
               .popover(isPresented: viewStore.binding(\.$showEnvInfoCopiedToClipboard), arrowEdge: Edge.top) {
                  Text("Copied!").padding(10)
               }
            }
         }
         .frame(width: 320)
         .padding()
         .onAppear { viewStore.send(.onAppear) }
         .onDisappear { viewStore.send(.onDisappear) }
         .sheet(
            isPresented: viewStore.binding(
               get: { $0.errorHandlingState != nil },
               send: Action.setErrorHandling(isPresented:)
            )
         ) {
            IfLetStore(
               self.store.scope(state: \State.errorHandlingState, action: Action.errorHandling(action:)),
               then: ErrorHandlingView.init(store:)
            )
         }
      }
   }
}

#if DEBUG
   struct AppInfoView_Previews: PreviewProvider {
      static let store = Store(
         initialState: .init(),
         reducer: appInfoReducer,
         environment: .mocked
      )

      static var previews: some View {
         AppInfoView(store: self.store)
      }
   }
#endif

AppInfoView.swift

シートを表示するために11行以上のコードが必要で、アクションsetErrorHandling(isPresented:)を手動でシステムに送り返す必要があることに注意してください。また、経験豊富な開発者であれば、Plan.loadCurrent()のようにビューコード内でグローバルな依存関係を使用していることに気づくかもしれません。これではUIコードのテスタビリティが低くなりますが、UIテストを書き始めるときに適切な依存関係として導入する予定なので、今は無視しましょう。

TCAにおけるフィーチャーの最後のピースはAppInfoReducerです:

import AppFoundation
import AppUI

public let appInfoReducer = AnyReducer.combine(
   errorHandlingReducer
      .optional()
      .pullback(
         state: \AppInfoState.errorHandlingState,
         action: /AppInfoAction.errorHandling(action:),
         environment: { $0 }
      ),
   AnyReducer<AppInfoState, AppInfoAction, AppEnv> { state, action, env in
      let actionHandler = AppInfoActionHandler(env: env)

      switch action {
      case .onAppear, .onDisappear:
         return .none  // for analytics only

      case .selectedAppIconChanged:
         return actionHandler.selectedAppIconChanged(state: &state)

      case .copyEnvironmentInfoPressed:
         return actionHandler.copyEnvironmentInfoPressed(state: &state)

      case .binding:
         return .none  // assignment handled by `.binding()` below

      case .errorOccurred, .setErrorHandling, .errorHandling:
         return actionHandler.handleErrorAction(state: &state, action: action)
      }
   }
   .binding()
   .recordAnalyticsEvents(eventType: AppInfoEvent.self) { state, action, env in
      switch action {
      case .onAppear:
         return .init(event: .onAppear)

      case .onDisappear:
         return .init(event: .onDisappear)

      case .copyEnvironmentInfoPressed:
         return .init(event: .copyEnvironmentInfoPressed)

      case .errorOccurred(let error):
         return .init(event: .errorOccurred, attributes: ["errorCode": error.errorCode])

      case .binding, .setErrorHandling, .errorHandling, .selectedAppIconChanged:
         return nil
      }
   }
)

まず、appInfoReducerがグローバルレベルで定義されているのが違和感があります。次に、子フィーチャーErrorHandlingをこのフィーチャーに接続するのに7行必要です。さらに、AppInfoActionHandlerという別の型を導入してリデューサーの実際のロジックを保持していることにお気づきでしょう。これは、リデューサーロジックの一部がかなり長くなり、すべてのロジックをswitch-case内に収めるとコード量が膨大になるためです。しかし、Xcodeにはswitchのcase間の検索やナビゲーションを支援する機能がないため、ロジックを別の型の関数に抽出しました。最後に、アナリティクスのためにAnyReducer型自体にエクステンション関数を定義していることがわかります:

import ComposableArchitecture

extension AnyReducer {
   /// Returns a `Result` where each action coming to the store first attempts to record an analytics event.
   /// In the implementation, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
   public func recordAnalyticsEvents<Event: AnalyticsEvent>(
      eventType: Event.Type,
      event toAttributedEvent: @escaping (State, Action, Environment) -> Analytics.AttributedEvent<Event>?
   ) -> Self {
      .init { state, action, env in
         guard let attributedEvent = toAttributedEvent(state, action, env) else { return self.run(&state, action, env) }

         return .concatenate(
            .fireAndForget { Analytics.shared.record(attributedEvent: attributedEvent) },
            self.run(&state, action, env)
         )
      }
   }
}

AnyReducerExt.swift

これは単に、記録したいアクションに対してTelemetryDeckを使ったアナリティクスエンジンにイベントを記録するだけです。AppInfoAction enumに新しいアクションを追加するたびに、その新しいイベントを完全に匿名化された方法で分析したいかどうかを常に検討するリマインダーとして、とても便利だと感じています。これを正しく動作させるには、各フィーチャーごとにもう1つの型を定義する必要があります。ここではAppInfoEventです:

import AppFoundation

enum AppInfoEvent: String {
   case onAppear
   case onDisappear
   case copyEnvironmentInfoPressed
   case errorOccurred
}

extension AppInfoEvent: AnalyticsEvent {
   var idComponents: [String] {
      ["AppInfo", self.rawValue]
   }
}

AppInfoEvent.swift

このenumは収集したいすべてのイベントを定義し、idComponentsプロパティはアナリティクスプロバイダーにイベント名を渡す際にStringを自動作成するのに役立ちます。AnalyticsEventプロトコルは本題から外れますが、興味がある方のために紹介すると以下のようになっています:

import Foundation

public protocol AnalyticsEvent: Identifiable where ID == String {
   var idComponents: [String] { get }
}

extension AnalyticsEvent {
   public var id: String {
      self.idComponents.joined(separator: ".")
   }
}

AnalyticsEvent.swift(Analyticsというヘルパーモジュールの一部)

Environmentに使用しているAppEnv型にも気づかれたかもしれません。これは基本的なEnvironment型が必要なところで再利用している共有型で、mainQueueを持ち、アプリケーション全体で受け渡されています:

import CombineSchedulers
import Defaults
import Foundation

public struct AppEnv {
   public let mainQueue: AnySchedulerOf<DispatchQueue>

   public init(mainQueue: AnySchedulerOf<DispatchQueue>) {
      self.mainQueue = mainQueue
   }
}

#if DEBUG
   extension AppEnv {
      public static var mocked: AppEnv {
         .init(mainQueue: DispatchQueue.main.eraseToAnyScheduler())
      }
   }
#endif

7つのファイルの最後はAppInfoErrorで、この非常にシンプルなフィーチャーでは実際に空です。その目的については、エラーハンドリングのアプローチを詳しく説明する後の記事で取り上げます。この記事で知っておくべきことは、予期しないことが起きた際にフィーチャーのコンテキスト内で有用な情報を含むシートを表示したいということだけです。

Point-Freeはすべての型を1つのファイルにまとめる傾向がありますが、AppInfoのような小さなフィーチャーならそれでも問題ないかもしれません。しかし、私の一般的なフィーチャーはすべての型を合わせると約500〜1,500行になります。ファイルのソフト上限を400行、ハード上限を1,000行に設定しています(SwiftLintのルールのデフォルト参照)。この非常にシンプルなフィーチャーでさえ314行でソフト上限に近づいており、一部のフィーチャーはハード上限を超えてしまう可能性があります。そのため、すべてを1つのファイルにまとめるのは私には合いません。代わりに各型をそれぞれのファイルに配置することにしました。しかし、それにも100%満足していたわけではなく、あちこちに散らばっている感じがありました。理想的には関連するコードがまとまっていながら、7つより少ないファイルに均等に分散されているのがベストです。では、移行後にどうなったか見てみましょう。


Want to see your ad here? Contact me at [email protected] to get in touch.


移行後(事例)

TCA 1.0ベータでは、Point-Freeがフィーチャーのスコープまたは名前空間として機能する特別なstructを作成し、StateActionのようなヘルパー型をサブタイプとしてその名前空間型に入れるというコンセプトを導入しました。この名前空間にFeatureという名前を付け、Reducerに準拠させています。おおよそ以下のようになります:

struct Feature: Reducer {
  struct State: Equatable { … }
  enum Action: Equatable { … }

  func reduce(into state: inout State, action: Action) -> Effect<Action> { … }
}

しかし、この構造には2つの理由から混乱を感じました:

  1. 名前空間にサフィックスFeatureを付けてReducerに準拠させるのは違和感があります。reducerパラメータを渡す必要があるところでFeatureを渡すことになり、混乱します。それなら名前空間は最初からReducerと名付けるべきですが、そうするとReducer.StateReducer.Actionでサブタイプにアクセスすることになり、これも正確ではありません。

  2. フィーチャーの名前空間というアイデアを完全に受け入れるなら、一貫性のためにFeature型の中にReducerサブタイプがあることを期待します。

代わりに、実際にFeatureを名前空間として使い、その中にReducerに準拠するReducerサブタイプを入れることにしました。ただし、struct Reducer: Reducerだと名前が衝突するので、typealiasで解決しましょう:

import ComposableArchitecture

public typealias FeatureReducer: Reducer

これでFeature名前空間内にReducer: FeatureReducerサブタイプを定義できるようになります。ついでに、アプリがモジュール化されているためリデューサーのpublicイニシャライザを書き忘れがちなので(必須なのに)、publicイニシャライザを要求する新しいpublicプロトコルも定義しましょう:

import ComposableArchitecture

public protocol FeatureReducer: Reducer {
   init()
}

実はTCAフィーチャーの他の型についても忘れがちなことがあります。プロトコルですべて明確な要件にしましょう。FeatureReducerのようなプロトコルをフィーチャー内のすべてのサブタイプに対して実装します:

import Analytics
import ComposableArchitecture
import ErrorHandling
import SwiftUI

public protocol FeatureState: Equatable {
   var childErrorHandling: ErrorHandlingFeature.State? { get set }
}

public protocol FeatureAction: Equatable {
   associatedtype ErrorType: FeatureError

   static func errorOccurred(_ error: ErrorType) -> Self
   static func childErrorHandling(_ action: PresentationAction<ErrorHandlingFeature.Action>) -> Self
}

public protocol FeatureEvent: AnalyticsEvent {}

public protocol FeatureError: HelpfulError {}

public protocol FeatureReducer: Reducer {
   init()
}

public protocol FeatureView: View {
   associatedtype Action: FeatureAction
}

Feature.swift(ヘルパーモジュールの一部)からの抜粋

FeatureStateFeatureActionEquatableを要求していることに注目してください。TCAでテスタブルにするために常に良いアイデアであり、すべてのstateとaction型はすでに準拠しています。さらに、アナリティクスとエラーハンドリングのニーズに応じてFeatureViewも定義し、2つの追加型も定義しました。また、以前errorHandlingStateのように子フィーチャーにサフィックスStateを付けていたのを、代わりにプレフィックスchildを使う方式(childErrorHandlingのように)に変更しました。これにより、属性を上から下にスキャンする際に子フィーチャーを見つけやすくなります。

これらのプロトコルが整ったことで、すべてのサブタイプを要求する別のプロトコルを定義して「フィーチャー」とは何かをコンパイラに教えることもできます:

/// A namespace for a TCA feature with extra requirements for Analytics and Error Handling.
public protocol Feature {
   associatedtype State: FeatureState
   associatedtype Action: FeatureAction
   associatedtype Event: FeatureEvent
   associatedtype Error: FeatureError
   associatedtype Reducer: FeatureReducer
   associatedtype View: FeatureView
}

/// A helper to declare a `Store` of a `Feature` type.
public typealias FeatureStore<F: Feature> = Store<F.State, F.Action>

Feature.swift(ヘルパーモジュールの一部)からの抜粋

ビューでstoreを定義するためのtypealiasも、Point-Freeが作成した新しいStoreOf typealiasと同様に、ただしFeatureに特化した形で定義しました。

さて、この前準備を踏まえて、移行後のFeatureがどのように見えるか確認しましょう:

import AppFoundation
import AppUI

public enum AppInfoFeature: Feature {
   public struct State: FeatureState {
      // see below
   }

   public enum Action: FeatureAction, BindableAction {
      // see below
   }

   public enum Event: String, FeatureEvent {
      // see below
   }

   public enum Error: FeatureError {
      // ...
   }

   public struct Reducer: FeatureReducer {
      // see below
   }

   public struct View: FeatureView {
      // see below
   }
}

extension AppInfoFeature.Event: AnalyticsEvent {
   public var idComponents: [String] {
      ["AppInfo", self.rawValue]
   }
}

AppInfoFeature.swiftの概要

Featurestructではなくenumを使っていることに注目してください。これは単なる名前空間であることを示すためです。次に、すべてのサブタイプがその名前にFeatureプレフィックスを付けたものに準拠しています(例:State: FeatureState)。これにより何に準拠すべきかが覚えやすくなり、コードの一貫性も向上します。

上の概要では省略したStateの本体を見てみましょう:

   public struct State: FeatureState {
      @BindingState
      var showEnvInfoCopiedToClipboard: Bool = false
      var selectedAppIcon: AppIcon

      @PresentationState
      public var childErrorHandling: ErrorHandlingFeature.State?

      public init() {
         self.selectedAppIcon = Defaults[.selectedAppIcon]
      }
   }

元のAppInfoStateとかなり似ていますが、子がerrorHandlingFeatureからchildErrorHandlingにリネームされています。また、子フィーチャー自体も移行したため、型がErrorHandlingState?からErrorHandlingFeature.State?に変わりました。さらに、TCA 1.0の新しいナビゲーションスタイルである@PresentationStateアトリビュートを追加しました。これにより、子フィーチャー内から@Dependency(\.dismiss)を使い、Reducer内でself.dismiss()を呼び出すことでのdismissがサポートされます。

次に、Actionサブタイプを見てみましょう:

   public enum Action: FeatureAction, BindableAction {
      case onAppear
      case onDisappear
      case selectedAppIconChanged
      case copyEnvironmentInfoPressed

      case binding(BindingAction<State>)

      case errorOccurred(Error)
      case childErrorHandling(PresentationAction<ErrorHandlingFeature.Action>)
   }

これも元のAppInfoActionとほぼ同じですが、子アクションの型が変わっています。HelpfulErrorActionからPresentationAction<HelpfulErrorFeature.Action>に変更され、すべての子アクションを.presentedというcaseに入れるラッパーになりました。もう1つのcase .dismissは、親がそれに反応する必要がある場合に子がdismissされたことを報告します。PresentationActionのおかげで、アクションsetErrorHandling(isPresented:)を完全に削除できました。TCAが提供する型にカプセル化されたためです。

次にViewがどのように見えるか確認しましょう:

   public struct View: FeatureView {
      let store: FeatureStore<AppInfoFeature>

      public init(store: FeatureStore<AppInfoFeature>) {
         self.store = store
      }
   }

ご覧の通り、古いスタイルのStore<State, Action>やTCA 1.0スタイルのStoreOf<Feature>の代わりにFeatureStore typealiasを使っています。しかし、bodyプロパティのようなSwiftUI Viewを定義する残りの部分はどこにあるのでしょうか?ビューの実装はフィーチャーの中で最も長い部分の1つになるため、すべてのサブタイプの構造的な部分は1か所にまとめつつ、多くのコードを必要とするプロトコル準拠は別のエクステンションファイルに抽出しました。

Viewの実装は別ファイルにエクステンションとして配置されています:

import AppFoundation
import AppUI

extension AppInfoFeature.View: View {
   public typealias Action = AppInfoFeature.Action

   public var body: some View {
      WithViewStore(self.store, observe: { $0 }) { viewStore in
         VStack(alignment: .leading, spacing: 20) {
            // same code as before
         }
         .frame(width: 320)
         .padding()
         .onAppear { viewStore.send(.onAppear) }
         .onDisappear { viewStore.send(.onDisappear) }
         .sheet(store: self.store.scope(state: \.$childErrorHandling, action: Action.childErrorHandling)) { childStore in
            HelpfulErrorFeature.View(store: childStore)
         }
      }
   }
}

#if DEBUG
   struct AppInfoView_Previews: PreviewProvider {
      static let store = Store(initialState: AppInfoFeature.State(), reducer: AppInfoFeature.Reducer())

      static var previews: some View {
         AppInfoFeature.View(store: self.store).previewVariants()
      }
   }
#endif

AppInfoFeature*View.swift

bodyプロパティの実装はほぼ以前と同じです。しかし、11行だった.sheetモディファイアがたった3行に縮まったことに注目してください。これは@PresentationStatePresentationActionを使った新しいナビゲーションツールのおかげです。もう1つの変更はPreviewProvider内のstatic let storeで、Storeに渡すEnvironmentパラメータがなくなりました!

その理由を理解するためにReducerサブタイプを見てみましょう:

   public struct Reducer: FeatureReducer {
      @Dependency(\.mainQueue)
      var mainQueue

      @Dependency(\.continuousClock)
      var clock

      public init() {}
   }

@Dependencyアトリビュートの使用に注目してください。SwiftUIの@Environmentアトリビュートを思い出すかもしれませんが、実際にまったく同じように動作します。この新しいアトリビュートのおかげで、TCA 1.0ではEnvironment型が不要になりました。代わりに、すべての依存関係は@Dependencyアトリビュートで宣言します。これにより、以前受け渡していたAppEnv型を完全に廃止できました。

ここでもReducerプロトコルの実際の実装が見当たらないかもしれません。リデューサーの実装はフィーチャーの中で2番目に長くなりがちなコード部分なので、こちらも独自のファイルに抽出しました:

import AppFoundation
import AppUI

extension AppInfoFeature.Reducer: Reducer {
   public typealias State = AppInfoFeature.State
   public typealias Action = AppInfoFeature.Action

   enum ShowEnvInfoCopiedId {}

   public var body: some ReducerOf<Self> {
      AnalyticsEventRecorderOf<AppInfoFeature> { state, action in
         switch action {
         case .onAppear:
            return .init(event: .onAppear)

         case .onDisappear:
            return .init(event: .onDisappear)

         case .copyEnvironmentInfoPressed:
            return .init(event: .copyEnvironmentInfoPressed)

         case .errorOccurred(let error):
            return .init(event: .errorOccurred, attributes: ["errorCode": error.errorCode])

         case .binding, .childErrorHandling, .selectedAppIconChanged:
            return nil
         }
      }

      BindingReducer()

      Reduce<State, Action> { state, action in
         switch action {
         case .onAppear, .onDisappear:
         return .none  // for analytics only

      case .selectedAppIconChanged:
         return self.selectedAppIconChanged(state: &state)

      case .copyEnvironmentInfoPressed:
         return self.copyEnvironmentInfoPressed(state: &state)

      case .binding:
         return .none  // assignment handled by `BindingReducer()` above

      case .errorOccurred, .childErrorHandling:
         return self.handleHelpfulErrorAction(state: &state, action: action)
         }
      }
      .ifLet(\.$childErrorHandling, action: /Action.childErrorHandling) {
         HelpfulErrorFeature.Reducer()
      }
   }

   private func selectedAppIconChanged(...)

   private func copyEnvironmentInfoPressed(state: inout State) -> Effect<Action> {
      Pasteboard.string = Constants.GitHub.environmentInfo
      state.showEnvInfoCopiedToClipboard = true

      return .run { send in
         try await self.clock.sleep(for: Constants.toastMessageDuration)
         try Task.checkCancellation()
         await send(.set(\.$showEnvInfoCopiedToClipboard, false))
      }
      .cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)
   }

   private func handleHelpfulErrorAction(...)
}

AppInfoFeature*Reducer.swift

まず構造がまったく異なることに注目してください。グローバルなreducer変数は不要になりました。代わりに、SwiftUIのViewプロトコルと非常によく似たbodyプロパティを実装します。そして類似点はそこで終わりません。構造も非常にSwiftUIライクで、BindingReducer()を含む様々なリデューサーのリストがAppInfoFeature.Reducerを構成しています。BindingReducer().binding()を置き換えるものです。また、子フィーチャーを接続する7行のコードが新しい.ifLet APIを使ってたった3行に縮小されていることにも注目してください。さらに、アクションに反応するロジックの実装のためにカスタムのActionHandler型を定義する必要もなくなりました。今は型の中にいるのでグローバルレベルではなく、これらの関数をReducer型自体に簡単に移動できました。また、copyEnvironmentInfoPressedの実装は新しいasyncスタイルのAPIを使用しています。以前は、より読みにくいCombineスタイルで実装されていました:

      Pasteboard.string = Constants.GitHub.environmentInfo
      state.showEnvInfoCopiedToClipboard = true

      return .init(value: .set(\.$showEnvInfoCopiedToClipboard, false))
         .delay(for: Constants.toastMessageDuration, scheduler: env.mainQueue)
         .eraseToEffect()
         .cancellable(id: ShowEnvInfoCopiedId.self, cancelInFlight: true)

古いAppInfoActionHandler.swiftからの抜粋

最後に、新しいSwiftUIライクなfunction builderスタイルに対応するため、AnyReducerのエクステンション関数recordAnalyticsEventsを、実行ロジックをプロパティとして保持する単なるReducerに変更しました:

import ComposableArchitecture
import Foundation

/// Returns a `Reducer` where each action coming to the store attempts to record an analytics event.
public struct AnalyticsEventRecorder<State, Action, Event: AnalyticsEvent>: Reducer {
   let toAttributedEvent: (State, Action) -> Analytics.AttributedEvent<Event>?

   /// In the event closure, switch over `action` and return an ``Analytics.AttributedEvent`` if the action should be recorded, or else return `nil`.
   public init(event toAttributedEvent: @escaping (State, Action) -> Analytics.AttributedEvent<Event>?) {
      self.toAttributedEvent = toAttributedEvent
   }

   public func reduce(into state: inout State, action: Action) -> Effect<Action> {
      if let attributedEvent = self.toAttributedEvent(state, action) {
         Analytics.shared.record(attributedEvent: attributedEvent)
      }

      return .none
   }
}

/// Convenient way to declare an `AnalyticsEventRecorder`, but requires `Reducer` to conform to `Feature`.
public typealias AnalyticsEventRecorderOf<F: Feature> = AnalyticsEventRecorder<F.State, F.Action, F.Event>

AnalyticsEventRecorder.swift(ヘルパーモジュール)

リデューサー上のアナリティクスヘルパーの本体はまったく変わっていません。以前のヘルパー関数からこの新しいリデューサーのイニシャライザにコピーしただけです。

以上で、AppInfoフィーチャー全体のTCA 1.0への移行が完了しました。全体的なファイル構成は7つから3つのファイルになりました:

After the migration case

サブタイプのファイル名のセパレータとして*を使っていることに注目してください。これはファイルがサブタイプのメイン部分を含んでいることを示すためです。本来なら.をセパレータとして使い、AppInfoFeature.Reducer.swiftのように読めるようにすることもできます。しかし、.Reducer.R.swift.sよりソート順が上になるため、サブタイプのファイルがメインのフィーチャーファイルAppInfoFeature.swiftの上に表示されてしまいます。そこで、見た目はドットに似ていますが.より優先順位が低いセパレータを選んだ結果、*になりました。

オプトインしたSwiftLintのルールfile_nameがこの命名スタイルで警告を表示しましたが、設定ファイルに以下を追加することで簡単に調整できました:

file_name:
   nested_type_separator: '*'

まとめ

中規模のアプリを新しいTCA 1.0スタイルのAPIに移行するのは大変な作業でしたが、そのほとんどはファイル構造の設定、検索と置換、既存コードの別の場所への移動でした。また、自分が気に入る良い構造を見つけるためにもかなりの時間を費やしました。もし別のアプリで同じ移行をもう一度やるとしたら、今回の学びを活かして5日ではなく2〜3日で完了できるのではないかと思います。

実際にコードを調整する必要があったのは数か所だけで、主にリデューサー内のCombineスタイルのEffectコードをasync-awaitスタイルに移行する際でした。ただ、優れたドキュメントと警告のおかげで、何をすべきかは常に明確でした。同様の移行を行う方のために、最も役に立った3つのリンクを紹介します:

  1. Concurrency Beta

  2. Migrating to the Reducer protocol

  3. Composable Navigation Beta

TCA 1.0の進化について概要を把握するのに1つだけエピソードを選ぶなら、Composable Navigationを開始するエピソード #222の最初の約35分がおすすめです。過去1年でどのように変わったかを素早く把握できます。

この記事を楽しんでいただけましたか?エキスパートアドバイスを受けてみませんか!

この記事が参考になりましたか?BlueskyMastodonでフォローして、Swiftのヒントやインディー開発の最新情報をチェックしてください。