← Back to blog

Building a Pokédex on iOS — Part 7: Image cache

Part 7 of the PokeTracker series: we implement a two-level image cache system — NSCache in memory and FileManager on disk — using Swift actors.

The Pokédex displays an image for every Pokémon. If we make an HTTP request every time a cell appears on screen, the app downloads the same image over and over, wastes the user’s data, and produces choppy scrolling.

The solution is an image cache. The system gives us two built-in options: SwiftUI’s AsyncImage, which downloads images but doesn’t persist them to disk, and URLCache, which does persist but offers no control over size or invalidation. For this project we build our own with two levels: memory (NSCache) and disk (FileManager).


Why two levels

Memory is the fastest — immediate access, no I/O. The drawback is that the system can purge it whenever it needs resources. If the user backgrounds the app and comes back, anything in memory is gone.

Disk survives across sessions. Access is slower than memory, but far faster than a network download.

The lookup order is: check memory → check disk → download from network. After a download, we save to both memory and disk for next time.


ImageCache

Create Core/Cache/ImageCache.swift:

import UIKit

actor ImageCache {

    static let shared = ImageCache()

    private let memoryCache = NSCache<NSString, UIImage>()
    private let fileManager = FileManager.default
    private let cacheDirectory: URL

    private init() {
        let paths = fileManager.urls(for: .cachesDirectory, in: .userDomainMask)
        cacheDirectory = paths[0].appendingPathComponent("ImageCache", isDirectory: true)

        if !fileManager.fileExists(atPath: cacheDirectory.path) {
            try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
        }

        memoryCache.countLimit = 100
        memoryCache.totalCostLimit = 50 * 1024 * 1024 // 50 MB
    }

    func image(for url: URL) async -> UIImage? {
        let key = cacheKey(for: url)

        if let cached = memoryCache.object(forKey: key as NSString) {
            return cached
        }

        if let diskImage = loadFromDisk(key: key) {
            memoryCache.setObject(diskImage, forKey: key as NSString)
            return diskImage
        }

        return nil
    }

    func setImage(_ image: UIImage, for url: URL) async {
        let key = cacheKey(for: url)
        memoryCache.setObject(image, forKey: key as NSString)
        saveToDisk(image: image, key: key)
    }

    func clearCache() async {
        memoryCache.removeAllObjects()
        try? fileManager.removeItem(at: cacheDirectory)
        try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
    }

    // MARK: - Private

    private func cacheKey(for url: URL) -> String {
        url.absoluteString.data(using: .utf8)?.base64EncodedString() ?? url.lastPathComponent
    }

    private func fileURL(for key: String) -> URL {
        cacheDirectory.appendingPathComponent(key)
    }

    private func loadFromDisk(key: String) -> UIImage? {
        guard let data = try? Data(contentsOf: fileURL(for: key)) else { return nil }
        return UIImage(data: data)
    }

    private func saveToDisk(image: UIImage, key: String) {
        guard let data = image.pngData() else { return }
        try? data.write(to: fileURL(for: key))
    }
}

The class is an actor. Swift actors guarantee exclusive access to their state — no two concurrent calls can read or write at the same time. Without this, multiple views could access memoryCache or the disk files in parallel, causing data races.

The image(for:) method has two deliberate steps: if memory misses, it tries disk, and if it finds the image there it also loads it into memory. The next time that same image is requested, it will already be in memory.

The cache key is the full URL encoded in base64. Using just lastPathComponent would cause collisions if two different URLs happen to end with the same filename.


ImageLoader

The cache knows how to store and retrieve images. The Loader knows how to download them. This separation matters because the Loader has extra state: the downloads currently in progress.

Create Core/Cache/ImageLoader.swift:

import UIKit

actor ImageLoader {

    static let shared = ImageLoader()

    private let cache = ImageCache.shared
    private let apiClient = APIClient()
    private var inProgressTasks: [URL: Task<UIImage?, Never>] = [:]

    private init() {}

    func loadImage(from url: URL) async -> UIImage? {
        // 1. Busca en caché
        if let cached = await cache.image(for: url) {
            return cached
        }

        // 2. Si ya hay una descarga en progreso para esta URL, espera ese resultado
        if let existingTask = inProgressTasks[url] {
            return await existingTask.value
        }

        // 3. Inicia la descarga
        let task = Task<UIImage?, Never> {
            do {
                let data = try await apiClient.fetchData(from: url)
                guard let image = UIImage(data: data) else { return nil }
                await cache.setImage(image, for: url)
                return image
            } catch {
                return nil
            }
        }

        inProgressTasks[url] = task
        let image = await task.value
        inProgressTasks[url] = nil

        return image
    }

    func cancelLoad(for url: URL) {
        inProgressTasks[url]?.cancel()
        inProgressTasks[url] = nil
    }
}

The inProgressTasks dictionary solves a concrete problem: if the user scrolls fast and the same image appears in multiple cells before it finishes downloading, without this mechanism there would be several identical downloads running in parallel. With inProgressTasks, the second cell that requests the same URL waits for the first download’s result instead of starting a new one.


CachedAsyncImage

ImageLoader works with UIImage. To use it in SwiftUI we need a view that bridges the gap. Create Features/Pokemon/Presentation/Views/Atoms/CachedAsyncImage.swift:

import SwiftUI

struct CachedAsyncImage<Content: View, Placeholder: View>: View {

    let url: URL?
    let content: (Image) -> Content
    let placeholder: () -> Placeholder

    @State private var image: UIImage?
    @State private var isLoading = false

    init(
        url: URL?,
        @ViewBuilder content: @escaping (Image) -> Content,
        @ViewBuilder placeholder: @escaping () -> Placeholder
    ) {
        self.url = url
        self.content = content
        self.placeholder = placeholder
    }

    var body: some View {
        Group {
            if let image {
                content(Image(uiImage: image))
            } else {
                placeholder()
            }
        }
        .task(id: url) {
            await loadImage()
        }
    }

    private func loadImage() async {
        guard let url, !isLoading else { return }
        isLoading = true
        image = await ImageLoader.shared.loadImage(from: url)
        isLoading = false
    }
}

// Extensiones de conveniencia
extension CachedAsyncImage where Placeholder == ProgressView<EmptyView, EmptyView> {
    init(url: URL?, @ViewBuilder content: @escaping (Image) -> Content) {
        self.init(url: url, content: content) { ProgressView() }
    }
}

extension CachedAsyncImage where Content == Image, Placeholder == ProgressView<EmptyView, EmptyView> {
    init(url: URL?) {
        self.init(url: url) { image in image.resizable() } placeholder: { ProgressView() }
    }
}

.task(id: url) is the key piece: if the URL changes (for example in a list reusing cells), the previous task is cancelled and a new one starts with the new URL. The same view can display different images without any leftover state from the previous one.

The convenience extensions allow different levels of customization:

// Full control
CachedAsyncImage(url: pokemon.spriteURL) { image in
    image.resizable().scaledToFit()
} placeholder: {
    ProgressView().tint(.red)
}

// Customize the image only
CachedAsyncImage(url: pokemon.spriteURL) { image in
    image.resizable().scaledToFit()
}

// Minimal usage — default ProgressView, resizable image
CachedAsyncImage(url: pokemon.spriteURL)

What’s next

In Post 8 we build the ViewModel with @Observable — the heart of the presentation layer.