Search code examples
iosswiftimagerequest

Instance method 'wait' is unavailable from asynchronous contexts; Use a TaskGroup instead; this is an error in Swift 6


Have below code and continue to get this error message.

var mediaimage = Image("blank")

let imageManager =  PHImageManager()

group.enter()
imageManager.requestImage(for: asset, targetSize: imageSize, contentMode: .aspectFill, options: requestImageOptions, resultHandler: { (image, info) in
 if image != nil {
  mediaimage = Image(uiImage: image!)
  group.leave()
 } else {
  group.leave()
 }
})

group.wait()

Have tried Task{} instead and other errors display iOS 16 build with Xcode 15. If I do not do group.enter() then image does not get added to mediaimage variable.


Solution

  • The use of legacy wait function (whether dispatch groups or semaphores or what have you) is an anti-pattern. If called from the main thread, the problems can range from hitches in the UI to catastrophic termination of the app. Even when called from a background thread, it is a bad idea, as it blocks one of the very limited number of threads from the worker thread pool which is both inefficient and can lead to more serious problems if you exhaust that pool. Furthermore, wait is not permitted within an asynchronous context because Swift concurrency is dependent upon a contract that all threads must be allowed to “make forward progress” (i.e., never block).

    Unfortunately, the error message’s reference to “use a TaskGroup instead” is a bit misleading. They are assuming that if you are using a dispatch group that you are using for its intended purpose, namely managing a group of tasks, for which TaskGroup is the modern alternative. But you do not have a group of tasks, but rather just one.

    So, we would not use a task group. Instead, we would simply wrap the legacy asynchronous API in a withCheckedThrowingContinuation:

    extension PHImageManager {
        func image(
            for asset: PHAsset,
            targetSize: CGSize,
            contentMode: PHImageContentMode = .default,
            options: PHImageRequestOptions? = nil
        ) async throws -> UIImage {
            assert(!(options?.isSynchronous ?? false), "Synchronous image retrieval not permitted from Swift concurrency")
    
            return try await withCheckedThrowingContinuation { continuation in
                requestImage(for: asset, targetSize: targetSize, contentMode: contentMode, options: options) { image, info in
                    if let image {
                        continuation.resume(returning: image)
                    } else {
                        continuation.resume(throwing: (info?[PHImageErrorKey] as? Error) ?? ImageManagerError.noImage)
                    }
                }
            }
        }
    }
    
    extension PHImageManager {
        enum ImageManagerError: Error {
            case noImage
        }
    }
    

    Then you could do:

    func fetch(asset: PHAsset, imageSize: CGSize) async throws -> Image {
        let uiImage = try await imageManager.image(for: asset, targetSize: imageSize)
        return Image(uiImage: uiImage)
    }
    

    By following async-await pattern, we avoid ever calling legacy wait API and thereby avoid ever blocking a thread.


    While I tried to retain the simplicity of your example, there are two caveats:

    1. Like your original example, the above does not handle cancelation. But when writing Swift concurrency code, we always want to support cancelation if the underlying API does (such as is the case with requestImage).

      You can modify the above to handle cancelation by wrapping it in a withTaskCancellationHandler:

      extension PHImageManager {
          func image(
              for asset: PHAsset,
              targetSize: CGSize,
              contentMode: PHImageContentMode = .default,
              options: PHImageRequestOptions? = nil
          ) async throws -> UIImage {
              assert(!(options?.isSynchronous ?? false), "Synchronous image retrieval not permitted from Swift concurrency")
              assert((options?.deliveryMode ?? .opportunistic) != .opportunistic, "Use 'images(for:targetSize:contentMode:options:)' sequence for 'deliveryMode' of 'opportunistic', as more than one image can be delivered.")
      
              let request = ImageRequest(manager: self)
      
              return try await withTaskCancellationHandler {
                  try await withCheckedThrowingContinuation { continuation in
                      guard !request.isCancelled else {
                          continuation.resume(throwing: CancellationError())
                          return
                      }
      
                      request.id = requestImage(for: asset, targetSize: targetSize, contentMode: contentMode, options: options) { image, info in
                          if let image {
                              continuation.resume(returning: image)
                          } else {
                              continuation.resume(throwing: (info?[PHImageErrorKey] as? Error) ?? ImageManagerError.noImage)
                          }
                      }
                  }
              } onCancel: {
                  request.cancel()
              }
          }
      }
      
      private extension PHImageManager {
          class ImageRequest: @unchecked Sendable {
              private weak var manager: PHImageManager?
              private let lock = NSLock()
              private var _id: PHImageRequestID?
              private var _isCancelled = false
      
              init(manager: PHImageManager) {
                  self.manager = manager
              }
      
              var id: PHImageRequestID? {
                  get { lock.withLock { _id } }
                  set { lock.withLock { _id = newValue } }
              }
      
              var isCancelled: Bool {
                  get { lock.withLock { _isCancelled } }
              }
      
              func cancel() {
                  lock.withLock {
                      _isCancelled = true
      
                      if let id = _id {
                          manager?.cancelImageRequest(id)
                      }
                  }
              }
          }
      }
      
    2. As noted above, an opportunistic delivery mode can cause requestImage to call its completion handler closure more than once. (Note, you may not necessarily see it called multiple times. But you can. This is especially true if you have chosen to “optimize” your photo storage and the high-quality image has not already been cached on device.)

      Like your dispatch group example, withCheckedContinuation requires that you resume exactly once and only once. The method referenced in that second assert message is below.

      Bottom line, if we want to support .opportunistic delivery, which means potentially multiple calls of the closure of requestImage (e.g., called after retrieval of the local low-quality image and possibly again for subsequent retrieval of the remote high-quality image), we would not use a simple continuation, but rather an AsyncSequence, namely an AsyncStream:

      extension PHImageManager {
          func images(
              for asset: PHAsset,
              targetSize: CGSize,
              contentMode: PHImageContentMode = .default,
              options: PHImageRequestOptions? = nil
          ) -> AsyncThrowingStream<UIImage, Error> {
              assert(!(options?.isSynchronous ?? false), "Synchronous image retrieval not permitted from Swift concurrency")
      
              let request = ImageRequest(manager: self)
      
              return AsyncThrowingStream { continuation in
                  request.id = requestImage(for: asset, targetSize: targetSize, contentMode: contentMode, options: options) { image, info in
                      guard let image else {
                          continuation.finish(throwing: (info?[PHImageErrorKey] as? Error) ?? ImageManagerError.noImage)
                          return
                      }
      
                      continuation.yield(image)
      
                      // don't finish, yet, if current result is degraded (and we didn't ask for `fastFormat`)
      
                      if
                          let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool,
                          isDegraded,
                          options?.deliveryMode != .fastFormat
                      {
                          return
                      }
      
                      // otherwise, go ahead and finish
      
                      continuation.finish()
                  }
      
                  continuation.onTermination = { reason in
                      guard case .cancelled = reason else { return }
      
                      request.cancel()
                  }
              }
          }
      }
      

      And then you would do something like:

      func fetchImages(for asset: PHAsset, imageSize: CGSize) async throws {
          for try await uiImage in imageManager.images(for: asset, targetSize: imageSize) {
              let image = Image(uiImage: uiImage)
              // do something with `image`
          }
      }
      

      I must confess that I find the “is it completed” logic in the above to be a little brittle. (How can Apple not provide a simple property to check whether the request is done and that the completion handler will not be called again?) But, you get the idea.

    I confess that I only threw this together quickly and have not done exhaustive testing, but, hopefully, it illustrates some of the concepts associated with wrapping legacy asynchronous API in Swift concurrency patterns and avoiding ever calling legacy wait functions.