目的
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")
}
ForEachStoreやSwitchStore、および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
の導入によって少し簡単に書けるようになりました。
例えば、text
とisOn
を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.firstName
とstate.lastName
を繋げたfullName
をViewState
に持っていた場合、
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.Action
をprotocol 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へと対応を進めていくとパフォーマンスへの影響が少ない