NSItemProviderのloadObjectをasync/awaitに対応させてみると、うんともすんとも言わなくなって困った話

2023.01.14

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

PHPickerViewControllerから選択した画像を取得する為にNSItemProviderのloadObject(ofClass:completionHandler:)を使用するのですが、completionHandlerをスッキリさせる為にasync/awaitに対応してみるとクロージャーの中が呼ばれなくなって困りました。

今回はその対応について書き残したいと思います。

環境

  • Xcode 14.1
  • iOS 16.1

PHPickerViewControllerの構成

選択できる写真は5枚、アセットタイプは画像にしています。

import UIKit
import PhotosUI

class ViewController: UIViewController {

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        var configuration = PHPickerConfiguration()
        configuration.selectionLimit = 5
        configuration.filter = .images
        let picker = PHPickerViewController(configuration: configuration)
        picker.delegate = self
        present(picker, animated: true)
    }
}

loadObject

通常ケース

画像を選択後、PHPickerViewControllerが閉じて、取得した結果をImageに変換する処理です。

// MARK: - 通常
extension ViewController: PHPickerViewControllerDelegate {

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {

        picker.dismiss(animated: true)

        let itemProviders = results.compactMap { $0.itemProvider }.filter { $0.canLoadObject(ofClass: UIImage.self)}

        if itemProviders.isEmpty { return }

        itemProviders.forEach { itemProvider in

            itemProvider.loadObject(ofClass: UIImage.self) { item, error in

                if let error {
                    print("Failure to get Image with", error)
                    return
                }

                if let image = item as? UIImage {
                    print("Image:", image)
                    return
                }

                print("Failure to get Image")
            }
        }
    }
}

実際に写真を5枚選択すると、デバッグコンソールに5回分のUIImage取得のログが表示されました。

Image: <UIImage:0x2818a62e0 anonymous {3024, 4032} renderingMode=automatic>
Image: <UIImage:0x2818aea30 anonymous {3024, 4032} renderingMode=automatic>
Image: <UIImage:0x2818ae9a0 anonymous {3024, 4032} renderingMode=automatic>
Image: <UIImage:0x2818a6400 anonymous {3024, 4032} renderingMode=automatic>
Image: <UIImage:0x2818a62e0 anonymous {3024, 4032} renderingMode=automatic>

async/await対応ケース

エクステンションの作成

まずは、async/awaitに対応したNSItemProviderのエクステンションを作成します。

extension NSItemProvider {
    func loadObject(ofClass aClass : NSItemProviderReading.Type) async throws -> NSItemProviderReading {
        try await withCheckedThrowingContinuation { continuation in
            self.loadObject(ofClass: aClass) { item, error in
                if let error {
                    return continuation.resume(throwing: error)
                }

                guard let item else {
                    return continuation.resume(throwing: NSError())
                }

                continuation.resume(returning: item)
            }
        }
    }
}

エラーがなければ、NSItemProviderReadingを返してくれます。

loadObjectを使用

// MARK: - async/await対応
extension ViewController: PHPickerViewControllerDelegate {

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {

        picker.dismiss(animated: true)

        let itemProviders = results.compactMap { $0.itemProvider }.filter { $0.canLoadObject(ofClass: UIImage.self)}

        if itemProviders.isEmpty { return }

        itemProviders.forEach { itemProvider in

            Task {
                do {
                    let item = try await itemProvider.loadObject(ofClass: UIImage.self)
                    if let image = item as? UIImage {
                        print("Image:", image)
                    } else {
                        print("Failure to get Image")
                    }
                } catch {
                    print("Failure to get Image with", error)
                }
            }
        }
    }
}

通常のケースと同じように写真を5枚選択すると、デバッグコンソールに何も表示されませんでした。エラーすらも表示されていませんでした。

写真を1枚のみにして試してみると、UIImage取得のログ出力が表示されました。エクステンションメソッドは機能しているようです。

Image: <UIImage:0x283006370 anonymous {3024, 4032} renderingMode=automatic>

2枚以上で試しても結果は何も出力されていませんでした。

エクステンションメソッドにログを埋め込む

開始前と開始後、終了時にログを埋め込んでみます。

extension NSItemProvider {
    func loadObject(ofClass aClass : NSItemProviderReading.Type) async throws -> NSItemProviderReading {
        try await withCheckedThrowingContinuation { continuation in

            print("will start loading object")
            self.loadObject(ofClass: aClass) { item, error in

                print("did start loading object")

                if let error {
                    print("end loading object")
                    return continuation.resume(throwing: error)
                }

                guard let item else {
                    print("end loading object")
                    return continuation.resume(throwing: NSError())
                }

                print("end loading object")
                continuation.resume(returning: item)
            }
        }
    }
}

同じように5枚の画像を選択して結果を見てみます。

will start loading object
will start loading object

2回だけwill start loading objectが呼ばれ、その後の出力は一切ありません、、。

ちなみに1枚選択だとしっかりログに出力されました。

will start loading object
did start loading object
end loading object
Image: <UIImage:0x281071b90 anonymous {3024, 4032} renderingMode=automatic>

原因

今回はforEach後にTask処理を実行していて、その処理が悪さをしていそうです。

対策

1枚だと成功するのでasync対応したforEachを作成し、TaskforEachの外側から囲み、順番に読み込むことにしました。

asyncForEach

extension Sequence {

    func asyncForEach(_ operation: (Element) async throws -> Void) async rethrows {
        for element in self {
            try await operation(element)
        }
    }
}

対策後コード

// MARK: - async/await対応 - 修正
extension ViewController: PHPickerViewControllerDelegate {

    func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {

        picker.dismiss(animated: true)

        let itemProviders = results.compactMap { $0.itemProvider }.filter { $0.canLoadObject(ofClass: UIImage.self)}

        if itemProviders.isEmpty { return }

        Task {

            await itemProviders.asyncForEach { itemProvider in

                do {
                    let item = try await itemProvider.loadObject(ofClass: UIImage.self)
                    if let image = item as? UIImage {
                        print("Image:", image)
                    } else {
                        print("Failure to get Image")
                    }
                } catch {
                    print("Failure to get Image with", error)
                }
            }
        }
    }
}

画像を5枚選択して、ログを確認します。

will start loading object
did start loading object
end loading object
Image: <UIImage:0x283875b90 anonymous {3024, 4032} renderingMode=automatic>
will start loading object
did start loading object
end loading object
Image: <UIImage:0x283875b90 anonymous {3024, 4032} renderingMode=automatic>
will start loading object
did start loading object
end loading object
Image: <UIImage:0x283875c20 anonymous {3024, 4032} renderingMode=automatic>
will start loading object
did start loading object
end loading object
Image: <UIImage:0x283875c20 anonymous {3024, 4032} renderingMode=automatic>
will start loading object
did start loading object
end loading object
Image: <UIImage:0x28387ad90 anonymous {3024, 4032} renderingMode=automatic>

5枚分のUIImage取得のログを確認出来ました。

おわりに

せめて、エラーだけでも返してよという気持ちになりました。

今回はこのような対策になってしまいましたが、もっと良い対応策ありましたら優しく教えていただけると嬉しいです。

参考