🐡

【翻訳】Converting closure-based code into async/await in Swift

2023/07/15に公開

この記事からより良い利益を得るためには、async/awaitに精通している必要があります。
そうでない場合は、この記事シリーズの最初の部分を読んでください。
Understanding async/await in Swift
(https://www.andyibanez.com/posts/understanding-async-await-in-swift/).

この記事を独立したものにすべきか、
それともその内容をSwiftのasync/await入門に追記すべきか悩んでいました。
私は、記事に情報を詰め込み過ぎないようにするため、
また、より小さな記事でこれらのAPIを理解しやすくすることを期待して、前の記事をより短くすることにしました。

先週、我々はasync/awaitについて長い議論をしました。コールバックとの比較を行い、
async/awaitが本当に素晴らしいものであることを納得してもらえるような例を示した。

実際の並行処理まであと一歩です。来週、構造化された並行処理に飛び込む前に、
クロージャ・ベースやデリゲート・ベースのコードを非同期/待ち受けコードに変換する方法を紹介したい。
この記事の背後にある考え方は、あなたのプロジェクトにasync/awaitを少しずつ
採用し始めることができるように、すべてのツールを提供することです。

もしあなたがライブラリベンダーであれば、クロージャベースのAPIすべてにasync/awaitコードを
提供することができるようになるので、あなたの用途にasync/awaitを使い始めることができるだけでなく、ユーザーにasync/awaitを出荷することができるようになります。

もしあなたがライブラリ・ベンダーではないが、実運用しているアプリがあるのであれば、
あなた自身のアプリがコールバック経由で通知する非同期コードを使用している可能性が高いです。
そのプロジェクトの移行を始めたいのであれば、
非同期メソッドの非同期バージョンを実装することから始められます。
非同期/待機バージョンの呼び出しを提供していないサードパーティ・ライブラリを使用している場合は、
簡単に独自のものを提供できます。

Continuationsを理解する

この連載の第1回をお読みになった方は、コンティニュエーションとは何かを覚えているかもしれませんが、
次に進む前に簡単に復習しておきましょう。

Continuationとは、簡単に言うと非同期呼び出しの後に発生する処理のことです。
async/awaitを使用している場合、コンティニュエーションを理解するのは簡単です。
awaitコール以下はすべてContinuationです。

次の例を考えてみましょう。

func downloadMetadata(for id: Int) async throws -> ImageMetadata {
    let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(id).json")!
    let metadataRequest = URLRequest(url: metadataUrl)
    let (data, metadataResponse) = try await URLSession.shared.data(for: metadataRequest)
    guard (metadataResponse as? HTTPURLResponse)?.statusCode == 200 else {
        throw ImageDownloadError.invalidMetadata
    }

    return try JSONDecoder().decode(ImageMetadata.self, from: data)
}

この例では、awaitというキーワードによって、
別のスレッドでデータダウンロードタスクが起動されます(かもしれない)。
awaitの下(つまりガードのある行から始まる)はすべてContinuationです。

Continuationはasync/await APIに限ったことではありません。
クロージャベースの非同期 API を使用している場合、
完了ハンドラ内で呼び出されるものすべてがContinuationとなります。

let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).json")!
let metadataTask = URLSession.shared.dataTask(with: metadataUrl) { data, response, error in
    guard let data = data, let metadata = try? JSONDecoder().decode(ImageMetadata.self, from: data),  (response as? HTTPURLResponse)?.statusCode == 200 else {
        completionHandler(nil, ImageDownloadError.invalidMetadata)
        return
    }
    let detailedImage = DetailedImage(image: image, metadata: metadata)
    completionHandler(detailedImage, nil)
}
metadataTask.resume()

これは上のコードのクロージャーバージョンです。ここでも、Continuationはガードから始まります。
主な違いは、完了ハンドラーバージョンのフローがわかりにくいことです。

明示的Continuationの導入

Swiftは、コールバックベースのコードをasync/awaitに変換するために
使用できるいくつかのメソッドを提供します。
:withCheckedContinuationとwithCheckedThrowingContinuation。
2つの違いは、後者がエラーを投げるコードに使用されることです。
私はこれらのメソッドを明示的Continuationと呼んでいます。

上で宣言したdownloadMetadata(for:)メソッドの完了ハンドラ・バージョンがあるとします。

// MARK: - Definitions

struct ImageMetadata: Codable {
    let name: String
    let firstAppearance: String
    let year: Int
}

struct DetailedImage {
    let image: UIImage
    let metadata: ImageMetadata
}

enum ImageDownloadError: Error {
    case badImage
    case invalidMetadata
}

// MARK: - Functions

