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
}
}
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.