🐥

TCA の Effect を整理してみる

7 min read 2

はじめに

TCA では副作用を Effect で扱います。
Effect には便利に使うために利用可能な function がいくつかあります。
個人的にどの function がどのような時に使えるかすぐ忘れてしまうので今現在 TCA で定義されている function をまとめてみようと思います(コード上の Summary, Discussion を抜粋している形です🙏 )。
今回は以下についてまとめます。

  • future(_: )
  • result(_: )
  • run(_: )
  • concatenate(_: )
  • merge(_: )
  • fireAndForget(_: )
  • map(_: )
  • catching(_: )
  • init(value: )

future(_: )

内部実装

future(_: ) の実装は以下のようになっています。

public static func future(
  _ attemptToFulfill: @escaping (@escaping (Result<Output, Failure>) -> Void) -> Void
) -> Effect {
  Deferred {
    Future { callback in
      attemptToFulfill { result in callback(result) }
    }
  }
  .eraseToEffect()
}

future(_: ) は名前の通り、内部的には Combine の Future を利用しています。
future(_: ) では、Future を利用して単一の値を非同期的に扱うことができる Effect を作成できます。

使用例

基本的に future(_: ) は callback ベースの API を Effect に変換するために利用することができます。
例えば、1 秒待った後に整数を送るような Effect を作成するには ↓ のように利用します。

Effect<Int, Never>.future { callback in
  DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    callback(.success(42))
  }
}

callback に渡すことができる値が一つだけということには注意しなければなりません。
二つ以上の値を送信した場合、破棄されるようになっています。

Effect<Int, Never>.future { callback in
  DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
    callback(.success(42))
    callback(.success(1729)) // これ以降は破棄されます
  }
}

もし複数の値を Effect に渡す必要がある場合は、Subscriber value を受け付ける Effect のイニシャライザ(後述の run(_: ) )を使用する必要があります。

result(_: )

内部実装

result(_: ) の実装は以下のようになっています。

public static func result(_ attemptToFulfill: @escaping () -> Result<Output, Failure>) -> Self {
  Deferred { Future { $0(attemptToFulfill()) } }.eraseToEffect()
}

result(_: ) では、戻り値が Result のクロージャを result(_: ) の引数に与えて利用することによって、同期的な Effect を作成することができます。

使用例

例えば、ディスク上のいくつかの JSON から User をロードするような実装を Effect として以下のように作成することができます。

Effect<User, Error>.result {
  let fileUrl = URL(
    fileURLWithPath: NSSearchPathForDirectoriesInDomains(
      .documentDirectory, .userDomainMask, true
    )[0]
  )
  .appendingPathComponent("user.json")

  let result = Result<User, Error> {
    let data = try Data(contentsOf: fileUrl)
    return try JSONDecoder().decode(User.self, from: $0)
  }

  return result
}

run(_: )

内部実装

run(_: ) の実装は以下のようになっています。

public static func run(
  _ work: @escaping (Effect.Subscriber) -> Cancellable
) -> Self {
  AnyPublisher.create(work).eraseToEffect()
}

run(_: ) は複数の値を送信できる Subscriber から Effect を作成できます。

使用例

callback API, delegate API, manager API などを Effect として扱えるようにするために利用できます。
これらの API を Effect でラップすれば Reducer で処理できるようになります。
例えば、MPMediaLibrary へのアクセスを要求する Effect を以下のように作成できます。

Effect.run { subscriber in
  subscriber.send(MPMediaLibrary.authorizationStatus())

  guard MPMediaLibrary.authorizationStatus() == .notDetermined else {
    // Failure を send する場合は `subscriber.send(compltion: )` とする
    subscriber.send(completion: .finished)
    return AnyCancellable {}
  }

  MPMediaLibrary.requestAuthorization { status in
    subscriber.send(status)
    subscriber.send(completion: .finished)
  }
  return AnyCancellable {
    // Typically clean up resources that were created here, but this effect doesn't
    // have any.
  }
}

