Leave a rating/review
In the previous episode, you created a custom GlobalActor to provide a persistent, on-disk image cache that allows easy and safe access to shared resources from anywhere in your app.
In this episode, you’ll put it to work in EmojiArt. Continue with your project from the previous episode or open the starter project.
Wiring up the persistence layer
Before you do anything with ImageDatabase, you need to set it up safely by calling its setUp method. You can do that anywhere in your code but, for this example, you’ll do it along with the rest of your app setup.
Open LoadingView.swift and scroll to task(...).
The first thing you do in the app is call model.loadImages() in the task modifier.
Set up ImageDatabase just before this line:
try await ImageDatabase.shared.setUp()
Now, start replacing calls to ImageLoader with calls to ImageDatabase, which transparently uses ImageLoader when an image isn’t in the disk cache.
First, in ThumbImage.swift, delete the imageLoader property — you won’t be using it anymore. This shows you where it’s used, so replace imageLoader.image(file.url) inside the task(...) closure:
ImageDatabase.shared.image(file.url)
This checks the in-memory cache, then the on-disk cache and then, if all else fails, the network.
The other ImageLoader call is in DetailsView.swift: Again, delete the imageLoader property, then replace imageLoader with ImageDatabase.shared:
ImageDatabase.shared.image(file.url)
Build and run. Watch the output console while you scroll:
Download: http://localhost:8080/gallery/image?26
In memory cache.
In memory cache.
Download: http://localhost:8080/gallery/image?2
In memory cache.
Download: http://localhost:8080/gallery/image?9
Download: http://localhost:8080/gallery/image?22
...
You’ll see a mix of network requests and assets cached in memory. Once you’ve downloaded all the images, you’ll only see memory hits:
In memory cache.
In memory cache.
In memory cache.
This is great! Your pair of actors work well together. Now, try this.
Stop the app and run it again. Don’t scroll the feed just yet!
In disk cache.
In disk cache.
Download: http://localhost:8080/gallery/image?10
In disk cache.
This time, the disk cache serves all the content that the previous run fetched. Sometimes, you’ll see a network request: These are assets that failed to download on the previous run of the app. The app retries fetching those because they’re not persisted on disk.
Scroll down to the bottom and up again. After loading all the assets from disk, the log again fills up with messages for memory-cached assets.
Congratulations, you’ve created a super-powerful image caching mechanism for your project. You still need to complete a few more tasks.
Adding a cache hit counter
The bottom bar has placeholders for information that helps you debug your caching mechanism.
These two buttons should clear the disk cache and the in-memory cache.
This label should show how many assets you loaded from disk and how many from memory. You’ll get to work implementing these now.
ImageLoader needs to continuously publish the count of cache hits — the perfect job for an AsyncStream!
In ImageLoader.swift, add an AsyncStream property:
@MainActor private(set) var inMemoryAccess: AsyncStream<Int>?
inMemoryAccess is an asynchronous stream that runs on the MainActor. Your views can access and subscribe to this property without worrying about any background UI updates.
Set up two more properties:
private var inMemoryAcccessContinuation: AsyncStream<Int>.Continuation?
private var inMemoryAccessCounter = 0
To produce ongoing updates, inMemoryAccess will be a buffered AsyncStream, and you’ll store its continuation in inMemoryAccessContinuation.
ImageLoader’s actor semantics protect the current count in inMemoryAccessCounter from data races.
Next, add an accessor to the counter:
private var inMemoryAccessCounter = 0🟩 {
didSet { inMemoryAcccessContinuation?.yield(inMemoryAccessCounter) }
}🟥
This didSet accessor sends any updates to inMemoryAccessCounter to the continuation, if one exists. Add a setUp method:
func setUp() async {
let accessStream = AsyncStream<Int> { continuation in
inMemoryAcccessContinuation = continuation
}
}
You initialize the stream in setUp() and store its continuation in inMemoryAccessContinuation. Save the stream in your property:
func setUp() async {
let accessStream = AsyncStream<Int> { continuation in
inMemoryAcccessContinuation = continuation
}
🟩
await MainActor.run { inMemoryAccess = accessStream }
🟥
}
You switch to the main actor to store the stream in inMemoryAccess, which runs on the MainActor. Now, you can produce new values any time by calling inMemoryAcccessContinuation.yield(...).
The image(_:) method should increment this counter, in case .completed: Do this before return statement:
inMemoryAccessCounter += 1
When you increase the hit counter, its didSet accessor yields the result to the stored continuation. Since both properties are on the actor, you perform both operations synchronously. However, the @MainActor annotation causes the stream to produce the value on the main actor asynchronously.
You’re a good developer, so add a deinitializer
deinit {
inMemoryAcccessContinuation?.finish()
}
You manually complete the stream when the actor is released from memory.
Displaying the counter
Now, you need to setup the image loader. A safe place to call ImageLoader.setUp() is your database’s own setUp().
In ImageDatabase.swift, find setUp() and do this at the end:
await imageLoader.setUp()
Next, you need to display this memory cache hit counter in the toolbar.
Open BottomToolbar.swift. Add a new task modifier after the last padding in the code:
.task {
guard let memoryAccessSequence =
ImageDatabase.shared.imageLoader.inMemoryAccess else {
return
}
}
First, unwrap the optional stream. Then asynchronously iterate over the sequence:
.task {
guard let memoryAccessSequence =
ImageDatabase.shared.imageLoader.inMemoryAccess else {
return
}
🟩
for await count in memoryAccessSequence {
inMemoryAccessCount = count
}
🟥
}
Each time the stream produces a value, you assign it to inMemoryAccessCount — a state property on the toolbar view that you use to display the text in the toolbar.
Build and run again. Scroll up and down a little, and you’ll see the in-memory counter give you updates in real-time:
Purging the in-memory cache
You’ll soon wire up the button that clears the memory cache.
But first, add a new method at the bottom of ImageDatabase to purge the in-memory assets:
func clearInMemoryAssets() async {
await imageLoader.clear()
print("Cleared in-memory cache.")
}
You just call the image loader’s method to clear its cache, then print a message. Go back to BottomToolbar.swift: Find the comment // Clear in-memory cache.
This code is for the right button in the toolbar.
Add a Task here, to call your new ImageDatabase method:
Task {
await ImageDatabase.shared.clearInMemoryAssets()
}
So first you clear the in-memory cache… Then reload the images:
Task {
await ImageDatabase.shared.clearInMemoryAssets()
🟩
try await model.loadImages()
🟥
}
Build and run. Scroll a little, to add assets to the in-memory cache, then tap the button to clear the memory.
The message appears, then the app reloads assets from the disk cache or from the network. The EmojiArt app is now almost complete. You’ve done a fantastic job working through all the steps so far.
Next up:
Complete the challenge to connect the second counter in the debugging toolbar, which displays on-disk cache hits.
And also implement the corresponding clear button.