14
6

TCA 1.7のリリース内容をざっくり理解する

Last updated at Posted at 2024-01-30

目的

TCA 1.7 がリリースされ、Migration Guideも発表されましたが、@ObservationStateに引っ張られて & そのトピックの多さに正直狼狽えました。
ただ、内容を細かく紐解いていくと自分なりに理解が深まってきたので、Migration Guide に沿って、必要に応じて少し順番を変えつつまとめていきます。
https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7

@ObservableState

新しく導入された@ObservableStateを利用することで、stateの変更検知に利用していたViewStoreを使う必要がなくなりました。

SwiftUI(iOS17以降を対象とするアプリ)の場合

 @Reducer
 struct Feature {
   @ObservableState
   struct State { /* ... */ }
   enum Action { /* ... */ }
   var body: some ReducerOf<Self> {
     // ...
   }
 }
 
 struct FeatureView: View {
   let store: StoreOf<Feature>
 
   var body: some View {
      Form {
         Text(store.count.description)
         Button("+") { store.send(.incrementButtonTapped) }
      }
   }
 }

SwiftUI(iOS17未満を対象とするアプリ)の場合

iOS17以降のアプリに対して、こちらはView全体をWithPerceptionTrackingで囲みます。

 @Reducer
 struct Feature {
   @ObservableState
   struct State { /* ... */ }
   enum Action { /* ... */ }
   var body: some ReducerOf<Self> {
     // ...
   }
 }
 
 struct FeatureView: View {
   let store: StoreOf<Feature>
 
   var body: some View {
     WithPerceptionTracking {
       Form {
         Text(store.count.description)
         Button("+") { store.send(.incrementButtonTapped) }
       }
     }
   }
 }

iOS17以降 or 未満でこれが異なるのは、WithPerceptionTrackingの中で「Observation Frameworkが利用できるかどうか」を判別しているためです。(iOS17以降なら利用可能)

利用できない場合はswift-perceptionというライブラリ(TCAの人が作ってる)で変更の検知を行います。

UIKitの場合

 @Reducer
 struct Feature {
   @ObservableState
   struct State { /* ... */ }
   enum Action { /* ... */ }
   var body: some ReducerOf<Self> {
     // ...
   }
 }
 
 struct FeatureVC: UIViewController {
   let store: StoreOf<Feature>

   @IBOutlet weak var countText: UITextView!

   override func viewDidLoad() {
      super.viewDidLoad()
      observe { [weak self] in
        guard let self else { return }
        countText.text = store.count.description
      }
   }

   @IBAction private func tappedIncrementButton(_ sender: Any) {
      store.send(.incrementButtonTapped)
   }
 }

IfLetStore から if let

State@ObservableStateに準拠している必要があります。

IfLetStoreは soft-deprecated になりました。
IfLetStoreは内部でWithViewStoreを利用して引数storeの変更検知を行っていましたが、
State@ObservableStateに準拠している場合はViewStoreを利用しなくても変更を検知できるので不要(むしろパフォーマンス悪化の原因になるかも?)です。

こういうのが

IfLetStore(store: store.scope(state: \.child, action: \.child)) { childStore in
  ChildView(store: childStore)
} else: {
  Text("Nothing to show")
}

こう書けるようになります。

if let childStore = store.scope(state: \.child, action: \.child) {
  ChildView(store: childStore)
} else {
  Text("Nothing to show")
}

ForEachStoreSwitchStore、およびNavigationStackStoreも同様の理由でsoft-deprecatedとなっています。

@PresentationState@Presents

Swift macrosの制限としてプロパティラッパーと同時に利用できない、というのがあるらしいです。(知らなかった)
これまであった@PresentationStateはプロパティラッパーで、@ObservableStateと併用できないので@Presentsに変わった = プロパティラッパーではなくマクロになりました。

 @Reducer
 struct Feature {
   @ObservableState
   struct State {
      @Presents var child: Child.State?
   }
   enum Action { /* ... */ }
   var body: some ReducerOf<Self> {
     // ...
   }
 }

@BindingStateの複雑さの緩和

これまでの歴史的な背景から@BindingState関連がかなり複雑化してしまっていた(らしい)のですが、@ObservableStateの導入によって少し簡単に書けるようになりました。

例えば、textisOnをBindable = 双方向の変更が可能な状態にするために、これまでは↓のような書き方をしていたのが

@Reducer
struct Feature {
  struct State {
    @BindingState var text = ""
    @BindingState var isOn = false
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
  }
  var body: some ReducerOf<Self> {
    BindingReducer()
  }
}

struct FeatureView: View {
  let store: StoreOf<Feature>
 
  var body: some View {
    WithViewStore(store, observe: { $0 }) { viewStore in
      Form {
        TextField("Text", text: viewStore.$text)
        Toggle(isOn: viewStore.$isOn)
      }
    }
  }
}

@ObservableState + @Bindable(iOS 16以前をターゲットとする場合は@Perception.Bindable)でこのように少しシンプルに書けるようになります。

@Reducer
struct Feature {
  @ObservableState
  struct State {
    var text = ""
    var isOn = false
  }
  enum Action: BindableAction {
    case binding(BindingAction<State>)
  }
  var body: some ReducerOf<Self> {
    BindingReducer()
  }
}