func downloadImageAndMetadata(
    imageNumber: Int,
    completionHandler: @escaping (_ image: DetailedImage?, _ error: Error?) -> Void
) {
    let imageUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).png")!
    let imageTask = URLSession.shared.dataTask(with: imageUrl) { data, response, error in
        guard let data = data, let image = UIImage(data: data), (response as? HTTPURLResponse)?.statusCode == 200 else {
            completionHandler(nil, ImageDownloadError.badImage)
            return
        }
        let metadataUrl = URL(string: "https://www.andyibanez.com/fairesepages.github.io/tutorials/async-await/part1/\(imageNumber).json")!
        let metadataTask = URLSession.shared.dataTask(with: metadataUrl) { data, response, error in
            guard let data = data, let metadata = try? JSONDecoder().decode(ImageMetadata.self, from: data),  (response as? HTTPURLResponse)?.statusCode == 200 else {
                completionHandler(nil, ImageDownloadError.invalidMetadata)
                return
            }
            let detailedImage = DetailedImage(image: image, metadata: metadata)
            completionHandler(detailedImage, nil)
        }
        metadataTask.resume()
    }
    imageTask.resume()
}

また、あなたがこのメソッドのオリジナル作者ではなく、クローズドソースであるため、
直接修正することができないとします。このメソッドでasync/awaitマイグレーションを開始したい場合、
最も簡単な方法は、withCheckedThrowingContinuationメソッドの中にdownloadImageAndMetadata(for:imageNumber:completionHandler)の呼び出しを
ラップすることです。

func downloadImageAndMetadata(imageNumber: Int) async throws -> DetailedImage {
    return try await withCheckedThrowingContinuation({
        (continuation: CheckedContinuation<DetailedImage, Error>) in
        downloadImageAndMetadata(imageNumber: imageNumber) { image, error in
            if let image = image {
                continuation.resume(returning: image)
            } else {
                continuation.resume(throwing: error!)
            }
        }
    })
}

この関数の背後にあるマジックは、withCheckedThrowingContinuationの部分で起こります。
この関数は、CheckedContinuation<T, E> (E: Error オブジェクト) を生成し、
呼び出す必要のあるメソッドを提供します。
この例では、downloadImageWithMetadataのオリジナルバージョンは、
DetailedImageまたはエラーを渡します。
このメソッドがResult<DetailedImage, Error>で呼ばれた場合、.resume(with:)を呼び出し、
結果を直接渡すことができます。

そのため、withCheckedThrowingContinuationのすべての分岐の中に、
継続の呼び出しがなければなりません。.resumeを呼び忘れると、物事がうまくいかなくなる可能性があります。幸運なことに、Swift はあなたに知らせてくれます。

注:少なくとも、そうすることになっている。
この記事は、Meet async/await in Swiftセッションの最後の数分に基づいています。
少なくともベータ1の時点では、resumeを呼び出さないブランチを持つコードを持つことができました。

そしてちょうどそのように、クロージャベースのコードをよりきれいなものに変換しました!
この関数のasync/awaitバージョンを使うのは簡単だ:

Task {
    if let imageDetail = try? await downloadImageAndMetadata(imageNumber: 1) {
        self.imageView.image = imageDetail.image
        self.metadata.text = "\(imageDetail.metadata.name) (\(imageDetail.metadata.firstAppearance) - \(imageDetail.metadata.year))"
    }
}

これを使ったプログラムを見てみたい、動かしてみたいという方は、
ここからサンプル・プロジェクトをダウンロードできる。

デリゲート・ベースのコードを非同期/待機に変換する。

これまで、コールバック・ベースのコードを非同期/待機に変換する方法を見てきました。
これはデリゲートベースのコードでも可能です。デリゲートベースのAPIはコールバックに取って代わられ、
ほとんど姿を消しましたが、特にイベント駆動型のAPI(Bluetooth、Locationなど)の場合、
デリゲートベースのAPIに遭遇することはよくあります。
そのため、これらのAPIをasync/awaitにブリッジすることができることを知っておくことは有益かもしれません。

例えば、ユーザーがViewControllerで連絡先を選択できるUIKitアプリがあるとします。
最も単純な形としては、次のようになります:

class ViewController: UIViewController, CNContactPickerDelegate {

    @IBOutlet weak var contactNameLabel: UILabel!

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
    }

    @IBAction func chooseContactTouchUpInside(_ sender: Any) {
        showContactPicker()
    }

    func showContactPicker() {
        let picker = CNContactPickerViewController()
        picker.delegate = self
        present(picker, animated: true)
    }

    func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
        self.contactNameLabel.text = contact.givenName
        picker.dismiss(animated: true, completion: nil)
    }

}

連絡先を選択 "ボタンを押すと、showContactPickerが呼び出され、実際のピッカーが表示され、
ユーザーが連絡先を選択すると、contactPicker(_:contact)メソッドを通して、
そのイベントがシステムに通知されます。

しかし、もっといい方法があります。
代わりに、このContactsをすべてラップするオブジェクトを作成することができます。
そして、ユーザーがコンタクトを選択したことを知らせる非同期メソッドを作成します。
こうすることで、プログラムの直線性を保ち、よりわかりやすいフローを保つことができます。

