[SwiftUI][TCA] Effect
概要
この記事ではTCA初心者の筆者が理解を深めていくために、
pointfreeco
公式のサンプルアプリを基に理解しやすく整理していきます。
今回から2章として02Effects
の内容を整理していきます。
前回までの記事はこちら
今回扱うファイル
今回は公式サンプルの以下2つのファイルです。
Basics
Cancellation
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
で内で、
例えばdelay
やreceive
等で使用します。
Reducer
各アクションの中で今回Effect
を使用している箇所を整理します。
以下は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
アクションの部分です。
FactClient
のfetch
メソッドにてパラメータである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
がキャンセルされるべきかを識別します。
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に準拠した新しい型の構造体を定義することで、
タイプミスによるミスを少し抑えることができます。
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側では特にポイントとなる新たな処理はありません。
Discussion