1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

SwiftUIAdvent Calendar 2024

Day 11

AsyncStreamを`for await`する`task`をラップしようとしてハマった話

Last updated at Posted at 2024-12-11

はじめに

RxSwift.PublishRelayCombine.PassthroughSubject に代わるものを作ろうとするとAsyncStreamを使用することになります。

例えば以下のコードを見てみましょう。

private var event: ((any Sendable) -> Void)?

var stream: AsyncStream<any Sendable> {
    AsyncStream { cont in
        cont.onTermination = { print($0) }
        event = { cont.yield($0) }
    }
}

let msgs = ["foo", "bar", "baz"]

struct MyView1: View {
    @State private var count = 0
    @Binding var navigationPath: NavigationPath

    var body: some View {
        VStack(spacing: 16) {
            Button("Hello World!") {
                defer { count += 1 }
                event?(msgs[count % msgs.count])
            }
            Text("count: \(count)")
            Button("Submit") {
                navigationPath.append("submit")
            }
            Button("Reset") {
                count = 0
            }
        }
        .padding(20)
        .task {
            for await event in stream {
                print(event) // 流れて来たイベント
            }
        }
        .navigationDestination(for: String.self) {
            switch $0 {
            case "submit":
                MyView2(
                    count: count,
                    navigationPath: $navigationPath
                )
            default: EmptyView()
            }
        }
    }
}

struct MyView2: View {
    var count: Int
    @Binding var navigationPath: NavigationPath

    var body: some View {
        Text("count: \(count)")
    }
}

struct RootView: View {
    @State private var navigationPath = NavigationPath()

    var body: some View {
        NavigationStack(path: $navigationPath) {
            MyView1(navigationPath: $navigationPath)
        }
    }
}

このコードをPlaygroundで動かした結果がこちら

task.gif

streamに流した値がtaskの中で処理されていることが分かります。
また、画面遷移によってtaskがキャンセルされるため、stream内の cont.onTermination が発火していることも確認できます。

問題のコード

PassthroughSubjectを使っていた時は onReceive を使って

.onReceive(subject) {
    // イベントを処理する
}

と書いていました。
このview側のコードをなるべく変えないよう以下のextensionを切ってみました。

extension View {
    func onReceive<E: Sendable>(
        _ stream: AsyncStream<E>,
        action: @escaping (E) -> Void
    ) -> some View {
        task {
            for await event in stream {
                action(event)
            }
        }
    }
}

そしてMyView1のbodyを以下のように書き換えます

VStack(spacing: 16) {
    Button("Hello World!") {
        defer { count += 1 }
        event?(msgs[count % msgs.count])
    }
    Text("count: \(count)")
    Button("Submit") {
        navigationPath.append("submit")
    }
    Button("Reset") {
        count = 0
    }
}
.padding(20)
- .task {
-     for await event in stream {
-         print(event) // 流れて来たイベント
-     }
- }
+ .onReceive(stream) { print($0) }
.navigationDestination(for: String.self) {
    switch $0 {
    case "submit":
        MyView2(
            count: count,
            navigationPath: $navigationPath
        )
    default: EmptyView()
    }
}

一見問題無いように見えますが、実際に動かすと想定しない動きをします。

on_receive.gif

原因

taskの中はclosureなので、画面表示されてから初めて実行されそのタイミングでstreamが発行されます。
また、taskは画面表示後の1度しか実行されない(@Stateの変更に左右されない)ため想定通りの動きになります。
一方、今回作成した onReceive は初期表示時の他@Stateが変更される度に呼ばれてしまうため、その度にstreamが発行されてしまいますが task の中は実行されないためそのまま捨てられてしまいます。

解決策

この現象を回避する手段として、onReceiveに渡すstreamを遅延評価させる方法があります。
具体的には値をそのまま渡すのではなくClosureに閉じ込めて渡します。

extension View {
    func onReceive<E: Sendable>(
-       _ stream: AsyncStream<E>,
+       _ stream: @escaping () -> AsyncStream<E>,
        action: @escaping (E) -> Void
    ) -> some View {
        task {
-           for await event in stream {
+           for await event in stream() {
                action(event)
            }
        }
    }
}

呼び出し方は

- .onReceive(stream) { print($0) }
+ .onReceive({stream}) { print($0) }

または

- .onReceive(stream) { print($0) }
+ .onReceive { stream } action: { print($0) }

のようになります。

...ちょっと見栄えが悪いですね😅

シン・解決策

Swiftには @autoclosure という独特の機能があります。
この機能を使うことで、呼び出し元に手を加えることなく遅延評価を実現することができます。

extension View {
    func onReceive<E: Sendable>(
-       _ stream: AsyncStream<E>,
+       _ stream: @autoclosure @escaping () -> AsyncStream<E>,
        action: @escaping (E) -> Void
    ) -> some View {
        task {
-           for await event in stream {
+           for await event in stream() {
                action(event)
            }
        }
    }
}

そして最終的にできたコードで動かした結果がこちら
autoclosure.gif

想定通り動くようになりました:tada::tada::tada:

まとめ

task(_:)をラップして関数を作る場合、遅延評価をしないと意図しない動きをする場合があることに注意しましょう。

参考

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?