【Swift Concurrency】Closure パターン(completionHandler など)を async な関数にする
Summary
- Closure パターン(completionHandler、resultHandler、errorHandler など)なものを async な関数にブリッジっぽいことをしてみる
- async な関数で
withCheckedContinuation(function:_:)
を使う - 処理が終了した後は
CheckedContinuation.resume()
などを Closure の中で1度だけ呼んで async な関数に値を返してあげる
Delegate パターンの場合はこちら
説明はほとんど上記記事と同じです。そういえば 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
// ...
}
}
}
CheckedContinuation
の resume()
などを呼ぶ
処理が終了したら Closure が呼ばれて返す値を用意できた、またはエラーを返す準備ができたら、withCheckedContinuation(function:_:)
・withCheckedThrowingContinuation(function:_:)
の Closure で得られた CheckedContinuation
の resume(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
参考
Discussion
以下のところ
task
ではなくdataTask
な気がします。ご指摘ありがとうございます🙇 修正しました!