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.