📖

swift-concurrency-extrasのStreamsはちょっと便利なヘルパー

2024/12/09に公開

TCA Advent Calendar 2024の記事です。

はじめに

TCAに使われているswift-concurrency-extrasのStreamsについて解説します。バージョンはv1.3です。

もし何か間違いがあったらコメントいただければ助かります。

Streams

AsyncSequenceとAsyncStream/AsyncThrowingStreamを拡張してヘルパーを追加しています。

前提

  • プロトコル
  • AsyncSequenceプロトコルに準拠する型
    • AsyncStream
      • ヘルパー
        • AsyncStream.never
        • AsyncStream.finished
    • AsyncThrowingStream
      • ヘルパー
        • AsyncThrowingStream.never
        • AsyncThrowingStream.finished

AsyncStream/AsyncThrowingStreamは何に使われるか

AsyncStream/AsyncThrowingStreamはアプリ内での状態の変化を監視するなどの用途で私は使います。

想像しやすいサンプルは次の通り。

for await state in authObserver.observe() {
  switch state {
  case .login:
    // ログイン成功した後の処理

  case .logout:
    // ログアウト状態になった場合の処理
    // たとえばログイン画面を出すなど
  }
}

AsyncStream.never/AsyncThrowingStream.never

AsyncStream.neverの戻り値は、
キャンセルされるまで何も要素を利用可能にしないAsyncStreamを返します。
テスト時などに何も返さないストリームをDIする際に有用です。

先述の例に沿ってDIする例は下記の通りです。

struct AuthObserverClient {
  var observe: () async -> AuthState
}
// MARK: - テスト用のDI
AuthObserverClient(
  observe: {
    AsyncStream.never
  }
)

AsyncThrowingStream.neverも同様なため説明を省略します。

実装

https://github.com/pointfreeco/swift-concurrency-extras/blob/main/Sources/ConcurrencyExtras/AsyncStream.swift#L83-L86

これは同じライブラリswift-concurrency-extrasのTask.neverでも利用されます。

https://zenn.dev/yimajo/articles/39f24fccd13bfa#taskのsuccessがneverでなくfailureがnever

実装が単純なのでextensionにするまでもないように思えますが、
Taskでもneverがあり、さらにTaskからも利用しているために意味は大きいと考えます。

AsyncThrowingStream.finished

AsyncThrowingStream.finishedの戻り値は、
即座に完了を伝えるAsyncThrowingStreamを返します。
引数で任意のエラーを指定可能です。

struct AuthObserverClient {
  var observe: () async throws -> AuthState
}
// MARK: - テスト用のDI
AuthObserverClient(
  observe: {
    AsyncStream.finished(throwing: TimeoutError)
  }
)

この例ではTimeoutErrorという任意のエラーを追加し、
テスト時にそれを検知した場合の検証を行えるようにしているという例です。

eraseToStream

eraseToStreamはAsyncSequenceからAsyncStreamを取り出すヘルパーです。プロトコルに準拠する具体的な型を汎用的なstructに変換させ、定義にあわせやすくしています。

iOSでスクリーンショットされた際の通知を、
AsyncStream型に変換する実例をステップバイステップで示す方が分かりやすいでしょう。
利用例から示すと、screenshotsというメソッドを作ってそれを利用したい場合次のようにするとします。

  func startMonitoring() async {
    for await _ in client.screenshots() {
      // スクリーンショット検知時の処理
    }
  }
struct ScreenShotClient {
  var screenshots() -> AsyncStream<Void>
}

ScreenShotClient(
  screenshots: {
    NotificationCenter.default
      .notifications(named: UIApplication.userDidTakeScreenshotNotification)
      .map { _ in } // AsyncMapSequence<NotificationCenter.Notifications, Transformed>
      .eraseToStream() // AsyncStream<Void>
  }
)

これを1つずつ説明すると、まずFoundationのNotificationCenterは.notificationsメソッドを持ちます。次の通りです。

// 具体的な型: NotificationCenter.Notifications
NotificationCenter.default
  .notifications(named: UIApplication.userDidTakeScreenshotNotification)

ここで取得できる型はNotificationCenter.Notificationsです。

これをStreamにし、すべての要素を使いたいためmapメソッドを利用します。

// 具体的な型: AsyncMapSequence<NotificationCenter.Notifications, Transformed>
NotificationCenter.default
  .notifications(named: UIApplication.userDidTakeScreenshotNotification)
  .map { _ in }

この戻り値の型はAsyncMapSequence<NotificationCenter.Notifications, Transformed>です。

ここでもし、mapではなくfilterメソッドを利用して一部の通知のみを捕まえたいのならAsyncFilterSequence<NotificationCenter.Notifications>型になります。

なぜそんなに細かい型があるのかというと、型安全のために細かい型が存在するのだと思います。さらにそれを掘り下げると、これらの細かい型であることでコンパイラによる最適化や型安全なチェーン構築を可能にするという思想が大きくあると考えます。

ちなみにRxSwiftではメソッドチェーンを使っても具体的な型を表に出さず、内部でキャストしているため毎回その分のコストは避けきれていませんでした(ObservableはObservableであり、MapObservableではないということ)。

さらに前提として、Swift標準ライブラリは同期的なSequence関連APIでもLazyFilterSequenceやLazyMapSequenceのように、各種変換ごとに固有のシーケンス型を提供する設計ポリシーがありそれに従ってもいます。

具体例に戻ると、AsyncMapSequence<NotificationCenter.Notifications, Transformed>では抽象度が足りないため、AsyncStreamに変換するためにeraseToStreamを使うということです。

// 具体的な型: AsyncStream<Void>
NotificationCenter.default
  .notifications(named: UIApplication.userDidTakeScreenshotNotification)
  .map { _ in }
  .eraseToStream() // ここで具体型を消去

iOS18からはeraseToStreamは非推奨

iOS 18からはeraseToStreamメソッドを利用しなくてもanyとしてプロトコルをそのまま定義すればコンパイルエラーにならず型消去できます。

struct ScreenshotsClient {
    // any AsyncSequenceとして型消去
    var screenshots: () -> any AsyncSequence<Void, Never>
}

おわりに

TCAを使う際も、Task.neverやAsyncStream.finishedなどは頻繁に使っているはずです。TCA外で使うにも便利なヘルパーだなという感じですね。

TCAに使われているswift-concurrency-extrasについては、下記の記事を含めてすべて解説し終わりました。

https://zenn.dev/yimajo/articles/eaa416cec23e58
https://zenn.dev/yimajo/articles/9297894b0895c0
https://zenn.dev/yimajo/articles/b1ddb17ba56370
https://zenn.dev/yimajo/articles/39f24fccd13bfa

Discussion