concatenate(_: )

内部実装

concatenate(_: ) の実装は以下のようになっています。

public static func concatenate(_ effects: Effect...) -> Effect {
  .concatenate(effects)
}

public static func concatenate<C: Collection>(
  _ effects: C
) -> Effect where C.Element == Effect {
  guard let first = effects.first else { return .none }

  return
    effects
    .dropFirst()
    .reduce(into: first) { effects, effect in
      effects = effects.append(effect).eraseToEffect()
    }
}

concatenate(_: ) は Effect のコレクションを一つの Effect に連結して、Effect を次々に実行することができます。
concatenate(_: ) を利用する上では、この function が使用する Combine の Publishers.Concatenate は、その suffix が Publishers.MergeMany である場合にリークする可能性があることに注意すべきと Discussion に記載されているので注意しましょう。

merge(_: )

内部実装

merge(_: ) の内部実装は以下のようになっています。

public static func merge(
  _ effects: Effect...
) -> Effect {
  .merge(effects)
}

public static func merge<S: Sequence>(_ effects: S) -> Effect where S.Element == Effect {
  Publishers.MergeMany(effects).eraseToEffect()
}

merge(_: ) は Effect の可変リストを一つの Effect にマージし、同時に Effect を実行することができます。

fireAndForget(_: )

内部実装

fireAndForget(_: ) の実装は以下のようになっています。

public static func fireAndForget(_ work: @escaping () -> Void) -> Effect {
  Deferred { () -> Publishers.CompactMap<Result<Output?, Failure>.Publisher, Output> in
    work()
    return Just<Output?>(nil)
      .setFailureType(to: Failure.self)
      .compactMap { $0 }
  }
  .eraseToEffect()
}

fireAndForget(_: ) は Store に data を送信する必要がない場合に、任意の処理を実行するような Effect を作成することができます。

map(_: )

内部実装

map(_: ) の内部実装は以下のようになっています。

public func map<T>(_ transform: @escaping (Output) -> T) -> Effect<T, Failure> {
  .init(self.map(transform) as Publishers.Map<Self, T>)
}

map は Swift の map operator と似ていて、上流から流れてきた Effect を任意のクロージャで変換することができます。

catching(_: )

内部実装

catching(_: ) の内部実装は以下のようになっています。

public static func catching(_ work: @escaping () throws -> Output) -> Self {
  .future { $0(Result { try work() }) }
}

catching(_: )result(_: ) と似ていますが、クロージャの戻り値が Result 型ではありません。

使用例

利用方法はほとんど result(_: ) と変わりません。

Effect<User, Error>.catching {
  let fileUrl = URL(
    fileURLWithPath: NSSearchPathForDirectoriesInDomains(
      .documentDirectory, .userDomainMask, true
    )[0]
  )
  .appendingPathComponent("user.json")

  let data = try Data(contentsOf: fileUrl)
  // Result で return しない
  return try JSONDecoder().decode(User.self, from: $0)
}

init(value: )

今城さんからコメントを頂いたので、よく使うイニシャライザである init(value: ) についても追記します🙏

内部実装

イニシャライザなので、以下のように value を指定するだけで Effect として扱えるようになります。

public init(value: Output) {
  self.init(Just(value).setFailureType(to: Failure.self))
}

使用例

使用例については今城さんがコメントで詳しく説明してくださっているので、そちらをご参照ください。

おわりに

今回は TCA の Effect.swift で定義されている様々な function の Summary と Discussion を紹介しました。
まだ自分も実際に利用したことがないものもあり、しっかりと理解できていない部分もありますが、勉強してわかったことや役立ちそうなことがあれば追記していこうと思います。
また、公式の Examples/CaseStudies/SwiftUICaseStudies/02-Effects-WebSocket.swift では Effect が様々な方法で利用されているため、そちらと照らし合わせながら見て頂けると理解が深まるかなと思います。(他にも Examples には良い例がたくさんあります)

この記事に贈られたバッジ