It's Just Text Files, People

Structured Concurrency Conversion (Part 3)

Alright, in part 1, I laid out the code structure is for Apple’s Async Image Loading sample code. In part 2, I fixed up the Xcode project—something you’ll need to do when starting a new project because Swift 6 and strict checking aren’t on by default (as of June 2025). Today, we’ll actually convert the dispatch and closure-based code to use Actors and async/await.

A Disclosure

Before we start, I want to reference back to Swift’s release in 2014. There was a tendency for developers with years of Objective-C experience to write Swift code using the same patterns, which often resulted in the fuzzy, subjective judgment that the code wasn’t “Swifty” enough. Something similar is happening again with Structured Concurrency. After years of writing code that explicitly manages threading and handles locks, we now have a handy tool that allows us to catch potential data races before compilation. However, as we learn this new approach, our code might initially mimic older patterns to some degree. The reason I mention this is if you squint at the old code in the exercise, you should be able to see how it translates to something more modern. The underlying patterns and structure are somewhat present, which might help you bridge from the old way of doing things to the new, even if it doesn’t fully embrace all the new paradigm has to offer.

ImageCacheActor

Let’s begin by making an Actor called ImageCacheActor.

Properties

We’ll have a singleton static variable that can be accessed from various places just like the old version.

The old version uses NSCache<NSURL, UIImage> to cache the images but is that Sendable or thread-safe? The documentation does say:

> You can add, remove, and query items in the cache from different threads without having to lock the cache yourself.

Let’s roll with it! We are making it @MainActor because we will want to access it later from the Table/CollectionView in order to determine if we even need to fetch the image. This will need to be done from the Main Actor.

The next and final property is a dictionary that used to contain a Dictionary where the key was the NSURL and the value was [(Item, UIImage?) -> Swift.Void] (Note, that’s an Array of closures). The purpose of this Dictionary is for the case the image was already loading but a newly dequeued cell requested the image again which would pass in a new completion closure. When the image is loaded, it will call the original closure but also all subsequent closures if other callers requested the same URL.

What we’ll be doing is converting that to a Dictionary where the key is still NSURL but the value will be the first task (See here). This is the first big shift in thinking because of the top-down approach of async/await. We’ll get into what that means in the implementation described down below.

Okay, the properties are out of the way so let’s get to the meat of it.

### Functions

Our first function follows what we had before with a public interface for our NSCache. This works, though, because well, “You can add, remove, and query items in the cache from different threads without having to lock the cache yourself.”. If you say so! We are annotating it as @MainActor for reasoned explained above.

#### Load URL

The real work gets done in final func load(url: URL) async throws -> UIImage. You’ll notice that we changed the signature to take in a URL but no Item as I don’t want to tie this utilitarian functionality to a specific model object type. Also, we change the completion handler to something that is async, throws an error, and returns UIImage. Before, the function gave no indication that something went wrong, it just returned nil for the image which is better than returning the placeholder image, I guess.

Let’s get to the meat of the function!

I’m actually going to defer my explaination of the defer to the end. Moving on…

After the defer, the first step is to check the cache using the function outlined above and returned the cached image if there is one.

        // Check for a cached image.
        if let cachedImage = await image(url: url) {
            return cachedImage
        }

We need to await because we are changing contexts between our Actor and the @MainActor. Not a huge deal as NSCache is safe and our function is async anyway.

Remember that Dictionary where the key was the NSURL and the value was a Task? Time to shine loadingResponses!

        // In case there are more than one requestor for the image, we wait for the previous request and
        // return the image (or throw)
        if let previousTask = loadingResponses[url] {
            return try await previousTask.value
        }

What this code does is check for a previous request (we will discuss shortly) in a Task at that URL which is stored in a Dictionary. If we did make a request earlier, we will tell whatever subsequent caller that is loading the image at that URL to hold on for the result of that first call. This is quite the shift in thinking! Previously, we held each request’s completion handler in an array and then iterate over each completion closure stored once the image comes back and is valid.

Next up, we make a Task<(UIImage), any Error> and save it to a local let variable.

        // Go fetch the image.
        let currentTask = Task {
            let (data, _) = try await ImageURLAsyncProtocol.urlSession().data(from: url)
            // Try to create the image. If not, throw bad image data error.
            guard let image = UIImage(data: data) else {
                throw LoadingError.badImageData
            }
            // Cache the image.
            await setCachedImage(image, atUrl: url)
            return image
        }

In the task, we asynchronously fetch the data from the URL. When that is done, we make sure it’s a valid UIImage or throw an error. If it is valid, we use a function (more on that in a second) to set the image to the cache, and, finally, return the image as the Task’s value.

This is the part of the code that lines up well with the old way of fetching URLSession data, getting a completion closure, and handling the result. In fact, it lines up so well, it’s probably doesn’t go far enough to transform the code to adjust to the new way of working which I apologized for before. Much like Apple probably looks at their original code sample and might cringe, so will I in five years.

After that Task is made, we store it in the loadingResponses Dictionary for the URL and then asynchronously return the eventual value of the task or throwing a possible error.

Back to the defer at top of the function. This one:

	defer { loadingResponses.removeValue(forKey: url) }

If you think about it, we have a shared Singleton of ImageCacheActor which has a NSCache and a Dictionary<URL: Task<(UIImage), any Error>]. That Task will hold on to the value as long as we keep it around. In essence, it could be our own cache if we wanted it to but, NSCache has some nice features such as flushing memory, if needed, that we get for free. In order to hold less memory, let’s remove the Task from the dictionary and it will get freed up.

This is the end of our big func load(url: URL) async throws -> UIImage function!

But why a function for setting the image to the cache?

If you try to set the object directly on the NSCache via cachedImages.setObject(image, forKey: url as NSURL), you will get the helpful message of Non-sendable type 'NSCache<NSURL, UIImage>' of property 'cachedImages' cannot exit main actor-isolated context; this is an error in the Swift 6 language mode. Calling with an await doesn’t matter. This is why we come up with this function:

    @MainActor
    private func setCachedImage(_ cachedImage: UIImage, atUrl url: URL) {
        cachedImages.setObject(cachedImage, forKey: url as NSURL)
    }

The property we are trying to alter is @MainActor so let’s annotate this function the same. Once we do that, we can set the property directly in this function and cross the contexts by awaiting when we call it.

Table/Collection View Implementation

You can look at the diff for this change but previously Apple set the image no matter of what it was and, even if the image was loaded, it would fetch the image no matter what. Now, this isn’t a big deal because we pull from the cache but it seems unnecessary. Now, we have a three step process:

  1. Does our model object have it? Use that.
  2. Does the shared cache have it in their NSCache? Use that.
  3. Set a placeholder image and go fetch the image, like so.

In order to fetch the image, we create a Task where we use our new ImageCacheActor to load the image from the URL asynchronously. If an error is thrown, we now set a broken image. Then it is a matter of setting the image to the Item that is used to drive the diffable data source and then asynchronously apply the updated snapshot. The cell will reload and it will use the first scenario of the model object’s image.

In Conclusion

That was a massive change!

We have some optimizations done here where we are no longer holding onto an array of completion handlers which themselves held a strong reference to the collection views. Additionally, we do not load the cached version of the image even though the item has loaded it and it is stored in memory in the Item.

From a code organization standpoint, we had to shift some blocks of the code around now that we were no longer capturing functionality and storing it for later. Instead, if we have a loading task in progress, we ask subsequent requesters to wait for the first call.

Coming up in Part 4, we’ll be focusing on that URLProtocol subclass.