🪂

【Swift Concurrency】Delegate パターンを async な関数にする

2021/08/29に公開

Summary

Closure パターン(completionHandler など)の場合はこちら

https://zenn.dev/treastrain/articles/e8099976ec845b

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

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

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

async な関数を用意する

まずは async な関数を用意しておきましょう。

import Foundation

class ViewModel: NSObject {
    // Core NFC を使って電子マネーカードの残高を読み取る
    func readBalance() async throws -> Int {
        // ...
    }
}

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

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

import Foundation

class ViewModel: NSObject {
    func readBalance() async throws -> Int {
        return try await withCheckedThrowingContinuation { continuation in
            // ...
        }
    }
}

CheckedContinuation をクラス内に保持する

withCheckedContinuation(function:_:)withCheckedThrowingContinuation(function:_:) のクロージャで得られた CheckedContinuation をクラスに用意したプロパティに置いておきます。

今回は activeContinuation という名前で、 readBalance() の返り値の型である Int と throw のための Error をジェネリクスにあてています。

その後、Concurrency 導入前と同じようにイベントが発火するようなコードを書いていきます。

import Foundation

class ViewModel: NSObject {
    private var activeContinuation: CheckedContinuation<Int, Error>?
    private var session: NFCTagReaderSession?
    
    func readBalance() async throws -> Int {
        return try await withCheckedThrowingContinuation { continuation in
            activeContinuation = continuation
	    
	    // 以降は Concurrency 導入前と同じようなコード
	    session = NFCTagReaderSession(pollingOption: .iso18092, delegate: self)
            session?.begin() // イベント発火
        }
    }
}

処理が終了したら CheckedContinuation.resume() などを呼ぶ

返す値を用意できた、またはエラーを返す準備ができたら、CheckedContinuation.resume(returning:)CheckedContinuation.resume(throwing:)CheckedContinuation.resume(with:) などでそれらを返します。

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

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

以下のコードでは resume を呼び出した後に activeContinuationnil を入れることできちんと破棄し、 resume が誤って2回以上呼び出そうとしても問題ないようにしています。

import CoreNFC

extension Hoge: NFCTagReaderSessionDelegate {
    func tagReaderSessionDidBecomeActive(_ session: NFCTagReaderSession) {
        // 今回は使用しない
    }
    
    func tagReaderSession(_ session: NFCTagReaderSession, didInvalidateWithError error: Error) {
        // エラーが起きるとこのメソッドが呼ばれる
        activeContinuation?.resume(throwing: error)
        activeContinuation = nil
    }
    
    func tagReaderSession(_ session: NFCTagReaderSession, didDetect tags: [NFCTag]) {
        // 正常なら(この場合は電子マネーカードを検出できたら)このメソッドが呼ばれる
        // 電子マネーカード読み取りの実装は省略
	// `balance` に `Int` で残高を入れた想定
        activeContinuation?.resume(returning: balance)
	activeContinuation = nil
    }
}

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

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

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

Task {
    do {
        let balance = try await viewModel.readBalance()
	print("残高:", balance)
    } catch {
        print(error.localizedDescription)
    }
}

resume と保持した CheckedContinuation の破棄を忘れずに

これまでの completionHandler 等によるコールバックや Delegate パターンでは、例えば値を取得したはいいもののそれらを元の処理に返すのを忘れてしまった場合、コンパイルエラーとはならず気づきにくい処理漏れとなることがありました。

しかし、これら Swift Concurrency の仕組みによってその心配が少なくなろうとしています。Apple が用意した Framework の多くは Interoperability with Objective-C の仕組みによって async への対応がなされているかと思いますが、そうでないクロージャを使うパターン、Delegate パターンでは、自分で CheckedContinuation 等の仕組みを使って async にできることが分かりました。

この自前対応の場合は resume と保持した CheckedContinuation の破棄を忘れずに行いましょう。

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

今回作った readBalance() は、このままだと 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)
class ViewModel: NSObject {
    private var activeContinuation: CheckedContinuation<Int, Error>?
    private var session: NFCTagReaderSession?
    
    func readBalance() async throws -> Int {
        return try await withCheckedThrowingContinuation { continuation in
            activeContinuation = continuation
	    
	    // 以降は Concurrency 導入前と同じようなコード
	    session = NFCTagReaderSession(pollingOption: .iso18092, delegate: self)
            session?.begin() // イベント発火
        }
    }
}
#endif

サンプルコード

https://gist.github.com/treastrain/bbe368a74f4094fbc105f1f51c90b6dd

参考

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

Discussion