Composable FormsでTCAのボイラープレートとおさらばする

4.5K Views

March 20, 21

スライド概要

TCA のボイラープレートコードを Composable Forms で滅する話です🙏

profile-image

iOS エンジニアをやっています。

シェア

埋め込む »CMSなどでJSが使えない場合

関連スライド

各ページのテキスト
1.

で のボイラープレートと おさらばする Composable Forms TCA iOS アプリ開発のためのFunctional Architecture情報共有会

2.

例えばこんな State, Action があったとする struct State: Equatable { var digest = Digest.daily var displayName = "" var protectMyPosts = false var sendNotifications = false } enum Action: Equatable { case digestChanged(Digest) // Digest case displayNameChanged(String) case protectMyPostsChanged(Bool) case sendNotificationsChanged(Bool) } は .daily, .weekly, .off を持つ enum 2

3.
[beta]
それに対応する 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
4.

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

5.

こういうボイラープレート感がある コードを⼀々書くのは⾯倒... 5

6.

そんな問題を解決してくれる Composable Forms が v0.12.0 から利⽤可能に! ※ v0.14.0 では少し利⽤⽅法が変更となっています 6

7.

今⽇は以下について話そうと思います がどのような考え⽅で出来上がったのか 主に Point-Free さんの ep133「Bye Bye Boilerplate」という エピソードについて今回はまとめています Composable Forms の実際の利⽤法については Zenn にまとめてみ たので、よろしければ参考にしてください 「TCA のボイラープレートを Composable Forms で解消する」 というタイトルでこの後公開する予定です Composable Forms 7

8.

先ほどの 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

9.
[beta]
こんな case があったらまとめられそう...? enum Action: Equatable { // ... case form((inout State) -> Void) } な State を受け取って、その State を変更できるようにする Equatable は⾃動的に破られてしまうため、 ↓ を enum に追加する inout static func ==(lhs: Action, rhs: Action) -> Bool { fatalError() // Equatable } 無理やりではあるが、⼀旦これで は満たすことができる 9
10.

先ほどの Action を扱う Reducer Reducer は以下のように定義できそう // Action case form((inout State) -> Void) //Reducer case let .form(update): update(&state) return .none 10

11.

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

12.

もちろんこの⽅法は問題点だらけ 追加ロジックを実装しにくい 例えば State の変更だけでなく、アラートを制御したり権限を 要求したりなどなど... State の変更が View で⾏われており、 TCA のルールを破っている 本来、State を変更できるのは Reducer のみ Equatable が破壊されてしまい、テスタブルではなくなっている 12

13.

どうやって問題を解決するか? 今のところ .form Action はクロージャなので、どんなことでも しようと思えばできる しかし、実際に .form Action で実現したいことは、State の値を 単純に変更することだけ 場合によっては、アラートの表⽰、副作⽤を発⽣させるなど ちょっとした追加ロジックも挟めるようにはしたい 幸い、Swift には KeyPath が存在しているため、State の変更を ある程度柔軟に表現することができる 13

14.

KeyPath を使うとどのように表現できるか // case form((inout State) -> Void) case form(WritableKeyPath<State, ???>, ???) ⼀つ⽬の ??? は変更したい値の型 ⼆つ⽬の ??? は変更したい値⾃体 型が決まっていないので、Generics を利⽤してみる case form<Value>(WritableKeyPath<State, Value>, Value) しかし、Swift の enum は Generics をサポートしていない... 14

15.

enum Reducer で Generics を使える想定で考えてみる はきっと以下のような形になる // case let .form(update): // update(&state) case let .form(keyPath, value: value): state[keyPath: keyPath] = value 任意の KeyPath が渡されるため、型が何であるかはわからない しかし、KeyPath で変更する型と Value の型は Generics によって ⼀致することは保証されている 15

16.

KeyPath は Equatable は Equatable なので、Action の Equatable も保てる Equatable であることを利⽤し、以下のようなこともできる KeyPath case let .form(keyPath, value: value): state[keyPath: keyPath] = value if keyPath == \State.displayName { // } else if keyPath == \State.sendNotificaitons { // } 追加のロジック 追加のロジック 16

17.

View からはこんな感じで使える TextField( "Display name", text: Binding( get: { viewStore.displayName }, set: { viewStore.send(.form(\.displayName, $0)) } ) // viewStore.binding( // get: \.displayName, // send: Action.displayNameChanged // ) ) 17

18.

Swift の機能を使って実際に実現していく 先ほどまでの例は enum で Generics が扱えるという前提があった しかし、実際には不可能なので⼯夫して同等の機能を実現したい 18

19.

少しずつ 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

20.

Generics は消えたが、まだ問題はある case form(PartialKeyPath<State>, value: Any) PartialKeyPath 限らない の Value の型と の型は value の Any が⼀致しているとは だが、このようなことができてしまう // displayName String Action.form(\.displayName, value: 1) 20

21.
[beta]
それを防ぐために作成⽅法を制限する 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
22.
[beta]
もう少し汎⽤的にしてみる 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
23.
[beta]
KeyPath KeyPath は Equatable は Equatable なので、Action 内にあった ↓ は、消せる //static func == (lhs: Action, rhs: Action) -> Bool { // fatalError() //} ついでに FormAction を Equatable に準拠させてみる struct FormAction<Root>: Equatable { ... } 23
24.
[beta]
しかし、今のままだと準拠させられない 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
25.
[beta]
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
26.

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

27.
[beta]
少し 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
28.
[beta]
Equatable を満たせなくなるため、少し追加 はクロージャであるため、 追加した瞬間 を満たすことができなくなる しかし、クロージャが等しいかどうかには興味がない 別にテストする必要はない そのため、以下のようなコードを追加して Equatable を満たす let setter: (inout Root) -> Void Equatable static func == (lhs: Self, rhs: Self) -> Bool { lhs.keyPath == rhs.keyPath && lhs.value == rhs.value } 28
29.

Reducer からはこんな感じで扱える //Reducer case let .form(formAction): formAction.setter(&state) if formAction.keyPath == \State.displayName { // } else if formAction.keyPath == \State.sendNotifications { // } 追加のロジック 追加のロジック 29

30.

View からはこんな感じ TextField( "Display name", text: Binding( get: { viewStore.displayName }, set: { // $0 newDisplayName viewStore.send(.form(.init(\.displayName, $0))) } ) ) は ⼗分良さそうではあるが、FormAction はイニシャライザを必要と しているため、毎回 .init を書くことになるのが微妙 30

31.
[beta]
を してみる // 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
32.

これで Action のボイラープレートが消える enum Action: Equatable { // case digestChanged(Digest) // case displayNameChanged(String) // case protectMyPostsChanged(Bool) // case sendNotificationsChanged(Bool) case form(FormAction<State>) } 32

33.

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

34.

仮に を追加したとしても ? // 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

35.

テストもコード追加せず書けます //.send(.displayNameChanged("Blob")) { .send(.form(.init(\.displayName, "Blob"))) { $0.displayName = "Blob" } 35

36.

あともう少しだけ改善していく case let .form(formAction): formAction.setter(&state) if formAction.keyPath == ... { } return .none 良さそうだが、以下のようなパターン化されたことを⾏っている Action の setter を state に適⽤し、 必要であれば Action の KeyPath をチェックして、処理を⾏い、 最後に .none Effect を返却する 36

37.
[beta]
higher-order reducer を使えるようにしたい let Reducer = Reducer<State, Action, Environment> { state, action, environment in // ... case let .form(formAction): // formAction.setter(&state) } .form() ここを取り除きたい は Point-Free で何回か登場している概念 Reducer にある debug も higer-order reducer の⼀つ higher-order reducer 37
38.

higher-order reducer を作っていく extension Reducer { func form() -> Self { Self { state, action, environment in } } } やりたいこと Reducer から送られてくる Action をチェック FormAction であれば setter ロジックを実⾏ 実現するには Action を分離する必要がある -> CasePaths 38

39.
[beta]
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
40.

には、まだ改善できることがある // 以下のような if, else は⾯倒 // Reducer if formAction.keyPath == \State.displayName { // ... } else if formAction.keyPath == \State.sendNotifications { // ... } 以下のようにしたいが、 を明⽰的に指定してというエラーが発⽣する // Root if formAction.keyPath == \.displayname {} を使えば多少マシになるが、インデントされているし、\State もあるし、default も処理しなければならない // switch switch formAction.keyPath { case \Action.displayName: // ... case \Action.sendNotifications: // ... default: return .none } 40

41.

仮に、こんな感じにできたとしたら良さそう // 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

42.
[beta]
実は Swift の ~= を使うと実現可能 では ~= (twiddle equals)演算⼦の overload を実装すること で ⽂のパターンマッチングの仕組みを利⽤できる Swift switch ⼆つの引数を取る 左:マッチさせたいパターン(case でマッチさせる値)、右:switch される値] // [ func ~= (pattern, value) -> Bool { } switch 42 { case 10...: print("10 or more") default: break } // pattern 10 value 42 10... ~= 42 が で、 が 42
43.
[beta]
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
44.

あと少し、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

45.
[beta]
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
46.

おわりに を使えば TCA のボイラープレートコードを 取り除くことができ、さらに TCA が扱いやすくなると感じました 今回の発表は、どのような仕組みで Composable Forms が できているのかというものでした 最初に説明したように TCA の新しめのバージョンでは、 コードを追加することなく Composable Forms は利⽤可能です Composable Forms 46