I was fortunate enough to talk to two very knowledgeable Apple Engineers in a lab tonight and they helped me through my refactor for image caching using actors. In this case I want to be able to access the images directly on the main thread for use in the UI but have all image downloading and resizing happen on background threads. Briefly, it looked something like this:
@MainActor
class OsuImageCache {
init() { ... }
/// Grab an image on the main thread for use in collection view cells
func image(for url: URL) -> UIImage? { }
/// Cache an image on a background thread
func cacheImage(for url: URL) async throws -> (image: UIImage?, thumbnail: UIImage?) {
let (data, _) = try await session.data(from: url)
async let processedImages = await processImageData(data, for: url)
try saveInFileSystem(imageData: processedImages.imageData, thumbnailImageData: processedImages.thumbnailData, for url: URL)
return (UIImage(data: processedImages.imageData), UIImage(data: processedImages.thumbnailData))
}
/// Resize images and create thumbnails
func processImageData(_ data: Data, url: URL) async -> (UIImage, UIImage) {
// Code that resizes the image and the thumbnail goes here. It's all inline, no completion handlers nor async calls...
...
// Finally, return
return (finalProcessedImageData, finalProcessedThumbnailData)
}
}
When we were talking, what I expected to have happen was the line with async let images = await processImageData(data, for: url)
was supposed to kick off to a background thread and do the processing of the image for me. I happily disconnected with them thinking it was solved, but when I ran the app afterwards I noticed to my dismay that the image processing was still happening on the main thread.
At this point I assume that I need to try something different, and I refactor by adding in a separate actor for image processing like so:
@MainActor
class OsuImageCache {
/// Actor just for image processing and thumbnail generation
let imageProcessing = ImageProcessing()
init() { ... }
func image(for url: URL) -> UIImage? { }
func cacheImage(for url: URL) async throws -> (image: UIImage?, thumbnail: UIImage?) {
let (data, _) = try await session.data(from: url)
async let processedImages = await imageProcessing.processImageData(data, for: url)
try saveInFileSystem(imageData: processedImages.imageData, thumbnailImageData: processedImages.thumbnailData, for url: URL)
return (UIImage(data: processedImages.imageData), UIImage(data: processedImages.thumbnailData))
}
}
actor ImageProcessing {
func processImageData(_ data: Data, url: URL) async -> (UIImage, UIImage) {
// Code that resizes the image and the thumbnail goes here. It's all inline, no completion handlers nor async calls...
...
// Finally, return
return (finalProcessedImageData, finalProcessedThumbnailData)
}
}
When I run it this time, I still see that the image processing within the ImageProcessing actor is still on the main thread. This is not what I expected. I flipped between different session videos and then changed the ImageProcessingActor like so:
actor ImageProcessing {
func processImageData(_ data: Data, url: URL) async -> (UIImage, UIImage) {
typealias ImageContinuation = CheckedContinuation<(Data, Data), Never>
return await withCheckedContinuation { (continuation: ImageContinuation) in
async {
// Code that resizes the image and the thumbnail goes here.
...
// Finally, return
continuation.resume(returning: (finalProcessedImageData, finalProcessedThumbnailData))
}
}
}
}
At this point it works! Image resizing happens on background threads and I still have the ability to use the images on the main thread! What I'm curious to know is if this is considered an OK pattern, or if I just stumbled by chance into something that worked but isn't actually the intended path. The checked continuation + async combo gets me the behavior I want, but I'm not sure if it's clean.
Is there a way to simplify this further and remove any of this boilerplate?
Thanks for any input you can give me!