struct FeatureView: View {
  // iOS 17以降をターゲットとする場合
  @Bindable var store: StoreOf<Feature>
  // iOS 16以前をターゲットとする場合
  // @Perception.Bindable var store: StoreOf<Feature>
 
  var body: some View {
    Form {
      TextField("Text", text: $store.text)
      Toggle(isOn: $store.isOn)
    }
  }
}

viewStore.bindingも同様に書きやすくなっていて、↓のようなbindingが

Toggle(
  ...,
  isOn: viewStore.binding(get: \.isOn, send: .toggleTapped)
)

こんな感じに書けます。

Toggle(
  ...,
  isOn: $store.isOn.sending(\.toggleTapped)
)

TCAのnavigation modifierからSwiftUIのmodifierへ

これまでは.sheetなどのnavigation modifierを利用する際、

.sheet(store: store.scope(state: \.child, action: \.child)) {
   ChildView(store: $0)
}

のようにTCAが用意するnavigation modifierを利用していましたが、SwiftUI純正のmodifierを利用できるようになりました。

// iOS 17以降をターゲットとする場合
@Bindable var store: StoreOf<Feature>
// iOS 16未満をターゲットとする場合
// @Perception.Bindable var store: StoreOf<Feature>

// ...
.sheet(item: $store.scope(state: \.child, action: \.child)) {
   ChildView(store: $0)
}

併せて、これまでのstoreを引数に受け取るTCAのnavigation modifierはdeprecatedとなりました。
.popover.navigationDestinationも同様にSwiftUIのmodifierが利用可能となり、TCAのstoreを受け取るnavigation modifierがdeprecatedになりました。

また、.alert().confirmationDialog()についても、SwiftUIの.sheet(item:)ライクな記述ができるようになりました。
(こちらは以前の書き方がdeprecatedになっているわけではありません)

// iOS 17以降をターゲットとする場合
@Bindable var store: StoreOf<Feature>
// iOS 16未満もターゲットとする場合
// @Perception.Bindable var store: StoreOf<Feature>

// ...
.alert($store.scope(state: \.alert, action: \.alert))

ViewStoreが無くなることに付随する更新、代替手法

機能的なアップデートというよりは、ViewStoreでやっていたことの代替手法などもMigration Guideで紹介されています。

ViewStateで算出されていたプロパティ

例えば、state.firstNamestate.lastNameを繋げたfullNameViewStateに持っていた場合、

let store: StoreOf<Feature>
@ObservedObject private var viewStore: ViewStore<ViewState, Feature.Action>

init(store: StoreOf<Feature>) {
  self.store = store
  self.viewStore = ViewStore(store, observe: ViewState.init(state:))
}

struct ViewState: Equatable {
  let fullName: String
  init(state: Feature.State) {
    self.fullName = "\(state.firstName) \(state.lastName)"
  }
}

そのままReducer.StateにComputed Propertyとして持たせることで対応可能です。

struct State {
  // ...  
  var fullName: String {
    "\(self.firstName) \(self.lastName)"
  }
}

Viewから送信できるアクションを制限する

Reducer.Actionの分割手法として、下記のようなパターンが提案されており、

enum Action {
  case view(View)

  enum View {
    case buttonTapped
  }
}

これまでのViewStoreでは、View側からはAction.Viewしか送れないようにすることができました。

struct FeatureView: View {
    let store: StoreOf<Feature>
    @ObservedObject private var viewStore: ViewStore<ViewState, Feature.Action>
    
    init(store: StoreOf<Feature>) {
      self.store = store
      self.viewStore = ViewStore(
        store,
        observe: ViewState.init(state:),
        // 送れるActionは Feature.Action.view の中身 = enum View のアクションのみ
        send: Feature.Action.view
      )
    }
    
    var body: some View {
      Button(action: { viewStore.send(.buttonTapped) })
    }
}

このパターンは1.7でも引き続き利用可能ですが、少しだけ修正を加える必要があります。

まずReducer.Actionprotocol ViewActionに準拠させます。
https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/viewaction/

enum Action: ViewAction {
  case view(View)

  enum View {
    case buttonTapped
  }
}

そして、View側で@ViewAction(for:)マクロを利用することで引き続きこのパターンを利用することができます。

@ViewAction(for: Feature.self)
struct FeatureView: View {
    let store: StoreOf<Feature>

    var body: some View {
      Button(action: { send(.buttonTapped) })
    }
}

マイグレーションの進め方

「どうやってマイグレーションを徐々に進めれば良いのか」もMigration Guideに書いてくれています。親切...。
https://pointfreeco.github.io/swift-composable-architecture/main/documentation/composablearchitecture/migratingto1.7#Incrementally-migrating

細かい話は↑を読んで頂くとして、ポイントは下記の2点です。

  • 新しい状態管理方式(@ObservableStateを使った方法)を利用するFeatureが、従来のViewStoreでの状態管理を利用するFeatureを内包する場合、その逆よりもパフォーマンスが良い
  • つまり、RootのReducerから子孫Reducerへと対応を進めていくとパフォーマンスへの影響が少ない
14
6
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
14
6