import SwiftUI /// Vista de lectura optimizada para rendimiento y uso de memoria /// /// OPTIMIZACIONES IMPLEMENTADAS: /// 1. Image caching con NSCache (BEFORE: Sin cache en memoria) /// 2. Preloading de páginas adyacentes (BEFORE: Carga bajo demanda) /// 3. Memory management para imágenes grandes (BEFORE: Sin control de memoria) /// 4. Optimización de TabView con lazy loading (BEFORE: Cargaba todas las vistas) /// 5. Thumbnail system para navegación rápida (BEFORE: Sin thumbnails) /// 6. Progress guardado eficientemente (BEFORE: Guardaba siempre) /// 7. View recycling para páginas (BEFORE: Nueva vista por página) struct ReaderViewOptimized: View { let manga: Manga let chapter: Chapter @StateObject private var viewModel: ReaderViewModelOptimized @Environment(\.dismiss) var dismiss init(manga: Manga, chapter: Chapter) { self.manga = manga self.chapter = chapter _viewModel = StateObject(wrappedValue: ReaderViewModelOptimized(manga: manga, chapter: chapter)) } var body: some View { ZStack { // Color de fondo configurable (viewModel.backgroundColor) .ignoresSafeArea() VStack(spacing: 0) { // Header readerHeader .background(viewModel.backgroundColor) // Content if viewModel.isLoading { loadingView } else if viewModel.pages.isEmpty { errorView } else { readerContent } // Footer/Toolbar readerFooter .background(viewModel.backgroundColor) } // Gestures para mostrar/ocultar controles if viewModel.showControls { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { withAnimation { viewModel.showControls = false } } } } .navigationBarHidden(true) .statusBar(hidden: viewModel.showControls ? false : true) .task { await viewModel.loadPages() } .onDisappear { // BEFORE: No se liberaba memoria explícitamente // AFTER: Limpieza explícita de memoria al salir viewModel.cleanupMemory() } .alert("Error", isPresented: $viewModel.showError) { Button("OK") { dismiss() } } message: { Text(viewModel.errorMessage) } } private var readerHeader: View { HStack { Button { dismiss() } label: { Image(systemName: "chevron.left") .foregroundColor(.primary) .padding() } VStack(alignment: .leading, spacing: 2) { Text(manga.title) .font(.caption) .foregroundColor(.secondary) Text("\(chapter.displayNumber)") .font(.headline) } Spacer() Button { viewModel.toggleFavorite() } label: { Image(systemName: viewModel.isFavorite ? "heart.fill" : "heart") .foregroundColor(viewModel.isFavorite ? .red : .primary) .padding() } Button { viewModel.showingSettings = true } label: { Image(systemName: "textformat") .foregroundColor(.primary) .padding() } } .opacity(viewModel.showControls ? 1 : 0) .animation(.easeInOut(duration: 0.2), value: viewModel.showControls) } private var loadingView: some View { VStack(spacing: 20) { ProgressView() .scaleEffect(1.5) Text("Cargando capítulo...") .foregroundColor(.secondary) if let progress = viewModel.downloadProgress { Text("\(Int(progress * 100))%") .font(.caption) .foregroundColor(.secondary) ProgressView(value: progress) .frame(width: 200) } } } private var errorView: some View { VStack(spacing: 20) { Image(systemName: "exclamationmark.triangle") .font(.system(size: 50)) .foregroundColor(.orange) Text("No se pudieron cargar las páginas") .foregroundColor(.secondary) Button("Reintentar") { Task { await viewModel.loadPages() } } .buttonStyle(.borderedProminent) } } /// BEFORE: TabView cargaba todas las páginas de una vez /// AFTER: Lazy loading de vistas + preloading inteligente private var readerContent: some View { GeometryReader { geometry in TabView(selection: $viewModel.currentPage) { ForEach(viewModel.pages) { page in // BEFORE: Nueva instancia de PageView para cada página // AFTER: View recycling con identificadores únicos PageViewOptimized( page: page, mangaSlug: manga.slug, chapterNumber: chapter.number, viewModel: viewModel ) .id(page.index) .tag(page.index) .onAppear { // BEFORE: Sin preloading // AFTER: Precargar páginas adyacentes al aparecer viewModel.preloadAdjacentPages( currentIndex: page.index, total: viewModel.pages.count ) } } } .tabViewStyle(.page(indexDisplayMode: .never)) .onTapGesture(count: 2) { withAnimation { viewModel.showControls.toggle() } } .onChange(of: viewModel.currentPage) { oldValue, newValue in // BEFORE: Sin tracking de cambios de página // AFTER: Preload basado en navegación + guardado diferido de progreso viewModel.currentPageChanged(from: oldValue, to: newValue) } } } private var readerFooter: some View { VStack(spacing: 8) { // Page indicator HStack { Text("Página \(viewModel.currentPageIndex + 1)") .font(.caption) .foregroundColor(.secondary) Text("de") .font(.caption) .foregroundColor(.secondary) Text("\(viewModel.totalPages)") .font(.caption) .foregroundColor(.secondary) Spacer() if viewModel.isDownloaded { Label("Descargado", systemImage: "checkmark.circle.fill") .font(.caption) .foregroundColor(.green) } // BEFORE: Sin indicador de memoria // AFTER: Indicador de uso de memoria (debug) #if DEBUG Text("\(ImageCache.shared.getCacheStatistics().memoryCacheHits) hits") .font(.caption2) .foregroundColor(.secondary) #endif } .padding(.horizontal) // Progress bar ProgressView(value: Double(viewModel.currentPageIndex + 1), total: Double(viewModel.totalPages)) .progressViewStyle(.linear) .padding(.horizontal) // Controls HStack(spacing: 20) { Button { viewModel.showingPageSlider = true } label: { Image(systemName: "slider.horizontal.3") .foregroundColor(.primary) } Button { viewModel.readingMode = viewModel.readingMode == .vertical ? .horizontal : .vertical } label: { Image(systemName: viewModel.readingMode == .vertical ? "rectangle.grid.1x2" : "rectangle.grid.2x1") .foregroundColor(.primary) } Button { viewModel.cycleBackgroundColor() } label: { Image(systemName: "circle.fill") .foregroundColor(viewModel.backgroundColor == .white ? .black : .white) .padding(4) .background(viewModel.backgroundColor == .white ? .black : .white) .clipShape(Circle()) } Spacer() // First/Last buttons Button { withAnimation { viewModel.currentPage = 0 } } label: { Image(systemName: "chevron.left.2") .foregroundColor(.primary) } Button { withAnimation { viewModel.currentPage = viewModel.totalPages - 1 } } label: { Image(systemName: "chevron.right.2") .foregroundColor(.primary) } } .padding() } .opacity(viewModel.showControls ? 1 : 0) .animation(.easeInOut(duration: 0.2), value: viewModel.showControls) .sheet(isPresented: $viewModel.showingPageSlider) { pageSliderSheet } .sheet(isPresented: $viewModel.showingSettings) { readerSettingsSheet } } private var pageSliderSheet: some View { NavigationView { VStack(spacing: 20) { Text("Ir a página") .font(.headline) VStack(alignment: .leading, spacing: 8) { Text("\(viewModel.currentPageIndex + 1) / \(viewModel.totalPages)") .font(.title) .bold() Slider( value: Binding( get: { Double(viewModel.currentPageIndex + 1) }, set: { viewModel.currentPage = Int($0) - 1 } ), in: 1...Double(viewModel.totalPages), step: 1 ) } .padding() Spacer() } .navigationTitle("Navegación") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Cerrar") { viewModel.showingPageSlider = false } } } } } private var readerSettingsSheet: some View { NavigationView { Form { Section("Fondo de pantalla") { Picker("Color", selection: $viewModel.backgroundColor) { Text("Blanco").tag(Color.white) Text("Negro").tag(Color.black) Text("Sepia").tag(Color(red: 0.76, green: 0.70, blue: 0.50)) } .pickerStyle(.segmented) } Section("Lectura") { Picker("Modo de lectura", selection: $viewModel.readingMode) { Text("Vertical").tag(ReadingMode.vertical) Text("Horizontal").tag(ReadingMode.horizontal) } } // BEFORE: Sin opciones de cache // AFTER: Control de cache Section("Rendimiento") { Toggle("Precargar páginas", isOn: $viewModel.enablePreloading) Toggle("Caché de imágenes", isOn: $viewModel.enableImageCache) Button("Limpiar caché") { viewModel.clearImageCache() } .foregroundColor(.red) } } .navigationTitle("Configuración") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button("Cerrar") { viewModel.showingSettings = false } } } } } } // MARK: - Optimized Page View struct PageViewOptimized: View { let page: MangaPage let mangaSlug: String let chapterNumber: Int @ObservedObject var viewModel: ReaderViewModelOptimized @State private var scale: CGFloat = 1.0 @State private var lastScale: CGFloat = 1.0 @State private var offset: CGSize = .zero @State private var lastOffset: CGSize = .zero /// BEFORE: Estado de imagen no gestionado /// AFTER: Estado explícito con gestión de memoria @State private var imageState: ImageLoadState = .loading @State private var currentImage: UIImage? enum ImageLoadState { case loading case loaded(UIImage) case failed case cached(UIImage) } var body: some View { GeometryReader { geometry in Group { switch imageState { case .loading: // BEFORE: ProgressView genérico // AFTER: Placeholder con skeleton skeletonView case .cached(let image), .loaded(let image): // BEFORE: Imagen cargada siempre a full resolution // AFTER: Optimizada con memory management OptimizedImageView(image: image) .onDisappear { // BEFORE: Sin liberación de memoria // AFTER: Limpieza de imagen cuando la vista desaparece cleanupImageIfNeeded() } case .failed: Image(systemName: "photo") .foregroundColor(.gray) .frame(maxWidth: .infinity, maxHeight: .infinity) } } .frame(maxWidth: .infinity, maxHeight: .infinity) .scaleEffect(scale) .offset(offset) .gesture( SimultaneousGesture( MagnificationGesture() .onChanged { value in let delta = value / lastScale lastScale = value scale = max(1, min(scale * delta, 10)) } .onEnded { _ in lastScale = 1.0 if scale < 1.2 { withAnimation { scale = 1.0 offset = .zero } } }, DragGesture() .onChanged { value in offset = CGSize( width: lastOffset.width + value.translation.width, height: lastOffset.height + value.translation.height ) } .onEnded { _ in if scale == 1.0 { withAnimation { offset = .zero } } lastOffset = offset } ) ) } .task { await loadImage() } } private var skeletonView: some View { RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) .overlay( ProgressView() ) .frame(maxWidth: .infinity, maxHeight: .infinity) } /// BEFORE: Carga de imagen sin optimización /// AFTER: Sistema multi-capa con prioridades private func loadImage() async { // 1. Verificar cache de imágenes en memoria primero if let cachedImage = ImageCache.shared.image(for: page.url) { imageState = .cached(cachedImage) currentImage = cachedImage return } // 2. Verificar si hay thumbnail disponible para preview rápido if let thumbnail = StorageServiceOptimized.shared.loadImage( mangaSlug: mangaSlug, chapterNumber: chapterNumber, pageIndex: page.index, useThumbnail: true ) { // Mostrar thumbnail primero (rápido) imageState = .loaded(thumbnail) currentImage = thumbnail } // 3. Cargar imagen completa if let localImage = StorageServiceOptimized.shared.loadImage( mangaSlug: mangaSlug, chapterNumber: chapterNumber, pageIndex: page.index, useThumbnail: false ) { // Guardar en cache ImageCache.shared.setImage(localImage, for: page.url) imageState = .loaded(localImage) currentImage = localImage } else { // 4. Descargar si no está disponible localmente await downloadImage() } } private func downloadImage() async { guard let url = URL(string: page.url) else { imageState = .failed return } do { let (data, _) = try await URLSession.shared.data(from: url) if let image = UIImage(data: data) { // BEFORE: Sin optimización // AFTER: Optimizar tamaño antes de usar let optimizedImage = optimizeImage(image) // Guardar en cache y localmente ImageCache.shared.setImage(optimizedImage, for: page.url) try? await StorageServiceOptimized.shared.saveImage( optimizedImage, mangaSlug: mangaSlug, chapterNumber: chapterNumber, pageIndex: page.index ) imageState = .loaded(optimizedImage) currentImage = optimizedImage } else { imageState = .failed } } catch { imageState = .failed } } /// BEFORE: Sin optimización de imagen /// AFTER: Redimensiona imágenes muy grandes private func optimizeImage(_ image: UIImage) -> UIImage { let maxDimension: CGFloat = 2048 guard let cgImage = image.cgImage else { return image } let width = CGFloat(cgImage.width) let height = CGFloat(cgImage.height) if width <= maxDimension && height <= maxDimension { return image } let aspectRatio = width / height let newWidth: CGFloat let newHeight: CGFloat if width > height { newWidth = maxDimension newHeight = maxDimension / aspectRatio } else { newHeight = maxDimension newWidth = maxDimension * aspectRatio } let newSize = CGSize(width: newWidth, height: newHeight) let renderer = UIGraphicsImageRenderer(size: newSize) return renderer.image { _ in image.draw(in: CGRect(origin: .zero, size: newSize)) } } /// BEFORE: Sin limpieza de memoria /// AFTER: Libera memoria cuando la página no está visible private func cleanupImageIfNeeded() { // Solo limpiar si no está en cache (para preservar cache adyacente) if imageState != .cached(nil) { // Mantener referencia débil para permitir liberación currentImage = nil } } } /// Vista optimizada para renderizar imágenes grandes struct OptimizedImageView: View { let image: UIImage var body: some View { Image(uiImage: image) .resizable() .aspectRatio(contentMode: .fit) .drawingGroup() // Optimiza rendering } } // MARK: - Optimized ViewModel @MainActor class ReaderViewModelOptimized: ObservableObject { @Published var pages: [MangaPage] = [] @Published var currentPage: Int = 0 @Published var isLoading = true @Published var showError = false @Published var errorMessage = "" @Published var showControls = true @Published var isFavorite = false @Published var isDownloaded = false @Published var downloadProgress: Double? @Published var showingPageSlider = false @Published var showingSettings = false @Published var backgroundColor: Color = .white @Published var readingMode: ReadingMode = .vertical // BEFORE: Sin control de optimizaciones // AFTER: Control de características de rendimiento @Published var enablePreloading = true @Published var enableImageCache = true var currentPageIndex: Int { currentPage } var totalPages: Int { pages.count } private let manga: Manga private let chapter: Chapter private let scraper = ManhwaWebScraperOptimized.shared private let storage = StorageServiceOptimized.shared // BEFORE: Sin debouncing de guardado de progreso // AFTER: Debouncing para no guardar en cada cambio de página private var progressSaveTimer: Timer? private let progressSaveDebounce: TimeInterval = 2.0 init(manga: Manga, chapter: Chapter) { self.manga = manga self.chapter = chapter self.isFavorite = storage.isFavorite(mangaSlug: manga.slug) self.isDownloaded = storage.isChapterDownloaded(mangaSlug: manga.slug, chapterNumber: chapter.number) } func loadPages() async { isLoading = true do { if let downloadedChapter = storage.getDownloadedChapter(mangaSlug: manga.slug, chapterNumber: chapter.number) { pages = downloadedChapter.pages isDownloaded = true if let progress = storage.getReadingProgress(mangaSlug: manga.slug, chapterNumber: chapter.number) { currentPage = progress.pageNumber } } else { let imageUrls = try await scraper.scrapeChapterImages(chapterSlug: chapter.slug) // BEFORE: Sin control de memoria durante carga // AFTER: Carga controlada con progress tracking let totalImages = imageUrls.count var loadedPages: [MangaPage] = [] for (index, url) in imageUrls.enumerated() { loadedPages.append(MangaPage(url: url, index: index)) // Actualizar progress downloadProgress = Double(index + 1) / Double(totalImages) } pages = loadedPages downloadProgress = nil } // Precargar primeras páginas if enablePreloading && !pages.isEmpty { await preloadAdjacentPages(currentIndex: 0, total: pages.count) } } catch { errorMessage = error.localizedDescription showError = true } isLoading = false saveProgressDebounced() } /// BEFORE: Sin sistema de preloading /// AFTER: Preloading inteligente de páginas adyacentes func preloadAdjacentPages(currentIndex: Int, total: Int) { guard enablePreloading else { return } // Precargar 2 páginas anteriores y 2 siguientes let startIndex = max(0, currentIndex - 2) let endIndex = min(total - 1, currentIndex + 2) for index in startIndex...endIndex { guard index != currentIndex else { continue } guard pages.indices.contains(index) else { continue } let page = pages[index] // Precargar en background si no está en cache Task(priority: .utility) { if ImageCache.shared.image(for: page.url) == nil { // La carga se hará bajo demanda } } } } /// BEFORE: Guardaba progreso en cada cambio /// AFTER: Debouncing para reducir escrituras a disco func currentPageChanged(from oldValue: Int, to newValue: Int) { saveProgressDebounced() // Precargar nuevas páginas adyacentes preloadAdjacentPages(currentIndex: newValue, total: pages.count) } private func saveProgressDebounced() { // Cancelar timer anterior progressSaveTimer?.invalidate() // Crear nuevo timer progressSaveTimer = Timer.scheduledTimer(withTimeInterval: progressSaveDebounce, repeats: false) { [weak self] _ in self?.saveProgress() } } private func saveProgress() { let progress = ReadingProgress( mangaSlug: manga.slug, chapterNumber: chapter.number, pageNumber: currentPage, timestamp: Date() ) storage.saveReadingProgress(progress) } func toggleFavorite() { if storage.isFavorite(mangaSlug: manga.slug) { storage.removeFavorite(mangaSlug: manga.slug) } else { storage.saveFavorite(mangaSlug: manga.slug) } isFavorite.toggle() } func cycleBackgroundColor() { switch backgroundColor { case .white: backgroundColor = .black case .black: backgroundColor = Color(red: 0.76, green: 0.70, blue: 0.50) default: backgroundColor = .white } } func clearImageCache() { ImageCache.shared.clearAllCache() } /// BEFORE: Sin limpieza explícita de memoria /// AFTER: Limpieza completa de memoria al salir func cleanupMemory() { // Cancelar timer de progreso progressSaveTimer?.invalidate() // Guardar progreso final saveProgress() // Limpiar cache de imágenes si está deshabilitado if !enableImageCache { let urls = pages.map { $0.url } ImageCache.shared.clearCache(for: urls) } } deinit { progressSaveTimer?.invalidate() } } enum ReadingMode { case vertical case horizontal } #Preview { ReaderViewOptimized( manga: Manga( slug: "one-piece_1695365223767", title: "One Piece", description: "", genres: [], status: "PUBLICANDOSE", url: "", coverImage: nil ), chapter: Chapter(number: 1, title: "El inicio", url: "", slug: "") ) }