🌂

【Swift Concurrency】Closure パターン(completionHandler など)を async な関数にする

2021/10/29に公開
2

Summary

  • Closure パターン(completionHandler、resultHandler、errorHandler など)なものを async な関数にブリッジっぽいことをしてみる
  • async な関数で withCheckedContinuation(function:_:) を使う
  • 処理が終了した後は CheckedContinuation.resume() などを Closure の中で1度だけ呼んで async な関数に値を返してあげる

Delegate パターンの場合はこちら

https://zenn.dev/treastrain/articles/484564cf15a8a1
説明はほとんど上記記事と同じです。そういえば Closure の方を記事化していなかったので…。

Closure パターンを async な関数にする

Swift 5.5 以降で導入される Concurrency、これを既存のプロジェクトで使用しようとするとき、しばらくは従来の Delegate パターンによるイベント駆動なものや、completionHandler 等によるコールバックと共存していくことがあるでしょう。

これを1つの async な関数にする方法の一例を見ていきます。

async な関数を用意する

まずは async な関数を用意します。

Apple が用意した Framework の多くは Interoperability with Objective-C の仕組みによって async への対応がなされています。しかし、それとは別に用意されたようなメソッド、例えば URLSession.data(from:delegate:) はそのの仕組みに則っていないため、Xcode 13.2 で Swift Conccurency のバックデプロイ対応が来ても Availability は iOS 15.0+、macOS 12.0+、tvOS 15.0+、watchOS 8.0+ のままです。

今回は data(from:delegate:) が登場する前に使われていた Closure パターンである dataTask(with:completionHandler:) を自分でラップして、iOS 13.0+、macOS 10.15+、tvOS 13.0+、watchOS 6.0+ で使えるようにしてみます。

import Foundation

extension URLSession {
    func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) {
        // ...
    }
}

withCheckedContinuation(function:_:) などを呼ぶ

この data(from:delegate:) の中で withCheckedContinuation(function:_:)withCheckedThrowingContinuation(function:_:) を呼び出し、それを return するようにします。両者の違いはエラーを投げるかそうでないかの違いです。これらは async な関数なので、await キーワードが必要になります。

import Foundation

extension URLSession {
    func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) {
        return try await withCheckedThrowingContinuation { continuation in
            // ...
        }
    }
}

処理が終了したら CheckedContinuationresume() などを呼ぶ

Closure が呼ばれて返す値を用意できた、またはエラーを返す準備ができたら、withCheckedContinuation(function:_:)withCheckedThrowingContinuation(function:_:) の Closure で得られた CheckedContinuationresume(returning:)resume(throwing:)resume(with:) などでそれらを返します。

ドキュメントにも記載がありますが、この resume メソッドはプログラム中の全ての実行経路において必ず正確に1度だけ呼び出す必要があります。

resume をメソッドを2回以上呼び出してしまうのは未定義の動作で、逆に1度も呼ばなければ今作っている async な関数が全く再開されない状態となってしまいます。
今回の記事で使用している CheckedContinuation はこの resume が呼び出されている・呼び出し忘れているという操作についてランタイムでチェックしてくれるようになっています。一応このランタイムでのチェックを行わない UnsafeContinuation も用意されていますが、これを使うのはパフォーマンスを非常に重視する場合などに限定し、テストでしっかりと網羅されているか確かめる必要があります。

import Foundation

extension URLSession {
    func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) {
        return try await withCheckedThrowingContinuation { continuation in
            let task = dataTask(with: url) { data, response, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: (data!, response!))
                }
            }
            task.resume()
        }
    }
}

完成した async な関数を呼び出してみる

以上で async な関数が完成しました。これを呼び出してみましょう。
今回作った data(from:delegate:)async であり throws なので、使う側では try await キーワードが必要となります。

Concurrency がサポートされていない場所で呼び出したい場合は Task.init(priority:operation:) で囲みます。

Task {
    do {
        let url = URL(string: "/path/to/api")!
        let (data, response) = try await URLSession.shared.data(from: url)
        print(data, response)
    } catch {
        print(error)
    }
}

【おまけ】 Xcode 13.2 以降でのみ使えるようにする

今回自分で作った data(from:delegate:) は、このままだと Xcode 13.0、Xcode 13.1 でもビルドしようとしてしまい、「withCheckedThrowingContinuation(function:_:) とか Concurrency は iOS 15.0 以降でしか使えないよ!」というエラーが出てしまいます。
そこで、Xcode 13.2 の Swift コンパイラが 5.5.2 であることを利用し、以下のように記述することで Xcode 13.2 以降でのみビルドできるようにします。

import Foundation

#if compiler(>=5.5.2) && canImport(_Concurrency)
extension URLSession {
    func data(from url: URL, delegate: URLSessionTaskDelegate? = nil) async throws -> (Data, URLResponse) {
        return try await withCheckedThrowingContinuation { continuation in
            let task = dataTask(with: url) { data, response, error in
                if let error = error {
                    continuation.resume(throwing: error)
                } else {
                    continuation.resume(returning: (data!, response!))
                }
            }
            task.resume()
        }
    }
}
#endif

参考

https://developer.apple.com/videos/play/wwdc2021/10132/
https://github.com/apple/swift-evolution/blob/main/proposals/0300-continuation.md
https://zenn.dev/treastrain/scraps/479338f6b7e467
https://zenn.dev/treastrain/articles/484564cf15a8a1

Discussion

zundazunda

以下のところtaskではなくdataTaskな気がします。

let task = task(with: url) { data, response, error in
let task = dataTask(with: url) { data, response, error in