Wrapping delegates with Swift async/await and continuations


In this article you will learn how to convert or use existing delegate patterns and wrap them with Swift’s structured concurrency to use it with async/await mechanism in your apps.

I will show you an example with AVCapturePhotoCaptureDelegate to start capturing a photo and return the resulting UIImage when the capture is over.

Adopting delegate and using completion handler

In the code example below I implemented a simple class CameraPhotoProcessor and that class adopts the AVCapturePhotoCaptureDelegate protocol. Its responsibility is to start capturing the photo and convert it to a UIImage when the photo is captured. It is done asynchronously. I introduced a completion handler that can notify the code that calls the startCapture() when the photo is actually captured. The completion handler will be invoked with Swift’s Result type and it will be one of the two supported values, success with image object or failure with error object.

class CameraPhotoProcessor: NSObject {
    
    private var completion: ((Result<UIImage, Error>) -> Void)?
    
    func startCapture(from photoOutput: AVCapturePhotoOutput, using settings: AVCapturePhotoSettings, completion: @escaping (Result<UIImage, Error>) -> Void) {
        self.completion = completion
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
}

// MARK: - AVCapturePhotoCaptureDelegate

extension CameraPhotoProcessor: AVCapturePhotoCaptureDelegate {
    
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
         if let error = error {
             completion?(.failure(error))
             return
         }

         guard let data = photo.fileDataRepresentation() else {
             completion?(.failure(CustomError(msg: "Cannot get photo file data representation")))
             return
         }

        guard let image = UIImage(data: data) else {
            completion?(.failure(CustomError(msg: "Invalid photo data")))
            return
        }
        
        completion?(.success(image))
    }
}

We can see that the code above is error prone. The completion handler that will be passed will need to use the [weak self] capturing to avoid creating retain cycles. Let’s say that we have a function that is called when the capture button is pressed. This is how we would use the code above:

func captureButtonPressed() {
    photoProcessor.startCapture(from: photoOutput, using: settings) { [weak self] result in
        switch result {
            case .success(let image):
                // We have the image and now we can pass it or process it further
            case .failure(let error):
                print(error.localizedDescription)
        }
    }
}

When using completion handlers we always need to watch out where the code will continue executing and what will we do beneath or inside the closure. For an experienced developer it doesn’t matter but when using the async/await the code looks a lot cleaner and less error prone.

Introducing continuations

Continuations are mechanisms to interface between synchronous and asynchronous code. They wrap our existing code and wait for us to notify them about changes. The notifying part is called the resume operation that we can use on the continuation.

There are two types of continuations:

  • Checked continuation - This type of continuation performs checks if continuation is resumed more than one time or if it is resumed after some time. Resuming from a continuation more than once is undefined behavior. Never resuming leaves the task in a suspended state indefinitely, and leaks any associated resources. Checked continuation logs a message if either of these invariants is violated.
  • Unsafe continuation - Checked continuation performs runtime checks for missing or multiple resume operations. Unsafe continuation avoids enforcing these invariants at runtime because it aims to be a low-overhead mechanism for interfacing Swift tasks with event loops, delegate methods, callbacks, and other non-async scheduling mechanisms.

During development, the ability to verify that the invariants are being upheld in testing is important. Because both types have the same interface, you can replace one with the other in most circumstances, without making other changes.

Continuations can also be throwing or non-throwing. We can create them using global functions:

  • withCheckedContinuation()
  • withCheckedThrowingContinuation()
  • withUnsafeContinuation()
  • withUnsafeThrowingContinuation()

Applying continuations

We will now use the theory from above and apply it onto our example with capturing a photo. Instead of creating a completion property we will now have an imageContinuation property.

In this example I will use the checked throwing continuation because even though I know that my resume operation on continuation will be called one time. The throwing part is needed because our photo capturing operation can fail and we need a way to throw errors when the operation is aborted or failed.

class CameraPhotoProcessor: NSObject {
    
    private var imageContinuation: CheckedContinuation<UIImage, Error>?
    
    func startCapture(from photoOutput: AVCapturePhotoOutput, using settings: AVCapturePhotoSettings) async throws -> UIImage {
        return try await withCheckedThrowingContinuation { continuation in
            imageContinuation = continuation
            photoOutput.capturePhoto(with: settings, delegate: self)
        }
    }
}

// MARK: - AVCapturePhotoCaptureDelegate

extension CameraPhotoProcessor: AVCapturePhotoCaptureDelegate {
    
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
         if let error = error {
             imageContinuation?.resume(throwing: error)
             return
         }

         guard let data = photo.fileDataRepresentation() else {
             imageContinuation?.resume(throwing: CustomError(msg: "Cannot get photo file data representation"))
             return
         }

        guard let image = UIImage(data: data) else {
            imageContinuation?.resume(throwing: CustomError(msg: "Invalid photo data"))
            return
        }
        
        imageContinuation?.resume(returning: image)
    }
}

And to use this in a Swift Task:

func captureButtonPressed() {
    Task {
        do {
            let image = try await startCapture(from: photoOutput, using: settings)
            // We have the image and now we can pass it or process it further
        } catch let error {
            print(error.localizedDescription)
        }
    }
}

Using Swift’s structured concurrency is more safe and less error prone as we can see. It also improves readability when multiple async operations are used. Imagine that we have three async operations using completion handlers. In that case we would need to indent the code three levels to use the final result. When using async/await we don’t have that. Everything is a straight line :)

Join the discussion

comments powered by Disqus