swiftuicollectionviewuicollectionviewcellswift-concurrency

How to run tasks off the main actor with Swift Concurrency


I am using a Task block to fetch images from disk to be displayed in a UIImageView.

I noticed that as I scroll the collection view, there's noticeable lag caused by the getArtwork method meaning it is blocking the main actor.

Marking it as "nonisolated" fixes the issue which if I understand correctly, means the method is not isolated to the current actor context but most examples I have seen uses the keyword in actors NOT classes thus I am wondering if it is even appropriate in a class?

class ListViewCell: UICollectionViewListCell {
    private lazy var thumbnailImageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFit
        imageView.layer.cornerRadius = 5
        imageView.clipsToBounds = true
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()
    
    private lazy var placeholderImageView: UIImageView = {
        let cfg = UIImage.SymbolConfiguration(scale: .small)
        let image = UIImage(systemName: "music.note", withConfiguration: cfg)
        let imageView = UIImageView(image: image)
        imageView.contentMode = .scaleAspectFit
        imageView.tintColor = .tertiaryLabel
        imageView.translatesAutoresizingMaskIntoConstraints = false
        return imageView
    }()
    
    private lazy var nameLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.lineBreakMode = .byTruncatingTail
        return label
    }()
    
    private lazy var artistLabel: UILabel = {
        let label = UILabel()
        label.translatesAutoresizingMaskIntoConstraints = false
        label.font = .preferredFont(forTextStyle: .subheadline)
        label.textColor = .secondaryLabel
        label.lineBreakMode = .byTruncatingTail
        return label
    }()
    
    var audioFile: AudioFile?
    private var thumbnailTask: Task<Void, Never>?
    
    override init(frame: CGRect) {
        super.init(frame: frame)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func prepareForReuse() {
        super.prepareForReuse()
        thumbnailTask?.cancel()
        thumbnailImageView.image = nil
        thumbnailImageView.backgroundColor = nil
    }
    
    func configure(with audioFile: AudioFile) {
        self.audioFile = audioFile
        
        if let artwork = audioFile.artwork {
            thumbnailTask = Task { [weak self] in
                var thumbnail: UIImage?
                if let cachedImage = self?.imageCache.object(forKey: artwork as NSString) {
                    thumbnail = cachedImage
                } else {
                    thumbnail = await self?.getArtwork(for: artwork)
                }
                
                await MainActor.run { [weak self] in
                    // Check if the cell's audioFile is still the same
                    if audioFile == self?.audioFile {
                        self?.thumbnailImageView.image = thumbnail
                    }
                }
            }
        } else {
            thumbnailImageView.addSubview(placeholderImageView)

            NSLayoutConstraint.activate([
                placeholderImageView.widthAnchor.constraint(equalToConstant: 30),
                placeholderImageView.heightAnchor.constraint(equalToConstant: 30),
                placeholderImageView.centerXAnchor.constraint(equalTo: thumbnailImageView.centerXAnchor),
                placeholderImageView.centerYAnchor.constraint(equalTo: thumbnailImageView.centerYAnchor)
            ])
            
            thumbnailImageView.bringSubviewToFront(placeholderImageView)
            thumbnailImageView.backgroundColor = .quaternarySystemFill
        }
    }
    
    private var imageCache = NSCache<NSString, UIImage>()

    nonisolated func getArtwork(for name: String) async -> UIImage? {
        let url = FileManager.artworksFolderURL.appendingPathComponent(name)
        
        do {
            let data = try Data(contentsOf: url)
            if let image = UIImage(data: data) {
                let targetSize = CGSize(width: 50, height: 50)
                let imageSize = image.size
                let widthRatio = targetSize.width / imageSize.width
                let heightRatio = targetSize.height / imageSize.height
                let scaleFactor = min(widthRatio, heightRatio)
                
                let scaledImageSize = CGSize(width: imageSize.width * scaleFactor, height: imageSize.height * scaleFactor)
                let renderer = UIGraphicsImageRenderer(size: targetSize)
                
                let centeredImage = renderer.image { context in
                    let origin = CGPoint(
                        x: (targetSize.width - scaledImageSize.width) / 2,
                        y: (targetSize.height - scaledImageSize.height) / 2
                    )
                    context.cgContext.addPath(UIBezierPath(roundedRect: CGRect(origin: origin, size: scaledImageSize), cornerRadius: 5).cgPath)
                    context.cgContext.clip()
                    image.draw(in: CGRect(origin: origin, size: scaledImageSize))
                }
                
                await MainActor.run {
                    imageCache.setObject(centeredImage, forKey: name as NSString)
                }
                
                return centeredImage
            }
        } catch {
            print("Error loading image data: \(error)")
            return nil
        }
        
        return nil
    }
}

Solution

  • thus I am wondering if it is even appropriate in a class?

    Yes, it is appropriate in this case. Your class ListViewCell inherits from a MainActor-isolated class UICollectionViewListCell, so it and its members are also MainActor-isolated by default.

    This is semantically not that different from the members declared in an actor type. Instead of being isolated to the enclosing actor type, the members in ListViewCell are isolated to the global actor that is MainActor. To opt out of this isolation is exactly the purpose of nonisolated.

    From the global actors SE proposal:

    It is common for entire types (and even class hierarchies) to predominantly require execution on the main thread, and for asynchronous work to be a special case. In such cases, the type itself can be annotated with a global actor, and all of the methods, properties, and subscripts will implicitly be isolated to that global actor. Any members of the type that do not want to be part of the global actor can opt out, e.g., using the nonisolated modifier.


    That said, it is also not a good idea to block other threads for long periods of time. The fundamental assumption in Swift Concurrency is that threads will always make progress. I see that you are doing:

    let data = try Data(contentsOf: url)
    

    which is blocking. If this is indeed what causes the lag, consider using URLSession instead:

    let (data, _) = try await URLSession.shared.data(from: url)
    

    If you need to do other CPU intensive work, you should do it using GCD, and port it to Swift Concurrency using withCheckedThrowingContinuation and friends.