【Swift】ポーリング処理を書いてみた話

## はじめに
最近、iOSアプリ開発でポーリング処理を書く機会がありました。この処理を実装する中で、いくつかの方法を試しながら学んだことや気づきを共有したいと思います。この記事では、TimerやAsyncStreamを使った実装方法を例に、ポーリング処理の基本と、それぞれのメリット・デメリットを解説します。

## ポーリング処理とは
そもそもポーリング処理とは、**一定間隔でサーバーにリクエストを送り、状態やデータの変化を確認する手法**のことです。例えば、バックエンドでのデータ処理が終わったかを確認したり、ユーザーがトリガーした操作の結果を取得したりする場合に使われます。

## Timerで実装
まずは、Timerを使ったポーリング処理の実装例です。Timerを利用することで、比較的シンプルにポーリングを実現できます。

```Swift
final class PollingWithTimer: @unchecked Sendable {
    private var timer: Timer?

    deinit {
        stopTimer()
    }
    
    func fetchData() -> Int {
        Int.random(in: 0..<5)
    }
    
    // Timerでポーリング処理を実装
    func polling(completion: @escaping @Sendable (Int) -> Void) {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            guard let self else { return }
            let result = fetchData()
            
            completion(result)
        }
    }
    
    func stopTimer() {
        timer?.invalidate()
        timer = nil
    }
}

let pollingWithTimer = PollingWithTimer()

pollingWithTimer.polling { value in
    print(value)
    
    if value == 4 {
        pollingWithTimer.stopTimer()
    }
}
```

**メリット**
- シンプルで実装が簡単
- タイマーを止めるタイミングを呼び出し側で指定できる

**デメリット**
デメリットはTimer内だとエラーをスローできない点です。
Timer.scheduledTimerで渡すクロージャはthrowsをサポートしていないため、エラーをスローする処理を組み込めません。

```Swift
enum SampleError: Error {
    case networkError
}

final class PollingWithTimer: @unchecked Sendable {
    private var timer: Timer?
    
    func fetchData() throws -> Int {
        throw SampleError.networkError
    }
    
    // ❌エラー処理ができない
    func polling(completion: @escaping @Sendable (Int) -> Void) throws {
        timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
            guard let self else { return }
            let result = try fetchData() // エラーをスローする関数は使用できない
            completion(result)
        }
    }
}
```
この点について詳しく別の記事に書いたので、ぜひこちらをご覧ください。
https://qiita.com/Ryuto_Yuz/items/8f5820e13e4da1468e0a

## AsyncStreamで実装
次に、AsyncStreamを使ったポーリング処理の実装例です。AsyncStreamは非同期処理に適したデザインで、エラー処理をサポートしている点が特徴です。

```Swift
final class PollingWithAsyncStream: @unchecked Sendable {
    var timerTask: Task<Void, Never>?
    
    deinit {
        stopTimerTask()
    }
    
    func fetchData() -> Int {
        Int.random(in: 0..<5)
    }

    // AsyncStreamでポーリングを実装
    func polling() -> AsyncStream<Int> {
        AsyncStream { [weak self] continunation in
            guard let self else {
                continunation.finish()
                return
            }
            self.timerTask = Task { [weak self] in
                guard let self else { return }
                
                while !Task.isCancelled {
                    let result = fetchData()
                    
                    continunation.yield(result)
                    
                    if result == 4 {
                        continunation.finish()
                    }
                    
                    try? await Task.sleep(for: .seconds(1))
                }
                
            }
            
            continunation.onTermination = { [weak self] _ in
                self?.stopTimerTask()
            }
        }
    }
    
    func stopTimerTask() {
        timerTask?.cancel()
        timerTask = nil
    }
}

let pollingWithAsyncStream = PollingWithAsyncStream()

Task {
    let stream = pollingWithAsyncStream.polling()
    for await value in stream {
        print(value)
    }
}
```
**メリット**
- 非同期処理に強い
- エラー処理が可能

`AsyncThrowingStream`を使えば、クロージャ内でエラーを返す可能性のある関数を呼び出すことができます。そこで発生したエラーは呼び出し元に伝播されます。

```Swift
enum SampleError: Error {
    case networkError
}


final class PollingWithAsyncStream: @unchecked Sendable {
    var timerTask: Task<Void, Never>?
    
    deinit {
        stopTimerTask()
    }
    
    func fetchData() throws -> Int {
        throw SampleError.networkError
    }
    
    func polling() -> AsyncThrowingStream<Int, Error> {
        AsyncThrowingStream { [weak self] continunation in
            guard let self else {
                continunation.finish()
                return
            }
            self.timerTask = Task { [weak self] in
                guard let self else { return }
                
                while !Task.isCancelled {
                    do {
                        let result = try fetchData()
                        
                        continunation.yield(result)
                        
                        if result == 4 {
                            continunation.finish()
                        }
                        
                        try? await Task.sleep(for: .seconds(1))
                    } catch {
                        continunation.finish(throwing: error)
                        self.stopTimerTask()
                        break
                    }
                }
            }
            
            continunation.onTermination = { [weak self] _ in
                self?.stopTimerTask()
            }
        }
    }
    
    func stopTimerTask() {
        timerTask?.cancel()
        timerTask = nil
    }
}

let pollingWithAsyncStream = PollingWithAsyncStream()

Task {
    do {
        let stream = pollingWithAsyncStream.polling()
        for try await value in stream {
            print(value)
        }
    } catch {
        print(error)
    }
}
```
エラーが発生した場合は`continunation.finish(throwing: error)`で呼び出し元にスローしつつ、AsyncStreamをキャンセルすることができます。

Timerと比べると、コードが少し複雑ですがエラーを扱う必要がある場合はこちらの方法で実装する方がいいと思います。

## まとめ
- 簡単に実装したい場合：Timer
- 非同期処理やエラー処理が必要な場合：AsyncStream

プロジェクトに応じてどちらでポーリング処理を実装するのか、選択するといいと思います。