Slide 1

Slide 1 text

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

Slide 2

Slide 2 text

例えばこんな 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

Slide 3

Slide 3 text

それに対応する Reducer はこんな感じ let reducer = Reducer { 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

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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

Slide 6

Slide 6 text

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

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

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

Slide 12

Slide 12 text

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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

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

Slide 16

Slide 16 text

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

Slide 17

Slide 17 text

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

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

少しずつ Generics を消していく case form(WritableKeyPath, Value) ↓ // PartialKeyPath は Root の型のみを保持し、Value の型は消す case form(PartialKeyPath, Value) ↓ // value の型だけであれば Any でも⼤丈夫 case form(PartialKeyPath, value: Any) 19

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

それを防ぐために作成⽅法を制限する struct FormAction { let keyPath: PartialKeyPath let value: Any init( _ keyPath: WritableKeyPath, _ value: Value ) { self.keyPath = keyPath self.value = value } } このようにすれば、Value という型で⼀致させることができる 21

Slide 22

Slide 22 text

もう少し汎⽤的にしてみる struct FormAction { let keyPath: PartialKeyPath let value: Any init( _ keyPath: WritableKeyPath, _ value: Value ) { self.keyPath = keyPath self.value = value } } // Action 内では以下のように扱える case form(FormAction) 22

Slide 23

Slide 23 text

KeyPath は Equatable KeyPath は Equatable なので、Action 内にあった ↓ は、消せる //static func == (lhs: Action, rhs: Action) -> Bool { // fatalError() //} ついでに FormAction を Equatable に準拠させてみる struct FormAction: Equatable { ... } 23

Slide 24

Slide 24 text

しかし、今のままだと準拠させられない struct FormAction: Equatable { let keyPath: PartialKeyPath let value: Any // Any 型は Equatable ではないため、準拠させられない init( _ keyPath: WritableKeyPath, _ value: Value ) { self.keyPath = keyPath self.value = value } } 24

Slide 25

Slide 25 text

AnyHashable を利⽤する 本当は AnyEquatable のようなものがあったら適切だったが、 それがないため、Equatable に準拠している AnyHashable を利⽤する struct FormAction: Equatable { let keyPath: PartialKeyPath let value: AnyHashable init( _ keyPath: WritableKeyPath, _ value: Value ) { self.keyPath = keyPath self.value = AnyHashable(value) } } 25

Slide 26

Slide 26 text

Reducer からはどんな形で扱えるか // Form Action struct struct FormAction: Equatable { let keyPath: PartialKeyPath let value: AnyHashable // ... } // Action case form(FormAction) // Reducer case let .form(formAction): // 実はこれはできない(PartialKeyPath に書き込む機能はないため) state[keyPath: formAction.keyPath] = formAction.value 26

Slide 27

Slide 27 text

少し FormAction を改善する struct FormAction: Equatable { ... // setter を保持するようにする let setter: (inout Root) -> Void init( _ keyPath: WritableKeyPath, _ value: Value ) where Value: Hashable { self.keyPath = keyPath self.value = AnyHashable(value) // 値を変更する($0 は Root ) self.setter = { $0[keyPath: keyPath] = value } } } 27

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

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

Slide 31

Slide 31 text

// ViewStore を extension してみる extension ViewStore { func binding( keyPath: WritableKeyPath, send action: @escaping (FormAction) -> Action ) -> Binding 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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

// 仮に 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

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

extension Reducer { func form( action formAction: CasePath> ) -> 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 in // ... case let .form(formAction): // ここで setter を呼び出す必要がなくなる } .form(action: /Action.form) // どの case が form Action を保持しているかを識別するための CasePath を渡す 39

Slide 40

Slide 40 text

// 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

Slide 41

Slide 41 text

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

Slide 42

Slide 42 text

実は 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

Slide 43

Slide 43 text

FormAction ⽤に override する // pattern(case でマッチさせる値) が keyPath で、 // value(switch される値) が formAction func ~= ( keyPath: WritableKeyPath, formAction: FormAction ) -> Bool { formAction.keyPath == keyPath } // これでパターンマッチできるようになる switch action { // formAction: FormAction case .form(\.displayName) // keyPath: WritableKeyPath // ... } 43

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

struct FormAction: Equatable { // ... static func set( _ keyPath: WritableKeyPath, _ 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

Slide 46

Slide 46 text

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