🍎

[SwiftUI][TCA] Effect

2022/05/24に公開

概要

この記事ではTCA初心者の筆者が理解を深めていくために、
pointfreeco公式のサンプルアプリを基に理解しやすく整理していきます。

今回から2章として02Effectsの内容を整理していきます。
前回までの記事はこちら

今回扱うファイル

今回は公式サンプルの以下2つのファイルです。
Basics
https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Basics.swift
Cancellation
https://github.com/pointfreeco/swift-composable-architecture/blob/main/Examples/CaseStudies/SwiftUICaseStudies/02-Effects-Cancellation.swift

Basics

Counter機能に加えてNumber factボタンでAPIをコールし、
そのレスポンスを処理し結果を表示しています。
1つポイントとしてdecrementボタン押下時の挙動で、
1秒後に数値がインクリメントされていることです。

State,Action

今回のBasicsでのここでのポイントは、
ActionのnumberFactResponseの引数でレスポンスを受け取るぐらいで、
これまでの記事とさほど変わりはないです。

struct EffectsBasicsState: Equatable {
  var count = 0
  var isNumberFactRequestInFlight = false
  var numberFact: String?
}

enum EffectsBasicsAction: Equatable {
  case decrementButtonTapped
  case incrementButtonTapped
  case numberFactButtonTapped
  case numberFactResponse(Result<String, FactClient.Error>)
}

Environment

これまでの記事で使用されていなかったEnvironmentについてです。
定義されているfactとmainQueueを整理します。

struct EffectsBasicsEnvironment {
  var fact: FactClient
  var mainQueue: AnySchedulerOf<DispatchQueue>
}

FactClient

ますFactClientから見ていきます。
これはAPIコール用のClientで以下のように書かれています。
リクエストのパラメータに必要なIntを引数に、
レスポンスが入るEffectを返しています。

struct FactClient {
  var fetch: (Int) -> Effect<String, Error>

  struct Error: Swift.Error, Equatable {}
}

extension FactClient {
    static let live = Self(
      fetch: { number in
        Effect.task {
          do {
            let (data, _) = try await URLSession.shared
              .data(from: URL(string: "http://numbersapi.com/\(number)/trivia")!)
            return String(decoding: data, as: UTF8.self)
          } catch {
            try? await Task.sleep(nanoseconds: NSEC_PER_SEC)
            return "\(number) is a good number Brent"
          }
        }
        .setFailureType(to: Error.self)
        .eraseToEffect()
      }
    )
}

AnySchedulerOf

続いてAnySchedulerOfです。
Schedulerを定義することでEffectで内で、
例えばdelayreceive等で使用します。

Reducer

各アクションの中で今回Effectを使用している箇所を整理します。
以下はEffectに関する公式リファレンスです。
https://pointfreeco.github.io/swift-composable-architecture/Effect/

let effectsBasicsReducer = Reducer<
  EffectsBasicsState, EffectsBasicsAction, EffectsBasicsEnvironment
> { state, action, environment in
  switch action {
  case .decrementButtonTapped:
    state.count -= 1
    state.numberFact = nil
    // ①1秒後にカウントを再インクリメントするEffectを返す
    return Effect(value: EffectsBasicsAction.incrementButtonTapped)
      .delay(for: 1, scheduler: environment.mainQueue)
      .eraseToEffect()

  case .incrementButtonTapped:
    state.count += 1
    state.numberFact = nil
    return .none

  case .numberFactButtonTapped:
    state.isNumberFactRequestInFlight = true
    state.numberFact = nil
    // ②APIからのレスポンスの値を`numberFactResponse`アクションに返すEffectを返す
    return environment.fact.fetch(state.count)
      .receive(on: environment.mainQueue)
      .catchToEffect(EffectsBasicsAction.numberFactResponse)
    // ③APIからのレスポンスをStateに更新する
  case let .numberFactResponse(.success(response)):
    state.isNumberFactRequestInFlight = false
    state.numberFact = response
    return .none

  case .numberFactResponse(.failure):
    state.isNumberFactRequestInFlight = false
    return .none
  }
}

①decrementButtonTapped

return Effect(value: EffectsBasicsAction.incrementButtonTapped)
      .delay(for: 1, scheduler: environment.mainQueue)
      .eraseToEffect()

