Share
Embed
Composable FormsでTCAのボイラープレートとおさらばする
TCA の Composable Forms がどのような仕組みでできているかを説明します。
Aikawa
March 21, 2021
Transcript
46 slides
-
例えばこんな State, Action があったとする struct State: Equatable { var digest
= Digest.daily var displayName = "" var protectMyPosts = false var sendNotifications = false } enum Action: Equatable { case digestChanged(Digest) // Digest は .daily, .weekly, .off を持つ enum case displayNameChanged(String) case protectMyPostsChanged(Bool) case sendNotificationsChanged(Bool) } 2 -
それに対応する Reducer はこんな感じ let reducer = Reducer<State, Action, Environment> {
state, action, environment in switch action { case let .digestChanged(digest): state.digest = digest return .none case let .displayNameChanged(displayName): state.displayName = String(displayName.prefix(16)) return .none case let .protectMyPostsChanged(protectMyPosts): state.protectMyPosts = protectMyPosts return .none case let .sendNotificationsChanged(sendNotifications): guard sendNotifications else { state.sendNotifications = sendNotifications return .none } } 3 -
switch action { // 単純な変更のみ case let .digestChanged(digest): state.digest =
digest return .none // 少し処理は⾏っているが単純な変更のみ case let .displayNameChanged(displayName): state.displayName = String(displayName.prefix(16)) return .none // 単純な変更のみ case let .protectMyPostsChanged(protectMyPosts): state.protectMyPosts = protectMyPosts return .none // 少し処理は⾏っているが単純な変更のみ case let .sendNotificationsChanged(sendNotifications): guard sendNotifications else { state.sendNotifications = sendNotifications return .none } } 4 -
今⽇は以下について話そうと思います Composable Forms がどのような考え⽅で出来上がったのか 主に Point-Free さんの ep133 「Bye Bye
Boilerplate 」という エピソードについて今回はまとめています Composable Forms の実際の利⽤法については Zenn にまとめてみ たので、よろしければ参考にしてください 「TCA のボイラープレートを Composable Forms で解消する」 というタイトルでこの後公開する予定です 7 -
先ほどの Action を再度⾒てみましょう enum Action: Equatable { case digestChanged(Digest) case
displayNameChanged(String) case protectMyPostsChanged(Bool) case sendNotificationsChanged(Bool) } enum Digest: String, CaseIterable { case daily case weekly case off } やっていることは何かを受け取り、State を変更するというだけ 8 -
こんな case があったらまとめられそう... ? enum Action: Equatable { // ...
case form((inout State) -> Void) } inout な State を受け取って、その State を変更できるようにする Equatable は⾃動的に破られてしまうため、↓ を enum に追加する static func ==(lhs: Action, rhs: Action) -> Bool { fatalError() // 無理やりではあるが、⼀旦これで Equatable は満たすことができる } 9 -
先ほどの Action を扱う Reducer Reducer は以下のように定義できそう // Action case form((inout
State) -> Void) //Reducer case let .form(update): update(&state) return .none 10 -
View からはこんな感じで使えそう TextField( "Display name", text: Binding.init( get: { viewStore.displayName
}, set: { displayName in viewStore.send(.form { $0.displayName = displayName }) } ) // viewStore.binding( // get: \.displayName, // send: Action.displayNameChanged // ) ) 11 -
もちろんこの⽅法は問題点だらけ 追加ロジックを実装しにくい 例えば State の変更だけでなく、アラートを制御したり権限を 要求したりなどなど... State の変更が View で⾏われており、TCA
のルールを破っている 本来、State を変更できるのは Reducer のみ Equatable が破壊されてしまい、テスタブルではなくなっている 12 -
どうやって問題を解決するか? 今のところ .form Action はクロージャなので、どんなことでも しようと思えばできる しかし、実際に .form Action で実現したいことは、State
の値を 単純に変更することだけ 場合によっては、アラートの表⽰、副作⽤を発⽣させるなど ちょっとした追加ロジックも挟めるようにはしたい 幸い、Swift には KeyPath が存在しているため、State の変更を ある程度柔軟に表現することができる 13 -
KeyPath を使うとどのように表現できるか // case form((inout State) -> Void) case form(WritableKeyPath<State,
???>, ???) ⼀つ⽬の ??? は変更したい値の型 ⼆つ⽬の ??? は変更したい値⾃体 型が決まっていないので、Generics を利⽤してみる case form<Value>(WritableKeyPath<State, Value>, Value) しかし、Swift の enum は Generics をサポートしていない... 14 -
enum で Generics を使える想定で考えてみる Reducer はきっと以下のような形になる // case let .form(update):
// update(&state) case let .form(keyPath, value: value): state[keyPath: keyPath] = value 任意の KeyPath が渡されるため、型が何であるかはわからない しかし、KeyPath で変更する型と Value の型は Generics によって ⼀致することは保証されている 15 -
KeyPath は Equatable KeyPath は Equatable なので、Action の Equatable も保てる
Equatable であることを利⽤し、以下のようなこともできる case let .form(keyPath, value: value): state[keyPath: keyPath] = value if keyPath == \State.displayName { // 追加のロジック } else if keyPath == \State.sendNotificaitons { // 追加のロジック } 16 -
View からはこんな感じで使える TextField( "Display name", text: Binding( get: { viewStore.displayName
}, set: { viewStore.send(.form(\.displayName, $0)) } ) // viewStore.binding( // get: \.displayName, // send: Action.displayNameChanged // ) ) 17 -
少しずつ Generics を消していく case form<Value>(WritableKeyPath<State, Value>, Value) ↓ // PartialKeyPath
は Root の型のみを保持し、Value の型は消す case form<Value>(PartialKeyPath<State>, Value) ↓ // value の型だけであれば Any でも⼤丈夫 case form(PartialKeyPath<State>, value: Any) 19 -
Generics は消えたが、まだ問題はある case form(PartialKeyPath<State>, value: Any) PartialKeyPath の Value の型と
value の Any が⼀致しているとは 限らない // displayName の型は String だが、このようなことができてしまう Action.form(\.displayName, value: 1) 20 -
それを防ぐために作成⽅法を制限する struct FormAction { let keyPath: PartialKeyPath<State> let value: Any
init<Value>( _ keyPath: WritableKeyPath<State, Value>, _ value: Value ) { self.keyPath = keyPath self.value = value } } このようにすれば、Value という型で⼀致させることができる 21 -
もう少し汎⽤的にしてみる struct FormAction<Root> { let keyPath: PartialKeyPath<Root> let value: Any
init<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) { self.keyPath = keyPath self.value = value } } // Action 内では以下のように扱える case form(FormAction<State>) 22 -
KeyPath は Equatable KeyPath は Equatable なので、Action 内にあった ↓ は、消せる
//static func == (lhs: Action, rhs: Action) -> Bool { // fatalError() //} ついでに FormAction を Equatable に準拠させてみる struct FormAction<Root>: Equatable { ... } 23 -
しかし、今のままだと準拠させられない struct FormAction<Root>: Equatable { let keyPath: PartialKeyPath<Root> let value:
Any // Any 型は Equatable ではないため、準拠させられない init<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) { self.keyPath = keyPath self.value = value } } 24 -
AnyHashable を利⽤する 本当は AnyEquatable のようなものがあったら適切だったが、 それがないため、Equatable に準拠している AnyHashable を利⽤する struct
FormAction<Root>: Equatable { let keyPath: PartialKeyPath<Root> let value: AnyHashable init<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) { self.keyPath = keyPath self.value = AnyHashable(value) } } 25 -
Reducer からはどんな形で扱えるか // Form Action struct struct FormAction<Root>: Equatable {
let keyPath: PartialKeyPath<Root> let value: AnyHashable // ... } // Action case form(FormAction<State>) // Reducer case let .form(formAction): // 実はこれはできない(PartialKeyPath に書き込む機能はないため) state[keyPath: formAction.keyPath] = formAction.value 26 -
少し FormAction を改善する struct FormAction<Root>: Equatable { ... // setter
を保持するようにする let setter: (inout Root) -> Void init<Value>( _ keyPath: WritableKeyPath<Root, Value>, _ value: Value ) where Value: Hashable { self.keyPath = keyPath self.value = AnyHashable(value) // 値を変更する($0 は Root ) self.setter = { $0[keyPath: keyPath] = value } } } 27 -
Equatable を満たせなくなるため、少し追加 let setter: (inout Root) -> Void はクロージャであるため、 追加した瞬間
Equatable を満たすことができなくなる しかし、クロージャが等しいかどうかには興味がない 別にテストする必要はない そのため、以下のようなコードを追加して Equatable を満たす static func == (lhs: Self, rhs: Self) -> Bool { lhs.keyPath == rhs.keyPath && lhs.value == rhs.value } 28 -
Reducer からはこんな感じで扱える //Reducer case let .form(formAction): formAction.setter(&state) if formAction.keyPath ==
\State.displayName { // 追加のロジック } else if formAction.keyPath == \State.sendNotifications { // 追加のロジック } 29 -
View からはこんな感じ TextField( "Display name", text: Binding( get: { viewStore.displayName
}, set: { // $0 は newDisplayName viewStore.send(.form(.init(\.displayName, $0))) } ) ) ⼗分良さそうではあるが、FormAction はイニシャライザを必要と しているため、毎回 .init を書くことになるのが微妙 30 -
// ViewStore を extension してみる extension ViewStore { func binding<Value>(
keyPath: WritableKeyPath<State, Value>, send action: @escaping (FormAction<State>) -> Action ) -> Binding<Value> where Value: Hashable { self.binding( get: { $0[keyPath: keyPath] }, send: { action(.init(keyPath, $0)) } ) } } TextField( "Display name", text: viewStore.binding( keyPath: \.displayName, send: Action.form ) ) 31 -
これで Action のボイラープレートが消える enum Action: Equatable { // case digestChanged(Digest)
// case displayNameChanged(String) // case protectMyPostsChanged(Bool) // case sendNotificationsChanged(Bool) case form(FormAction<State>) } 32 -
State を変更するだけではないものへの対応 // 細かいロジックは気にしなくて良いです case let .form(formAction): if formAction.keyPath ==
\State.displayName { state.displayName = String(state.displayName.prefix(16)) } else if formAction.keyPath == \State.sendNotifications { guard state.sendNotifications else { return .none } state.sendNotifications = false return environment.userNotifications.getNotificationSettings() .receive(on: environment.mainQueue) .map(Action.notificationSettingsResponse) .eraseToEffect() } 33 -
// 仮に State を追加したとしても... ? struct State: Equatable { //
... var sendMobileNotificaitons = false var sendEmailNotifications = false } // View からは State を追加するだけで使えてしまう! Toggle( "Email", isOn: viewStore.binding( keyPath: \.sendEmailNotifications, send: Action.form ) ) Toggle( "Mobile", isOn: viewStore.binding( keyPath: \.sendMobileNotifications, send: Action.form ) ) 34 -
あともう少しだけ改善していく case let .form(formAction): formAction.setter(&state) if formAction.keyPath == ... {
} return .none 良さそうだが、以下のようなパターン化されたことを⾏っている Action の setter を state に適⽤し、 必要であれば Action の KeyPath をチェックして、処理を⾏い、 最後に .none Effect を返却する 36 -
higher-order reducer を使えるようにしたい let Reducer = Reducer<State, Action, Environment> {
state, action, environment in // ... case let .form(formAction): // formAction.setter(&state) ここを取り除きたい } .form() higher-order reducer は Point-Free で何回か登場している概念 Reducer にある debug も higer-order reducer の⼀つ 37 -
higher-order reducer を作っていく extension Reducer { func form() -> Self
{ Self { state, action, environment in } } } やりたいこと Reducer から送られてくる Action をチェック FormAction であれば setter ロジックを実⾏ 実現するには Action を分離する必要がある -> CasePaths 38 -
extension Reducer { func form( action formAction: CasePath<Action, FormAction<State>> )
-> Self { Self { state, action, environment in // FormAction を抽出 guard let formAction = formAction.extract(form: action) else { // 失敗したら元の Reducer をそのまま実⾏ return self.run(&state, action, environment) } // 抽出成功したら formAction の setter を実⾏ formAction.setter(&state) // 成功したとしても、追加のロジックがあるかもしれないので元の Reducer も実⾏ return self.run(&state, action, enviroment) } } } // Reducer も少し書き換える let Reducer = Reducer<State, Action, Environment> { state, action, environment in // ... case let .form(formAction): // ここで setter を呼び出す必要がなくなる } .form(action: /Action.form) // どの case が form Action を保持しているかを識別するための CasePath を渡す 39 -
// Reducer には、まだ改善できることがある // 以下のような if, else は⾯倒 if formAction.keyPath
== \State.displayName { // ... } else if formAction.keyPath == \State.sendNotifications { // ... } // 以下のようにしたいが、Root を明⽰的に指定してというエラーが発⽣する if formAction.keyPath == \.displayname {} // switch を使えば多少マシになるが、インデントされているし、\State もあるし、default も処理しなければならない switch formAction.keyPath { case \Action.displayName: // ... case \Action.sendNotifications: // ... default: return .none } 40 -
// 仮に、こんな感じにできたとしたら良さそう switch action { // ... case .form(\.displayName): state.displayName
= String(state.displayName.prefix(16)) return .none case .form(\.sendNotifications): guard state.sendNotifications else { return .none } state.sendNotifications = false return environment.userNotifications.getNotificationSettings() .receive(on: environment.mainQueue) .map(Action.notificationSettingsResponse) .eraseToEffect() case .form: return .none } 41 -
実は Swift の ~= を使うと実現可能 Swift では ~= (twiddle equals)
演算⼦の overload を実装すること で switch ⽂のパターンマッチングの仕組みを利⽤できる // ⼆つの引数を取る[ 左:マッチさせたいパターン(case でマッチさせる値)、右:switch される値] func ~= (pattern, value) -> Bool { } switch 42 { case 10...: print("10 or more") default: break } // pattern が 10 で、value が 42 10... ~= 42 42 -
FormAction ⽤に override する // pattern(case でマッチさせる値) が keyPath で、
// value(switch される値) が formAction func ~= <Root, Value>( keyPath: WritableKeyPath<Root, Value>, formAction: FormAction<Root> ) -> Bool { formAction.keyPath == keyPath } // これでパターンマッチできるようになる switch action { // formAction: FormAction<State> case .form(\.displayName) // keyPath: WritableKeyPath<State, String> // ... } 43 -
あと少し、Test ⽤にだけ改善できる部分 store.assert( // init しているのは .form に渡す FormAction を作るため
// しかし、FormAction が表すのは KeyPath に値を設定したいという考え // そのため、init ではなくより適切な名前に変更する .send(.form(.init(\.displayName, "Blob"))) { $0.displayName = "Blob" }, .send(.form(.init(\.displayName, "Blob McBlob, Esq."))) { $0.displayName = "Blob McBlob, Esq" }, .send(.form(.init(\.protectPosts, true))) { $0.protectPosts = true }, .send(.form(.init(\.digest, .weekly))) { $0.digest = .weekly } 44 -
struct FormAction<Root>: Equatable { // ... static func set<Value>( _
keyPath: WritableKeyPath<Root, Value>, _ value: Value ) where Value: Hashable -> Self { self.init(keyPath, value) } } // テストは以下のように書けるようになる store.assert( // より直感的になった .send(.form(.set(\.displayName, "Blob"))) { $0.displayName = "Blob" }, .send(.form(.set(\.displayName, "Blob McBlob, Esq."))) { $0.displayName = "Blob McBlob, Esq" }, .send(.form(.set(\.protectPosts, true))) { $0.protectPosts = true }, .send(.form(.set(\.digest, .weekly))) { $0.digest = .weekly } ) 45 -
おわりに Composable Forms を使えば TCA のボイラープレートコードを 取り除くことができ、さらに TCA が扱いやすくなると感じました 今回の発表は、どのような仕組みで
Composable Forms が できているのかというものでした 最初に説明したように TCA の新しめのバージョンでは、 コードを追加することなく Composable Forms は利⽤可能です 46