import UIKit protocol RDEpubReaderCachePolicyProviding { var shouldAvoidReaderPageCaching: Bool { get } } /// 页面视图被缓存彻底丢弃(旋转失效、窗口裁剪)时的即时清理钩子, /// 让持有重资源的页面(如活 WKWebView)不必等 dealloc 就能拆除内容。 /// 实现方在之后再次被 configure 时必须能恢复可用。 protocol RDEpubReaderPageResourceReleasing { func releaseResources() } /// 页面进入或离开当前阅读视口时的生命周期通知。 /// 持有音视频等持续运行资源的页面应在离开时暂停,在重新进入时恢复。 protocol RDEpubReaderPageVisibilityHandling: AnyObject { func readerPageVisibilityDidChange(isVisible: Bool) } final class RDEpubReaderPreloadController { var radius: Int = 2 private let preloadHostView = UIView() private var preloadedPageViews: [Int: UIView] = [:] private var pageCurlCachedViews: [Int: UIView] = [:] private var cacheSignature: CacheSignature? struct Environment { let displayType: RDEpubReaderView.DisplayType let isLandscape: Bool let pagesPerScreen: Int let boundsSize: CGSize let landscapeDualPageEnabled: Bool let coverPageIndex: Int? let totalPages: Int let spreadResolver: RDEpubReaderSpreadResolver } private struct CacheSignature: Equatable { let displayType: RDEpubReaderView.DisplayType let isLandscape: Bool let pagesPerScreen: Int let boundsSize: CGSize } func setHostFrame(_ frame: CGRect) { preloadHostView.frame = frame } func ensureHostView(in parentView: UIView) { guard preloadHostView.superview == nil else { return } preloadHostView.isHidden = true preloadHostView.isUserInteractionEnabled = false preloadHostView.clipsToBounds = true preloadHostView.frame = parentView.bounds preloadHostView.autoresizingMask = [.flexibleWidth, .flexibleHeight] parentView.insertSubview(preloadHostView, at: 0) } func initializeSignature(_ environment: Environment) { cacheSignature = currentCacheSignature(environment) } func invalidate(environment: Environment) { RDEpubReaderTapDebug.log( "PreloadController.invalidate", "clearing pageCurlCached=\(pageCurlCachedViews.keys.sorted()) preloaded=\(preloadedPageViews.keys.sorted())" ) pageCurlCachedViews.values.forEach { discard($0) } preloadedPageViews.values.forEach { discard($0) } pageCurlCachedViews.removeAll() preloadedPageViews.removeAll() cacheSignature = currentCacheSignature(environment) } func pageViewForDisplay( pageNum: Int, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView? ) -> UIView { let view: UIView if let reusableView = detachedReusablePageView(for: pageNum) { view = reusableView RDEpubReaderTapDebug.log( "PreloadController.pageViewForDisplay", "cache HIT page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(reusableView))" ) } else { view = contentViewProvider(pageNum, nil) ?? UIView() RDEpubReaderTapDebug.log( "PreloadController.pageViewForDisplay", "cache MISS page=\(pageNum) created=\(RDEpubReaderTapDebug.describe(view))" ) } if shouldCache(view: view, for: pageNum, environment: environment) { pageCurlCachedViews[pageNum] = view } else { pageCurlCachedViews.removeValue(forKey: pageNum) } return view } func takePreloadedView(for pageNum: Int) -> UIView? { let preloaded = preloadedPageViews.removeValue(forKey: pageNum) preloaded?.removeFromSuperview() RDEpubReaderTapDebug.log( "PreloadController.takePreloadedView", "cache \(preloaded == nil ? "MISS" : "HIT") page=\(pageNum) view=\(RDEpubReaderTapDebug.describe(preloaded))" ) return preloaded } func updatePageVisibility(visiblePageNumbers: Set) { var notifiedViews = Set() let cachedViews = pageCurlCachedViews.merging(preloadedPageViews) { current, _ in current } for (pageNumber, view) in cachedViews { let identifier = ObjectIdentifier(view) guard notifiedViews.insert(identifier).inserted else { continue } (view as? RDEpubReaderPageVisibilityHandling)? .readerPageVisibilityDidChange(isVisible: visiblePageNumbers.contains(pageNumber)) } } func prime( around pageNum: Int, preferredForward: Bool? = nil, parentView: UIView, environment: Environment, contentViewProvider: (Int, UIView?) -> UIView? ) { refreshCacheSignatureIfNeeded(environment) let targets = forecastTargets(around: pageNum, preferredForward: preferredForward, environment: environment) let keepSet = Set(targets).union(visiblePageNumbers(for: pageNum, environment: environment)) trimCachedPageViews(keeping: keepSet) guard !targets.isEmpty else { return } ensureHostView(in: parentView) preloadHostView.frame = parentView.bounds for targetPage in targets { if let liveCached = pageCurlCachedViews[targetPage], liveCached.superview != nil, liveCached.superview !== preloadHostView { // 该页视图仍挂在翻页控制器的相邻页缓存上,内容已就绪。 // 以前这里会误删缓存条目并创建一个从零加载的副本顶替, // 重内容页翻过去时拿到的是仍在加载的副本,表现为翻页后闪烁重置。 RDEpubReaderTapDebug.log( "PreloadController.prime", "skip live page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(liveCached))" ) continue } let contentView: UIView if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView { contentView = existing RDEpubReaderTapDebug.log( "PreloadController.prime", "cache HIT(preloaded) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(existing))" ) } else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil { contentView = cached RDEpubReaderTapDebug.log( "PreloadController.prime", "cache HIT(pageCurl) page=\(targetPage) view=\(RDEpubReaderTapDebug.describe(cached))" ) } else { guard let providedView = contentViewProvider(targetPage, nil) else { // 页面提供者明确禁止预加载时不能塞入空白占位缓存, // 否则真实展示会直接命中白页而不再请求实际内容。 preloadedPageViews.removeValue(forKey: targetPage) pageCurlCachedViews.removeValue(forKey: targetPage) continue } contentView = providedView RDEpubReaderTapDebug.log( "PreloadController.prime", "cache MISS page=\(targetPage) created=\(RDEpubReaderTapDebug.describe(contentView))" ) } let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment) if shouldCacheContentView { preloadedPageViews[targetPage] = contentView } else { preloadedPageViews.removeValue(forKey: targetPage) } if contentView.superview !== preloadHostView { contentView.removeFromSuperview() preloadHostView.addSubview(contentView) } contentView.frame = preloadHostView.bounds if !shouldCacheContentView { contentView.removeFromSuperview() } } } private func currentCacheSignature(_ environment: Environment) -> CacheSignature { CacheSignature( displayType: environment.displayType, isLandscape: environment.isLandscape, pagesPerScreen: environment.pagesPerScreen, boundsSize: environment.boundsSize ) } private func refreshCacheSignatureIfNeeded(_ environment: Environment) { let signature = currentCacheSignature(environment) if cacheSignature != signature { invalidate(environment: environment) } else if cacheSignature == nil { cacheSignature = signature } } private func shouldCachePage(_ pageNum: Int, environment: Environment) -> Bool { pageNum >= 0 && pageNum != RDEpubReaderView.blankPageNum && pageNum != RDEpubReaderView.blankEndPageNum && pageNum < environment.totalPages } private func shouldCache(view: UIView, for pageNum: Int, environment: Environment) -> Bool { guard shouldCachePage(pageNum, environment: environment) else { return false } if let policyProvider = view as? RDEpubReaderCachePolicyProviding { return !policyProvider.shouldAvoidReaderPageCaching } return true } private func isDualPage(environment: Environment) -> Bool { environment.landscapeDualPageEnabled && environment.isLandscape && environment.displayType != .verticalScroll } private func spreadStart(for pageNum: Int, environment: Environment) -> Int? { guard shouldCachePage(pageNum, environment: environment) else { return nil } guard isDualPage(environment: environment) else { return pageNum } return environment.spreadResolver.dualPagePair( for: pageNum, totalPages: environment.totalPages, coverPageIndex: environment.coverPageIndex ).left } private func spreadPageNumbers(startingAt pageNum: Int, environment: Environment) -> Set { guard shouldCachePage(pageNum, environment: environment) else { return [] } guard isDualPage(environment: environment) else { return [pageNum] } let pair = environment.spreadResolver.dualPagePair( for: pageNum, totalPages: environment.totalPages, coverPageIndex: environment.coverPageIndex ) var pages: Set = [pair.left] if let right = pair.right, shouldCachePage(right, environment: environment) { pages.insert(right) } return pages } private func adjacentSpreadStart(from pageNum: Int, forward: Bool, environment: Environment) -> Int? { guard environment.totalPages > 0, let spreadStart = spreadStart(for: pageNum, environment: environment) else { return nil } guard isDualPage(environment: environment) else { let target = forward ? spreadStart + 1 : spreadStart - 1 return shouldCachePage(target, environment: environment) ? target : nil } if forward { return environment.spreadResolver.adjacentDualPage( from: spreadStart, totalPages: environment.totalPages, coverPageIndex: environment.coverPageIndex, forward: true ) } let pair = environment.spreadResolver.dualPagePair( for: spreadStart, totalPages: environment.totalPages, coverPageIndex: environment.coverPageIndex ) let prevEnd = pair.left - 1 guard prevEnd >= 0 else { return nil } return environment.spreadResolver.dualPagePair( for: prevEnd, totalPages: environment.totalPages, coverPageIndex: environment.coverPageIndex ).left } private func visiblePageNumbers(for anchorPage: Int, environment: Environment) -> Set { spreadPageNumbers(startingAt: anchorPage, environment: environment) } private func detachedReusablePageView(for pageNum: Int) -> UIView? { if let preloaded = preloadedPageViews.removeValue(forKey: pageNum) { preloaded.removeFromSuperview() return preloaded } guard let cached = pageCurlCachedViews[pageNum], cached.superview == nil else { return nil } pageCurlCachedViews.removeValue(forKey: pageNum) return cached } private func trimCachedPageViews(keeping pageNumbers: Set) { pageCurlCachedViews = pageCurlCachedViews.filter { key, value in let keep = pageNumbers.contains(key) if !keep { discard(value) } return keep } preloadedPageViews = preloadedPageViews.filter { key, value in let keep = pageNumbers.contains(key) if !keep { discard(value) } return keep } } private func discard(_ view: UIView) { // 只有闲置视图(未挂载或还在预加载宿主里)才能立即拆除内容; // 挂在子 VC / cell 上的视图可能正在展示或会被 UIPageViewController // 的缓存重新呈现,提前拆除会让翻页露出空白页。这类视图交由持有方 // 释放后经 deinit 自然回收(弱代理已保证无保留环)。 let isHeldOutsidePreloadHost = view.superview != nil && view.superview !== preloadHostView view.removeFromSuperview() if !isHeldOutsidePreloadHost { (view as? RDEpubReaderPageResourceReleasing)?.releaseResources() } } private func forecastTargets(around pageNum: Int, preferredForward: Bool?, environment: Environment) -> [Int] { guard environment.totalPages > 0, shouldCachePage(pageNum, environment: environment) else { return [] } var targets = Set() var previousAnchor = pageNum for _ in 0..