まずは1秒後にカウントを再インクリメントするEffectである、
decrementButtonTappedアクションの部分です。
引数のvalueではアクションを定義し、
delayメソッドで1秒後にincrementButtonTappedアクションを送っています。
このままだとPublishersなので最後にeraseToEffectでEffectに変換しています。

②numberFactButtonTapped

 return environment.fact.fetch(state.count)
      .receive(on: environment.mainQueue)
      .catchToEffect(EffectsBasicsAction.numberFactResponse)

ここはAPIからのレスポンスの値をnumberFactResponseアクションに返すEffectを返す
numberFactButtonTappedアクションの部分です。
FactClientfetchメソッドにてパラメータであるIntを受け取りAPIコールをします。
この段階ではEffectなのですが、
もちろんそれを処理するreceiveメソッドが必要になるので、
再びSchedulerのmainQueueでreceiveします。
最後にcatchToEffectで引数にActionを入れていますが、
これはActionが必要なのではなく、
今回入れているnumberFactResponseアクションの引数に、
Result<String, FactClient.Error> が定義されていることがポイントになります。
以下のcatchToEffectのソースを見てみると理解しやすいかと思います。

 public func catchToEffect<T>(
    _ transform: @escaping (Result<Output, Failure>) -> T
  ) -> Effect<T, Never> {
    self
      .map { transform(.success($0)) }
      .catch { Just(transform(.failure($0))) }
      .eraseToEffect()
  }

なので簡潔に整理するとcatchToEffectのパラメータは、
APIのレスポンスのResultを持つActionを入れることで、
そのレスポンスの値を持つActionが走りStateに値を更新出来るようになります。※③の部分

View,Store

View側では特にポイントとなる新たな処理はありません。

Cancellation

上記のBasicsの機能に加えてCancellボタンで、
Environmentを中断することが出来ます。
(公式のままだとCancellボタン押す時間が無かったのでdelayを入れています)

State,Action

今回のCancellationでは、
ActionのcancelButtonTappedが加わったことです。
Action内容はReducerで記載します。
あと上記Basicsとの違いだとincrement・decrementが、
Stepperで実装されているのでstepperChangedのみになっています。

struct EffectsCancellationState: Equatable {
  var count = 0
  var currentTrivia: String?
  var isTriviaRequestInFlight = false
}

enum EffectsCancellationAction: Equatable {
  case cancelButtonTapped
  case stepperChanged(Int)
  case triviaButtonTapped
  case triviaResponse(Result<String, FactClient.Error>)
}

Environment

Environmentは上記のBasicsと変更ありません。

Reducer

Effectで今回ポイントの.cancellableを使用している箇所を整理します。
.catchToEffectの後に.cancellableメソッドを定義することで、
Effectをキャンセル可能なものに変更します。
.cancel(id:)で使用され、
現在処理中のどのEffectがキャンセルされるべきかを識別します。
https://pointfreeco.github.io/swift-composable-architecture/Effect/#effect.cancellable(id:cancelinflight:)

  case .triviaButtonTapped:
    state.currentTrivia = nil
    state.isTriviaRequestInFlight = true

    return environment.fact.fetch(state.count)
      .receive(on: environment.mainQueue)
      .catchToEffect(EffectsCancellationAction.triviaResponse)
      .cancellable(id: TriviaRequestId())

そして上記Cancellableがコールされる契機である、.cancelは、
与えられた識別子で現在処理中のEffectをキャンセルするEffectを返します。
識別子は、文字列のようなハッシュ可能な値なら何でも使えますが、
Hashableに準拠した新しい型の構造体を定義することで、
タイプミスによるミスを少し抑えることができます。
https://pointfreeco.github.io/swift-composable-architecture/Effect/#effect.cancel(id:)

  case .cancelButtonTapped:
    state.isTriviaRequestInFlight = false
    return .cancel(id: TriviaRequestId())

  case let .stepperChanged(value):
    state.count = value
    state.currentTrivia = nil
    state.isTriviaRequestInFlight = false
    return .cancel(id: TriviaRequestId())

View,Store

こちらもView側では特にポイントとなる新たな処理はありません。

次回

次回はrefreshableについての記事となります。

Discussion