Search code examples
swiftios-app-extensionswift-concurrency

Convert sequential access of [NSItemProvider] content to parallel using Swift Concurrency


I'm making an Action Extension that takes in a mix of images and URLs for remote images and then does some image processing on them before returning an array of UIImage.

At startup, I'm calling the loadImages method of this handler class with the array of NSExtensionItem provided by the system:

import UniformTypeIdentifiers
import UIKit

@MainActor
public final class ImageImporter {
    
    public enum Error: Swift.Error {
        case itemsHadNoImages
        case failedToLoadImage
    }
        
    public func loadImages(from items: [NSExtensionItem]) async throws -> [UIImage] {
        let itemProviders = items
            .compactMap { $0.attachments }
            .flatMap { $0 }
        
        let imageProviders = itemProviders.filter { $0.hasItemConformingToTypeIdentifier(UTType.image.identifier) }
        let urlProviders = itemProviders.filter { $0.hasItemConformingToTypeIdentifier(UTType.url.identifier) }
        
        var images: [UIImage] = []
        for provider in imageProviders {
            do {
                let image = try await load(provider: provider, type: UTType.image)
                images.append(image)
            } catch {
                continue
            }
        }
        
        for provider in urlProviders {
            do {
                let image = try await load(provider: provider, type: UTType.url)
                images.append(image)
            } catch {
                continue
            }
        }
        
        guard !images.isEmpty else { throw Error.itemsHadNoImages }
        return images
    }
        
    private func load(provider: NSItemProvider, type: UTType) async throws -> UIImage {
        let result = try await provider.loadItem(forTypeIdentifier: type.identifier)
        guard let url = result as? URL else { throw Error.itemsHadNoImages }
        let (data, _) = try await URLSession.shared.data(from: url)
        guard let image = UIImage(data: data) else { throw Error.failedToLoadImage }
        return image
    }
}

This works, but it processes things sequentially, accessing the URL inside each NSItemProvider and then loading its data and creating an image from it. I would like to parallelize this since the order that the assets go through processing doesn't matter. I tried using a TaskGroup, but when I try to add a sub-task to call the load method, I get a warning that I'm capturing a non-Sendable type NSItemProvider. I have no idea how dangerous this is, but ideally I'd want to be doing something that doesn't produce warnings.

Also, if I want true concurrency, I probably shouldn't mark this class @MainActor, but I'm not sure how to get the results back onto the main thread for updating the image views that will display the final results.


Solution

  • The sad fact is that they simply have not updated/audited much of Foundation for Swift concurrency. Personally, I am reluctant to make assumptions regarding thread-safety or sendability, so I would be inclined to follow legacy patterns (which I would keep private) when using API that does not yet play well with Swift concurrency, but then expose an async function that wraps the legacy interface in a continuation. E.g.,

    import UIKit
    import UniformTypeIdentifiers
    import os.log
    
    private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "ImageImporter")
    
    @MainActor
    public final class ImageImporter {
        public enum Error: Swift.Error {
            case itemsHadNoImages
            case failedToLoadImage
        }
    
        public func loadImages(from items: [NSExtensionItem]) async throws -> [UIImage] {
            try await withCheckedThrowingContinuation { continuation in
                loadImages(from: items) { result in
                    continuation.resume(with: result)
                }
            }
        }
    }
    
    private extension ImageImporter {
        func loadImages(from items: [NSExtensionItem], completion: @MainActor @Sendable @escaping (Result<[UIImage], Error>) -> Void) {
            let permissibleTypes: [UTType] = [.image, .url]
    
            let providersAndTypes = items
                .compactMap { $0.attachments }
                .flatMap { $0 }
                .compactMap {
                    for type in permissibleTypes {
                        if $0.hasItemConformingToTypeIdentifier(type.identifier) {
                            return ($0, type)
                        }
                    }
                    return nil
                }
    
            let group = DispatchGroup()
            var images: [UIImage] = []
    
            for (provider, type) in providersAndTypes {
                group.enter()
                loadImage(provider: provider, type: type) { result in
                    defer { group.leave() }
    
                    switch result {
                    case .success(let image): images.append(image)
                    case .failure(let error): logger.error("\(#function): \(error)")
                    }
                }
            }
    
            group.notify(queue: .main) {
                guard !images.isEmpty else {
                    let error = Error.itemsHadNoImages
                    logger.warning("\(#function): \(error)")
                    completion(.failure(error))
                    return
                }
    
                completion(.success(images))
            }
        }
    
        func loadImage(provider: NSItemProvider, type: UTType, completion: @MainActor @Sendable @escaping (Result<UIImage, Swift.Error>) -> Void) {
            provider.loadItem(forTypeIdentifier: type.identifier) { url, error in
                guard error == nil, let url = url as? URL else {
                    DispatchQueue.main.async {
                        completion(.failure(error ?? Error.itemsHadNoImages))
                    }
                    return
                }
    
                URLSession.shared.dataTask(with: url) { data, _, error in
                    guard error == nil, let data, let image = UIImage(data: data) else {
                        DispatchQueue.main.async {
                            completion(.failure(error ?? Error.failedToLoadImage))
                        }
                        return
                    }
    
                    DispatchQueue.main.async {
                        completion(.success(image))
                    }
                }.resume()
            }
        }
    }
    

    I admit that one could argue that this approach is overly cautious, but is prudent, IMHO.

    Note, I have not tested the above, so I apologize if I introduced any errors, but hopefully it illustrates the idea. But, as shown above, you can enjoy parallelism with the legacy completion handler pattern, and use the dispatch group to know when they are done.