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

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

Aikawa
March 21, 2021

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

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

Aikawa

March 21, 2021
Tweet

More Decks by Aikawa

Other Decks in Programming

Transcript

  1. Composable Forms

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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

  6. そんな問題を解決してくれる
    Composable Forms

    v0.12.0
    から利⽤可能に!
    ※ v0.14.0
    では少し利⽤⽅法が変更となっています
    6

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  19. 少しずつ Generics
    を消していく
    case form(WritableKeyPath, Value)

    // PartialKeyPath
    は Root
    の型のみを保持し、Value
    の型は消す
    case form(PartialKeyPath, Value)

    // value
    の型だけであれば Any
    でも⼤丈夫
    case form(PartialKeyPath, value: Any)
    19

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  25. 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

    View Slide

  26. 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

    View Slide

  27. 少し 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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  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

    View Slide

  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

    View Slide

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

    View Slide

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

    View Slide

  37. 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

    View Slide

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

    View Slide

  39. 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

    View Slide

  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

    View Slide

  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

    View Slide

  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

    View Slide

  43. 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

    View Slide

  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

    View Slide

  45. 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

    View Slide

  46. おわりに
    Composable Forms
    を使えば TCA
    のボイラープレートコードを
    取り除くことができ、さらに TCA
    が扱いやすくなると感じました
    今回の発表は、どのような仕組みで Composable Forms

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

    View Slide