Upgrade to Pro — share decks privately, control downloads, hide ads and more …

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

6dc67d02d6b322ee317cece9b045317d?s=47 Aikawa
March 21, 2021

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

TCA の Composable Forms がどのような仕組みでできているかを説明します。

6dc67d02d6b322ee317cece9b045317d?s=128

Aikawa

March 21, 2021
Tweet

Transcript

  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 は .daily, .weekly, .off を持つ enum case displayNameChanged(String) case protectMyPostsChanged(Bool) case sendNotificationsChanged(Bool) } 2
  3. それに対応する 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. 今⽇は以下について話そうと思います Composable Forms がどのような考え⽅で出来上がったのか 主に Point-Free さんの ep133 「Bye Bye

    Boilerplate 」という エピソードについて今回はまとめています Composable Forms の実際の利⽤法については Zenn にまとめてみ たので、よろしければ参考にしてください 「TCA のボイラープレートを 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. こんな case があったらまとめられそう... ? enum Action: Equatable { // ...

    case form((inout State) -> Void) } inout な State を受け取って、その State を変更できるようにする Equatable は⾃動的に破られてしまうため、↓ を enum に追加する 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 で Generics を使える想定で考えてみる Reducer はきっと以下のような形になる // case let .form(update):

    // update(&state) case let .form(keyPath, value: value): state[keyPath: keyPath] = value 任意の KeyPath が渡されるため、型が何であるかはわからない しかし、KeyPath で変更する型と Value の型は Generics によって ⼀致することは保証されている 15
  16. 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
  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. それを防ぐために作成⽅法を制限する 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. もう少し汎⽤的にしてみる 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. KeyPath は Equatable KeyPath は Equatable なので、Action 内にあった ↓ は、消せる

    //static func == (lhs: Action, rhs: Action) -> Bool { // fatalError() //} ついでに FormAction を Equatable に準拠させてみる struct FormAction<Root>: Equatable { ... } 23
  24. しかし、今のままだと準拠させられない 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. 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. 少し 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. Equatable を満たせなくなるため、少し追加 let setter: (inout Root) -> Void はクロージャであるため、 追加した瞬間

    Equatable を満たすことができなくなる しかし、クロージャが等しいかどうかには興味がない 別にテストする必要はない そのため、以下のようなコードを追加して 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. // 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. 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
  38. higher-order reducer を作っていく extension Reducer { func form() -> Self

    { Self { state, action, environment in } } } やりたいこと Reducer から送られてくる Action をチェック FormAction であれば setter ロジックを実⾏ 実現するには Action を分離する必要がある -> CasePaths 38
  39. 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. // 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
  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. 実は 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
  43. 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. 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. おわりに Composable Forms を使えば TCA のボイラープレートコードを 取り除くことができ、さらに TCA が扱いやすくなると感じました 今回の発表は、どのような仕組みで

    Composable Forms が できているのかというものでした 最初に説明したように TCA の新しめのバージョンでは、 コードを追加することなく Composable Forms は利⽤可能です 46