Project 4 · Milestone 3 — the image cache and your film data
Unit 24 · Project 4 · Milestone 3. This milestone adds the two things that make Reeler feel like a real app: images that don't re-download on every scroll, and your own record of each film — a rating, a like, a watchlist, a review — persisted with SwiftData and read back as your Diary, Watchlist, and Profile. The next lesson is the AI checkpoint on the image-cache code.
A scrolling poster grid asks for the same image URLs over and over as cells recycle. Fetch each
one every time and you burn bandwidth and stutter. The fix is a cache — but a cache is shared
mutable state, read and written from many concurrent image loads at once. That's the textbook
job for an actor. Then, separately, favorites are durable state, which is SwiftData's job.
The image cache as an actor
The cache holds decoded images keyed by URL. Because posters load concurrently while you scroll,
many tasks touch that dictionary at once. An actor serializes every access, so the dictionary
can never be torn by an interleaved read-modify-write.
import UIKit actor ImageLoader { private var cache: [URL: UIImage] = [:] func image(for url: URL) async throws -> UIImage { if let cached = cache[url] { return cached // cache hit — no network } let (data, _) = try await URLSession.shared.data(from: url) guard let image = UIImage(data: data) else { throw LoaderError.decodeFailed } cache[url] = image return image } } enum LoaderError: Error { case decodeFailed }
Every image(for:) call from outside is awaited, and the actor runs them one at a time. The
cache[url] = image write is safe precisely because no other task can be inside the actor at the
same moment. This is the "protect mutable state with an actor" pattern from Unit 2, applied to a
real resource.
Actor reentrancy leaves a gap. Look closely: between the cache miss and the
cache[url] = image write, there's an await on the network. Actors are reentrant — during
that suspension, another task can enter the actor, also miss the cache for the same URL, and
start a second download. The cache still isn't corrupted, but the same image downloads twice.
Correct, but wasteful. The fix is request coalescing, next.
Coalescing in-flight requests
To make the same URL download exactly once even under concurrent demand, cache the task, not
just the result. A second caller for an in-flight URL awaits the same task instead of starting
a new download.
actor ImageLoader { private enum Entry { case ready(UIImage) case inFlight(Task<UIImage, Error>) } private var entries: [URL: Entry] = [:] func image(for url: URL) async throws -> UIImage { if let entry = entries[url] { switch entry { case .ready(let image): return image case .inFlight(let task): return try await task.value // join the existing download } } let task = Task { () -> UIImage in let (data, _) = try await URLSession.shared.data(from: url) guard let image = UIImage(data: data) else { throw LoaderError.decodeFailed } return image } entries[url] = .inFlight(task) do { let image = try await task.value entries[url] = .ready(image) return image } catch { entries[url] = nil // let a later call retry a failed load throw error } } }
Now the first caller for a URL records an .inFlight task and everyone else joins it with
await task.value. When it finishes, the entry flips to .ready. One download, many awaiters —
the standard production shape of an image loader.
Why store the Task, not lock around the download. Storing the in-flight Task is how you
express "this work is already happening, wait for it" inside an actor without blocking the actor
during the download. The actor is free the whole time the network runs; callers only serialize
on the quick dictionary reads and writes.
A SwiftUI wrapper for the loader
Give the loader a small view so cells stay declarative. Inject the shared loader through the environment.
struct PosterImage: View { let url: URL? @Environment(\.imageLoader) private var loader @State private var image: UIImage? var body: some View { Group { if let image { Image(uiImage: image).resizable().scaledToFit() } else { Color.gray.opacity(0.2) } } .task(id: url) { guard let url else { return } image = try? await loader.image(for: url) } } }
.task(id: url) reloads when the cell recycles to a new URL and cancels the old load. The
loader is one shared actor for the whole app, placed in the environment so every cell hits the
same cache.
Your film data with SwiftData
Everything you record about a film — a rating, a like, a watchlist flag, a review, a watched
date — is durable state that must survive relaunch and read back with no network. Model it as
one @Model per film, FilmActivity, so Diary, Watchlist, and Likes are each just a filter
over the same store:
import SwiftData @Model final class FilmActivity { @Attribute(.unique) var filmID: Int var title: String var year: Int var rating: Double // 0 = unrated, else 0.5…5 in half-steps var liked: Bool var inWatchlist: Bool var review: String var watchedAt: Date? // set when logged as watched var updatedAt: Date init(film: Film) { self.filmID = film.id self.title = film.title self.year = film.year self.rating = 0 self.liked = false self.inWatchlist = false self.review = "" self.watchedAt = nil self.updatedAt = .now } var isLogged: Bool { watchedAt != nil } }
@Attribute(.unique) on filmID means there's exactly one activity row per film. Fetch (or
create) it once when the detail appears, then bind the whole editor to it with @Bindable:
@MainActor enum Activities { static func forFilm(_ film: Film, in context: ModelContext) -> FilmActivity { let id = film.id let descriptor = FetchDescriptor<FilmActivity>(predicate: #Predicate { $0.filmID == id }) if let existing = try? context.fetch(descriptor).first { return existing } let created = FilmActivity(film: film) context.insert(created) return created } }
Now the detail's log section is pure @Bindable — the star rating, the like/watchlist/watched
buttons, and the review all write straight through to the persisted object:
struct DetailContent: View { let film: Film @Bindable var activity: FilmActivity var body: some View { // …poster + metadata + overview… StarRating(rating: $activity.rating) // tap a star, it's saved Button { activity.liked.toggle() } label: { Label("Like", systemImage: activity.liked ? "heart.fill" : "heart") } Button { activity.inWatchlist.toggle() } label: { Label("Watchlist", systemImage: activity.inWatchlist ? "clock.fill" : "clock") } Button { activity.watchedAt = activity.isLogged ? nil : .now } label: { Label("Watched", systemImage: activity.isLogged ? "checkmark.circle.fill" : "checkmark.circle") } TextField("Add a review…", text: $activity.review, axis: .vertical) } }
The StarRating control is worth building yourself — five tappable stars, and tapping the star
you're already on drops it to a half:
struct StarRating: View { @Binding var rating: Double var interactive = true var body: some View { HStack(spacing: 4) { ForEach(1...5, id: \.self) { star in Image(systemName: symbol(for: star)) .foregroundStyle(.green) .onTapGesture { guard interactive else { return } rating = (rating == Double(star)) ? Double(star) - 0.5 : Double(star) } } } } private func symbol(for star: Int) -> String { if rating >= Double(star) { "star.fill" } else if rating >= Double(star) - 0.5 { "star.leadinghalf.filled" } else { "star" } } }
Now the three surfaces fall out as filters over the one store. Diary is the logged films, newest first; Watchlist is the flagged ones; Profile totals them up:
struct DiaryView: View { @Query(sort: \FilmActivity.watchedAt, order: .reverse) private var activities: [FilmActivity] private var logged: [FilmActivity] { activities.filter { $0.isLogged } } // …render `logged` as rows with the star rating + date… } struct WatchlistView: View { @Query(sort: \FilmActivity.updatedAt, order: .reverse) private var activities: [FilmActivity] private var watchlist: [FilmActivity] { activities.filter { $0.inWatchlist } } // …render `watchlist` as a poster grid… }
Because there's one source of truth, a rating you set on the detail page appears in the Diary and
bumps the Profile's average with nothing to keep in sync — the @Querys just re-run. (Custom
lists are the one place you add a second model: a FilmList with a cascade @Relationship to its
ListEntrys, exactly the shape you built in Project 2.)
Core Data made this a multi-file affair: an .xcdatamodeld, an NSManagedObjectContext threaded
through by hand, NSFetchRequests, and NSFetchedResultsController for live updates. SwiftData
collapses all of that into @Model, @Query, and modelContext — the model is the schema,
and @Query is the live fetch. If you're returning from Core Data, this is the single biggest
quality-of-life change in the persistence story.
Checkpoint
Your app should now: load posters through a shared actor-based loader that caches and coalesces downloads; rate, like, watchlist, log, and review a film from its detail page; and see it in your Diary, Watchlist, and Profile — all working with the network off (relaunch in airplane mode and your data's still there).
Stretch: add an eviction cap to the loader — once entries exceeds N ready images, drop the
oldest. (Track insertion order; evict inside the actor so it stays race-free.)
Knowledge check
Q: Why is the image cache an actor and not a plain class with a dictionary?
The cache is shared mutable state touched by many concurrent image loads. An actor serializes
every access, so the dictionary can't be corrupted by interleaved read-modify-writes. A plain
class would be a data race the compiler rejects under strict concurrency.
Q: Why are favorites in SwiftData instead of the same in-memory cache?
Favorites are durable state — they must survive relaunch and be readable offline. The image
cache is a performance optimization that can be rebuilt any time. Different lifetimes, different
tools: @Model + @Query for persistence, an actor for the transient cache.