import Foundation import UIKit /// Cache de imágenes optimizado con NSCache y políticas de expiración /// /// OPTIMIZACIONES IMPLEMENTADAS: /// 1. NSCache con límites configurables (BEFORE: Sin cache en memoria) /// 2. Preloading inteligente de imágenes adyacentes (BEFORE: Sin preloading) /// 3. Memory warning response (BEFORE: Sin gestión de memoria) /// 4. Disk cache para persistencia (BEFORE: Solo NSCache) /// 5. Priority queue para loading (BEFORE: FIFO simple) final class ImageCache { // MARK: - Singleton static let shared = ImageCache() // MARK: - In-Memory Cache (NSCache) /// BEFORE: Sin cache en memoria (redecargaba siempre) /// AFTER: NSCache con límites inteligentes y políticas de expiración private let cache: NSCache // MARK: - Disk Cache Configuration /// BEFORE: Sin persistencia de cache /// AFTER: Cache en disco para sesiones futuras private let diskCacheDirectory: URL private let fileManager = FileManager.default // MARK: - Cache Configuration /// BEFORE: Sin límites claros /// AFTER: Límites configurables y adaptativos private var memoryCacheLimit: Int { // 25% de la memoria disponible del dispositivo let totalMemory = ProcessInfo.processInfo.physicalMemory return Int(totalMemory / 4) // 25% de RAM } private var diskCacheLimit: Int64 { // 500 MB máximo para cache en disco return 500 * 1024 * 1024 } private let maxCacheAge: TimeInterval = 7 * 24 * 3600 // 7 días // MARK: - Preloading Queue /// BEFORE: Sin sistema de preloading /// AFTER: Queue con prioridades para carga inteligente private enum ImagePriority: Int, Comparable { case current = 0 // Imagen actual (máxima prioridad) case adjacent = 1 // Imágenes adyacentes (alta prioridad) case prefetch = 2 // Prefetch normal (media prioridad) case background = 3 // Background (baja prioridad) static func < (lhs: ImagePriority, rhs: ImagePriority) -> Bool { return lhs.rawValue < rhs.rawValue } } private struct ImageLoadRequest: Comparable { let url: String let priority: ImagePriority let completion: (UIImage?) -> Void static func < (lhs: ImageLoadRequest, rhs: ImageLoadRequest) -> Bool { return lhs.priority < rhs.priority } } private var preloadQueue: [ImageLoadRequest] = [] private let preloadQueueLock = NSLock() private var isPreloading = false // MARK: - Image Downscaling /// BEFORE: Cargaba imágenes a resolución completa siempre /// AFTER: Redimensiona automáticamente imágenes muy grandes private let maxImageDimension: CGFloat = 2048 // 2048x2048 máximo // MARK: - Performance Monitoring /// BEFORE: Sin métricas de rendimiento /// AFTER: Tracking de hits/miss para optimización private var cacheHits = 0 private var cacheMisses = 0 private var totalLoadedImages = 0 private var totalLoadTime: TimeInterval = 0 private init() { // Configurar NSCache self.cache = NSCache() self.cache.countLimit = 100 // Máximo 100 imágenes en memoria self.cache.totalCostLimit = memoryCacheLimit // Configurar directorio de cache en disco let cacheDir = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] self.diskCacheDirectory = cacheDir.appendingPathComponent("ImageCache") // Crear directorio si no existe try? fileManager.createDirectory(at: diskCacheDirectory, withIntermediateDirectories: true) // Setup memory warning observer NotificationCenter.default.addObserver( self, selector: #selector(handleMemoryWarning), name: UIApplication.didReceiveMemoryWarningNotification, object: nil ) // Setup background cleanup setupPeriodicCleanup() } // MARK: - Public Interface /// Obtiene imagen desde cache o la descarga /// /// BEFORE: Descargaba siempre sin prioridad /// AFTER: Cache en memoria + disco con priority loading func image(for url: String) -> UIImage? { return image(for: url, priority: .current) } func image(for url: String, priority: ImagePriority) -> UIImage? { // 1. Verificar memoria cache primero (más rápido) if let cachedImage = getCachedImage(for: url) { cacheHits += 1 print("✅ Memory cache HIT: \(url)") return cachedImage } cacheMisses += 1 print("❌ Memory cache MISS: \(url)") // 2. Verificar disco cache if let diskImage = loadImageFromDisk(for: url) { // Guardar en memoria cache setImage(diskImage, for: url) print("💾 Disk cache HIT: \(url)") return diskImage } print("🌐 Cache MISS - Need to download: \(url)") return nil } /// Guarda imagen en cache /// /// BEFORE: Guardaba sin optimizar /// AFTER: Optimiza tamaño y cache en múltiples niveles func setImage(_ image: UIImage, for url: String) { // 1. Guardar en memoria cache let cost = estimateImageCost(image) cache.setObject(image, forKey: url as NSString, cost: cost) // 2. Guardar en disco cache (async) saveImageToDisk(image, for: url) } // MARK: - Preloading System /// BEFORE: Sin sistema de preloading /// AFTER: Preloading inteligente de páginas adyacentes func preloadAdjacentImages(currentURLs: [String], currentIndex: Int, completion: @escaping () -> Void) { preloadQueueLock.lock() defer { preloadQueueLock.unlock() } let range = max(0, currentIndex - 1)...min(currentURLs.count - 1, currentIndex + 2) for index in range { if index == currentIndex { continue } // Skip current let url = currentURLs[index] guard image(for: url) == nil else { continue } // Ya está en cache let priority: ImagePriority = index == currentIndex - 1 || index == currentIndex + 1 ? .adjacent : .prefetch let request = ImageLoadRequest(url: url, priority: priority) { [weak self] image in if let image = image { self?.setImage(image, for: url) } } preloadQueue.append(request) } preloadQueue.sort() // Procesar queue si no está ya procesando if !isPreloading { isPreloading = true processPreloadQueue(completion: completion) } } /// BEFORE: Sin gestión de prioridades /// AFTER: PriorityQueue con prioridades private func processPreloadQueue(completion: @escaping () -> Void) { preloadQueueLock.lock() guard !preloadQueue.isEmpty else { isPreloading = false preloadQueueLock.unlock() DispatchQueue.main.async { completion() } return } let request = preloadQueue.removeFirst() preloadQueueLock.unlock() // Cargar imagen con prioridad loadImageFromURL(request.url) { [weak self] image in request.completion(image) // Continuar con siguiente self?.processPreloadQueue(completion: completion) } } /// BEFORE: Descarga síncrona bloqueante /// AFTER: Descarga asíncrona con callback private func loadImageFromURL(_ urlString: String, completion: @escaping (UIImage?) -> Void) { guard let url = URL(string: urlString) else { completion(nil) return } let startTime = Date() URLSession.shared.dataTask(with: url) { [weak self] data, response, error in guard let self = self, let data = data, error == nil, let image = UIImage(data: data) else { completion(nil) return } // OPTIMIZACIÓN: Redimensionar si es muy grande let optimizedImage = self.optimizeImageSize(image) // Guardar en cache self.setImage(optimizedImage, for: urlString) // Metrics let loadTime = Date().timeIntervalSince(startTime) self.totalLoadedImages += 1 self.totalLoadTime += loadTime print("📥 Loaded image: \(urlString) in \(String(format: "%.2f", loadTime))s") completion(optimizedImage) }.resume() } // MARK: - Memory Cache Operations private func getCachedImage(for url: String) -> UIImage? { return cache.object(forKey: url as NSString) } private func setImage(_ image: UIImage, for url: String) { let cost = estimateImageCost(image) cache.setObject(image, forKey: url as NSString, cost: cost) } /// BEFORE: No había estimación de costo /// AFTER: Costo basado en tamaño real en memoria private func estimateImageCost(_ image: UIImage) -> Int { // Estimar bytes en memoria: width * height * 4 (RGBA) guard let cgImage = image.cgImage else { return 0 } let width = cgImage.width let height = cgImage.height return width * height * 4 } // MARK: - Disk Cache Operations private func getDiskCacheURL(for url: String) -> URL { let filename = url.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) ?? UUID().uuidString return diskCacheDirectory.appendingPathComponent(filename) } private func loadImageFromDisk(for url: String) -> UIImage? { let fileURL = getDiskCacheURL(for: url) guard fileManager.fileExists(atPath: fileURL.path) else { return nil } // Verificar edad del archivo if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path), let modificationDate = attributes[.modificationDate] as? Date { let age = Date().timeIntervalSince(modificationDate) if age > maxCacheAge { try? fileManager.removeItem(at: fileURL) return nil } } return UIImage(contentsOfFile: fileURL.path) } private func saveImageToDisk(_ image: UIImage, for url: String) { let fileURL = getDiskCacheURL(for: url) // Guardar en background queue DispatchQueue.global(qos: .utility).async { [weak self] in guard let self = self else { return } // OPTIMIZACIÓN: JPEG con calidad media para cache guard let data = image.jpegData(compressionQuality: 0.7) else { return } try? data.write(to: fileURL) } } // MARK: - Image Optimization /// BEFORE: Imágenes a resolución completa /// AFTER: Redimensiona imágenes muy grandes automáticamente private func optimizeImageSize(_ image: UIImage) -> UIImage { guard let cgImage = image.cgImage else { return image } let width = CGFloat(cgImage.width) let height = CGFloat(cgImage.height) // Si ya es pequeña, no cambiar if width <= maxImageDimension && height <= maxImageDimension { return image } // Calcular nuevo tamaño manteniendo aspect ratio let aspectRatio = width / height let newWidth: CGFloat let newHeight: CGFloat if width > height { newWidth = maxImageDimension newHeight = maxImageDimension / aspectRatio } else { newHeight = maxImageDimension newWidth = maxImageDimension * aspectRatio } // Redimensionar let newSize = CGSize(width: newWidth, height: newHeight) let renderer = UIGraphicsImageRenderer(size: newSize) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } } // MARK: - Memory Management @objc private func handleMemoryWarning() { // BEFORE: Sin gestión de memoria // AFTER: Limpieza agresiva bajo presión de memoria print("⚠️ Memory warning received - Clearing image cache") // Limpiar cache de memoria (conservando disco cache) cache.removeAllObjects() // Cancelar preloading pendiente preloadQueueLock.lock() preloadQueue.removeAll() isPreloading = false preloadQueueLock.unlock() } // MARK: - Cache Maintenance /// BEFORE: Sin limpieza periódica /// AFTER: Limpieza automática de cache viejo private func setupPeriodicCleanup() { // Ejecutar cleanup cada 24 horas Timer.scheduledTimer(withTimeInterval: 86400, repeats: true) { [weak self] _ in self?.performCleanup() } // También ejecutar al iniciar performCleanup() } private func performCleanup() { print("🧹 Performing image cache cleanup...") var totalSize: Int64 = 0 var files: [(URL, Int64)] = [] // Calcular tamaño actual if let enumerator = fileManager.enumerator(at: diskCacheDirectory, includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey]) { for case let fileURL as URL in enumerator { if let resourceValues = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]), let fileSize = resourceValues.fileSize, let modificationDate = resourceValues.contentModificationDate { let age = Date().timeIntervalSince(modificationDate) totalSize += Int64(fileSize) files.append((fileURL, Int64(fileSize))) // Eliminar archivos muy viejos if age > maxCacheAge { try? fileManager.removeItem(at: fileURL) totalSize -= Int64(fileSize) print("🗑️ Removed old cached file: \(fileURL.lastPathComponent)") } } } } // Si excede límite de tamaño, eliminar archivos más viejos primero if totalSize > diskCacheLimit { let excess = totalSize - diskCacheLimit var removedSize: Int64 = 0 for (fileURL, fileSize) in files.sorted(by: { $0.0 < $1.0 }) { if removedSize >= excess { break } try? fileManager.removeItem(at: fileURL) removedSize += fileSize print("🗑️ Removed cached file due to size limit: \(fileURL.lastPathComponent)") } } print("✅ Cache cleanup completed. Size: \(formatFileSize(totalSize))") } /// Elimina todas las imágenes cacheadas func clearAllCache() { // Limpiar memoria cache.removeAllObjects() // Limpiar disco try? fileManager.removeItem(at: diskCacheDirectory) try? fileManager.createDirectory(at: diskCacheDirectory, withIntermediateDirectories: true) print("🧹 All image cache cleared") } /// Elimina imágenes específicas (para cuando se descarga un capítulo) func clearCache(for urls: [String]) { for url in urls { cache.removeObject(forKey: url as NSString) let fileURL = getDiskCacheURL(for: url) try? fileManager.removeItem(at: fileURL) } } // MARK: - Statistics func getCacheStatistics() -> CacheStatistics { let hitRate = cacheHits + cacheMisses > 0 ? Double(cacheHits) / Double(cacheHits + cacheMisses) : 0 let avgLoadTime = totalLoadedImages > 0 ? totalLoadTime / Double(totalLoadedImages) : 0 return CacheStatistics( memoryCacheHits: cacheHits, cacheMisses: cacheMisses, hitRate: hitRate, totalImagesLoaded: totalLoadedImages, averageLoadTime: avgLoadTime ) } func printStatistics() { let stats = getCacheStatistics() print("📊 Image Cache Statistics:") print(" - Cache Hits: \(stats.memoryCacheHits)") print(" - Cache Misses: \(stats.cacheMisses)") print(" - Hit Rate: \(String(format: "%.2f", stats.hitRate * 100))%") print(" - Total Images Loaded: \(stats.totalImagesLoaded)") print(" - Avg Load Time: \(String(format: "%.3f", stats.averageLoadTime))s") } private func formatFileSize(_ bytes: Int64) -> String { let formatter = ByteCountFormatter() formatter.allowedUnits = [.useBytes, .useKB, .useMB] formatter.countStyle = .file return formatter.string(fromByteCount: bytes) } deinit { NotificationCenter.default.removeObserver(self) } } // MARK: - Supporting Types struct CacheStatistics { let memoryCacheHits: Int let cacheMisses: Int let hitRate: Double let totalImagesLoaded: Int let averageLoadTime: TimeInterval }