Search code examples
swiftasync-awaitapple-vision

How can I convert multiple completion handlers to async?


I am using Apple's Vision framework to run multiple detection requests against one image.

VNImageRequestHandler.perform() schedules multiple requests (likely in a separate thread). Completion handlers are defined for the individual requests. In the end, results should be assigned to a class var.

From my understanding, continuations are the best way to convert completion handler based APIs to async code. However, continuations can only be resumed once.

AsyncStream seems like a more viable solution. However, results can arrive in any order and I only have a static number of requests.

What are the most idiomatic async paradigms to use with the following code?

// non-async code
let handler = VNImageRequestHandler(url: url)
let faceQualityRequest = VNDetectFaceCaptureQualityRequest { request, err in
    guard err == nil else { return }
    self.faceDetectionResults = request.results
}
let featurePrintRequest = VNGenerateImageFeaturePrintRequest { request, err in
    guard err == nil else { return }
    self.featurePrintResults = request.results
}
try handler.perform([faceQualityRequest, featurePrintRequest])

Solution

  • I understand why, when seeing a series of closures, one might jump to the idea of asynchronous sequences or task groups, but we should note that perform is synchronous, not asynchronous. So, I might suggest that the relevant question is less “what do I do with these closures”, but rather, “how do I avail myself of a slow, synchronous API within Swift concurrency”.

    A few observations:

    1. Given that perform is a synchronous function, you should be aware that you should avoid blocking the current actor. So, you theoretically could move it into a detached task.

      Having having been said, even that is not prudent. E.g., in WWDC 2022’s Visualize and optimize Swift concurrency, Apple explicitly advises moving the blocking code out of the Swift concurrency system:

      Be sure to avoid blocking calls in tasks. … If you have code that needs to do these things, move that code outside of the concurrency thread pool – for example, by running it on a DispatchQueue – and bridge it to the concurrency world using continuations.

      See async/await: How do I run an async function within a @MainActor class on a background thread?

    2. You are focusing on the closures provided to VNDetectFaceCaptureQualityRequest and VNGenerateImageFeaturePrintRequest. But those closures are an optional convenience and you can instead just use their respective results (here and here).

    So, you might end up with something like:

    func faceAndFeature(for url: URL) async throws -> ([VNFaceObservation], [VNFeaturePrintObservation]) {
        try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global().async {
                let faceQualityRequest = VNDetectFaceCaptureQualityRequest()
                let featurePrintRequest = VNGenerateImageFeaturePrintRequest()
    
                let handler = VNImageRequestHandler(url: url)
    
                do {
                    try handler.perform([faceQualityRequest, featurePrintRequest])
                    continuation.resume(returning: (faceQualityRequest.results ?? [], featurePrintRequest.results ?? []))
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
    

    E.g.,

    let (faceQuality, featurePrint) = try await faceAndFeature(for: url)
    print("faceQuality =", faceQuality)
    print("featurePrint =", featurePrint)
    

    It should be noted that the above will not return any results until perform finishes all the requests. You can, alternatively, create two AsyncChannel instances, one for faces and one for features. E.g.,

    let faceChannel = AsyncThrowingChannel<[VNFaceObservation], Error>()
    let featureChannel = AsyncThrowingChannel<[VNFeaturePrintObservation], Error>()
    
    func analyzeImage(at url: URL) async throws {
        try await withCheckedThrowingContinuation { continuation in
            DispatchQueue.global().async {
                let faceQualityRequest = VNDetectFaceCaptureQualityRequest { request, error in
                    Task { [weak self] in
                        guard let self else { return }
    
                        guard error == nil, let results = request.results as? [VNFaceObservation] else {
                            faceChannel.fail(error ?? VisionError.invalidRequestType)
                            return
                        }
    
                        await faceChannel.send(results)
                    }
                }
    
                let featurePrintRequest = VNGenerateImageFeaturePrintRequest { request, error in
                    let results = request.results
                    Task { [weak self, results] in
                        guard let self else { return }
    
                        guard error == nil, let results = results as? [VNFeaturePrintObservation] else {
                            featureChannel.fail(error ?? VisionError.invalidRequestType)
                            return
                        }
    
                        await featureChannel.send(results)
                    }
                }
    
                let handler = VNImageRequestHandler(url: url)
    
                do {
                    try handler.perform([faceQualityRequest, featurePrintRequest])
                    continuation.resume()
                } catch {
                    continuation.resume(throwing: error)
                }
            }
        }
    }
    

    And then monitor those channels:

    await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask {
            for try await observations in self.faceChannel {
                …
            }
        }
    
        group.addTask {
            for try await observations in self.featureChannel {
                …
            }
        }
    }
    

    And finally, start the request:

    try await analyzeImage(at: url)
    

    A few notes:

    1. The Vision framework does not appear to be audited for Sendable yet, so you might want to designate it as @preconcurrency to silence annoying warnings about this:

      @preconcurrency import Vision
      
    2. Note, in SwiftUI you can launch these for-await-in loops from the .task view modifier and they will be canceled when the view is dismissed. In UIKit/AppKit, you will have to keep your own reference to the Task that launched these and manually cancel them when the view in question disappears.