ContactPickerを次のように宣言します。

@MainActor
class ContactPicker: NSObject, CNContactPickerDelegate {
    private typealias ContactCheckedContinuation = CheckedContinuation<CNContact, Never> // 1

    private unowned var viewController: UIViewController
    private var contactContinuation: ContactCheckedContinuation? // 2
    private var picker: CNContactPickerViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
        picker = CNContactPickerViewController()
        super.init()
        picker.delegate = self
    }

    func pickContact() async -> CNContact { // 3
        viewController.present(picker, animated: true)
        return await withCheckedContinuation({ (continuation: ContactCheckedContinuation) in
            self.contactContinuation = continuation
        })
    }

    func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
        contactContinuation?.resume(returning: contact) // 4
        contactContinuation = nil
        picker.dismiss(animated: true, completion: nil)
    }
}

ここで理解してほしいのは

  1. CheckedContinuation<CNContact,Never>を参照しやすいようにタイプエイリアスしている。
    エラーは発生しないので、エラーパラメータはNeverです。

  2. private var contactContinuation:は継続自体への参照を保持します。
    この継続は withCheckedContinuation ハンドラで渡されます。
    複数回呼び出されるのを避けるために、最初の呼び出しの後に nil に設定します。

  3. pickContactはCNContactを返すので非同期です。
    ここでwithCheckedContinuationを呼び出します。

  4. contactがpickされたら、resumeでcontinuationを呼び出します。

そして、これを使う:

@IBAction func chooseContactTouchUpInside(_ sender: Any) {
    async {
        let contactPicker = ContactPicker(viewController: self)
        let contact = await contactPicker.pickContact()
        self.contactNameLabel.text = contact.givenName
    }
}

しかし、我々の実装には欠陥があることに注意してほしい。
ContactsUIフレームワークを使ったことがある人なら、気づいたかもしれません。

このUIは、ユーザーにコンタクトを選択せずにキャンセルするオプションを与えています。
先に、continuationsを扱うときは、continuationを正確に一度だけ呼び出す必要があると述べました。
上のプログラムでは、contactPickerDidCancel(_)メソッドを実装していないので、
ユーザがキャンセルしたときにcontinuationが呼び出されません。

これを解決するには、2つの方法があります:ユーザーがキャンセルしたときにエラーを投げるか、
nil contactを渡すかです。この場合、エラーを投げるのはあまり意味がないので、
代わりにnil contactを受け取るようにコードを修正します。

class ContactPicker: NSObject, CNContactPickerDelegate {
    private typealias ContactCheckedContinuation = CheckedContinuation<CNContact?, Never>

    private unowned var viewController: UIViewController
    private var contactContinuation: ContactCheckedContinuation?
    private var picker: CNContactPickerViewController

    init(viewController: UIViewController) {
        self.viewController = viewController
        picker = CNContactPickerViewController()
        super.init()
        picker.delegate = self
    }

    func pickContact() async -> CNContact? {
        return await withCheckedContinuation({ (continuation: ContactCheckedContinuation) in
            self.contactContinuation = continuation
            viewController.present(picker, animated: true)
        })
    }

    func contactPicker(_ picker: CNContactPickerViewController, didSelect contact: CNContact) {
        contactContinuation?.resume(returning: contact)
        contactContinuation = nil
        picker.dismiss(animated: true, completion: nil)
    }
    
    func contactPickerDidCancel(_ picker: CNContactPickerViewController) {
        contactContinuation?.resume(returning: nil)
        contactContinuation = nil
    }
}

//...

// in ViewController

@IBAction func chooseContactTouchUpInside(_ sender: Any) {
    async {
        let contactPicker = ContactPicker(viewController: self)
        let contact = await contactPicker.pickContact()
        self.contactNameLabel.text = contact?.givenName
    }
}

この方がずっといい。そして、より多くのコードを書いたが、線形性を維持するために余計なことをすることが、
長期的にはプログラムの構造にプラスになるケースもあるでしょう。

コンタクトピッカーアプリのフルバージョンはこちらからダウンロードできます。
これはUIKitアプリで、選択したコンタクトの名前を表示するシンプルなボタンとラベルがあります。
この記事の内容をよりよく理解していただけると幸いです。

要約

この記事では、コールバックベースのコードやデリゲートベースのコードから
async/awaitへの橋渡しをする方法を探りました。
そのためにチェックされた継続を使用する方法を学び、
継続が実際にどのようなものであるかを理解しました。

これによって、async/awaitの本質をすべて理解したことになります。
来週は構造化されたconcurrencyから始めます。
多くのタスクを並行して実行する方法と、その結果を処理する方法を学びます。

【翻訳元の記事】

Converting closure-based code into async/await in Swift
https://www.andyibanez.com/posts/converting-closure-based-code-into-async-await-in-swift/

Discussion