Project 4 · Milestone 3 — the image cache and your film dataswift-6.4/ios-26
Lesson 4 / 6
Unit 24 · Reeler

Project 4 · Milestone 3 — the image cache and your film data

Note

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.

Note

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.

Tip

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

Coming from older Swift

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.