← Volver al blog

Construyendo una Pokédex en iOS — Parte 7: Caché de imágenes

Parte 7 de la serie PokeTracker: implementamos un sistema de caché de imágenes en dos niveles — NSCache en memoria y FileManager en disco — usando actors de Swift.

La Pokédex muestra imágenes para cada Pokémon. Si cada vez que aparece una celda hacemos una llamada HTTP, la app descarga la misma imagen repetidamente, gasta datos del usuario y tiene un scroll poco fluido.

La solución es una caché de imágenes. Tenemos dos opciones del sistema: AsyncImage de SwiftUI, que descarga pero no persiste en disco, y URLCache, que persiste pero no da control sobre el tamaño ni la invalidación. Para este proyecto construimos la nuestra con dos niveles: memoria (NSCache) y disco (FileManager).


Por qué dos niveles

Memoria es el más rápido — acceso inmediato, sin I/O. El problema es que el sistema puede limpiarla cuando necesita recursos. Si el usuario sale de la app y vuelve, las imágenes en memoria ya no están.

Disco sobrevive entre sesiones. El acceso es más lento que memoria pero mucho más rápido que una descarga de red.

El flujo es: busca en memoria → busca en disco → descarga de red. Cuando descarga, guarda en memoria y en disco para la próxima vez.


ImageCache

Crea 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))
    }
}

La clase es un actor. Los actors en Swift garantizan acceso exclusivo a su estado — no puede haber dos llamadas simultáneas que lean o escriban al mismo tiempo. Sin esto, múltiples vistas podrían acceder a memoryCache o a los archivos de disco en paralelo, lo que causa condiciones de carrera.

El método image(for:) tiene el acceso en dos pasos intencionales: si memoria falla, intenta disco y si lo encuentra lo carga a memoria también. La próxima vez que se pida la misma imagen, ya estará en memoria.

La clave de caché es el URL completo codificado en base64. Usar solo el lastPathComponent causaría colisiones si dos URLs distintas terminan con el mismo nombre de archivo.


ImageLoader

La caché sabe almacenar y recuperar imágenes. El Loader sabe descargarlas. Esta separación importa porque el Loader tiene un estado adicional: las descargas en progreso.

Crea 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
    }
}

El diccionario inProgressTasks resuelve un problema concreto: si el usuario hace scroll rápido y la misma imagen aparece en varias celdas antes de que se descargue, sin este mecanismo habría múltiples descargas idénticas en paralelo. Con inProgressTasks, la segunda celda que pida la misma URL espera el resultado de la primera descarga en lugar de iniciar una nueva.


CachedAsyncImage

ImageLoader trabaja con UIImage. Para usarlo en SwiftUI necesitamos una vista que haga el puente. Crea 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) es clave: si la URL cambia (por ejemplo en una lista reutilizando celdas), la task anterior se cancela y empieza una nueva con la nueva URL. La misma vista puede mostrar imágenes distintas sin estado residual de la imagen anterior.

Las extensiones de conveniencia permiten distintos niveles de personalización:

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

// Solo personalizar la imagen
CachedAsyncImage(url: pokemon.spriteURL) { image in
    image.resizable().scaledToFit()
}

// Uso mínimo — ProgressView por defecto, imagen resizable
CachedAsyncImage(url: pokemon.spriteURL)

Qué viene en el siguiente post

En el Post 8 construimos el ViewModel con @Observable — el corazón de la capa de presentación.