源码注释: - 为 ~60 个 Swift 文件补充缺失的 doc comment(file header、类型、属性、方法) - 修正 4 处错误注释:翻页模式数量、搜索行为描述、手势识别器描述、悬空文档块 文档维护: - 删除重复文档:WXRead/读书EPUB阅读器实现架构.md(与微信读书版完全一致) - 合并重叠文档:阅读器规划.md → 阅读器功能开发计划.md(单一真值) - 修正过时内容:所有文档中"四种翻页模式"→"三种",移除 horizontalCoverScroll - 更新架构图:补齐 EPUBUI/ReaderController、Paging/、Typesetter/ 等子目录 - 更新 index.md 索引:新增开发计划和架构对比文档引用
276 lines
11 KiB
Swift
276 lines
11 KiB
Swift
//
|
||
// RDReaderPreloadController.swift
|
||
// ReadViewSDK
|
||
//
|
||
// 文件职责:页面预加载控制器,负责提前渲染即将显示的页面视图以减少翻页卡顿。
|
||
//
|
||
|
||
import UIKit
|
||
|
||
/// 页面预加载控制器
|
||
/// 负责管理页面视图的预加载和缓存,在当前页面基础上提前渲染前后若干页,
|
||
/// 以减少翻页时的等待时间。支持仿真翻页和滚动模式下的不同复用策略。
|
||
final class RDReaderPreloadController {
|
||
/// 预加载半径,表示在当前页前后各预加载的页数,默认为 1
|
||
var radius: Int = 1
|
||
|
||
private let preloadHostView = UIView()
|
||
private var preloadedPageViews: [Int: UIView] = [:]
|
||
private var pageCurlCachedViews: [Int: UIView] = [:]
|
||
private var cacheSignature: CacheSignature?
|
||
|
||
/// 预加载环境参数,封装翻页模式、屏幕方向、页面尺寸等上下文信息
|
||
struct Environment {
|
||
let displayType: RDReaderView.DisplayType
|
||
let isLandscape: Bool
|
||
let pagesPerScreen: Int
|
||
let boundsSize: CGSize
|
||
let landscapeDualPageEnabled: Bool
|
||
let coverPageIndex: Int?
|
||
let totalPages: Int
|
||
let spreadResolver: RDReaderSpreadResolver
|
||
}
|
||
|
||
private struct CacheSignature: Equatable {
|
||
let displayType: RDReaderView.DisplayType
|
||
let isLandscape: Bool
|
||
let pagesPerScreen: Int
|
||
let boundsSize: CGSize
|
||
}
|
||
|
||
/// 设置预加载宿主视图的 frame
|
||
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) {
|
||
pageCurlCachedViews.values.forEach { $0.removeFromSuperview() }
|
||
preloadedPageViews.values.forEach { $0.removeFromSuperview() }
|
||
pageCurlCachedViews.removeAll()
|
||
preloadedPageViews.removeAll()
|
||
cacheSignature = currentCacheSignature(environment)
|
||
}
|
||
|
||
/// 获取指定页码的页面视图用于显示,优先复用已缓存的视图
|
||
func pageViewForDisplay(
|
||
pageNum: Int,
|
||
environment: Environment,
|
||
contentViewProvider: (Int, UIView?) -> UIView?
|
||
) -> UIView {
|
||
let reusableView = detachedReusablePageView(for: pageNum)
|
||
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
|
||
pageCurlCachedViews[pageNum] = view
|
||
return view
|
||
}
|
||
|
||
/// 取出指定页码的预加载视图并从缓存中移除,供外部复用
|
||
func takePreloadedView(for pageNum: Int) -> UIView? {
|
||
let preloaded = preloadedPageViews.removeValue(forKey: pageNum)
|
||
preloaded?.removeFromSuperview()
|
||
return preloaded
|
||
}
|
||
|
||
/// 预加载当前页周围指定半径内的页面,清除不再需要的缓存视图
|
||
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 {
|
||
let existing = detachedReusablePageView(for: targetPage)
|
||
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
|
||
preloadedPageViews[targetPage] = contentView
|
||
if contentView.superview !== preloadHostView {
|
||
contentView.removeFromSuperview()
|
||
preloadHostView.addSubview(contentView)
|
||
}
|
||
contentView.frame = preloadHostView.bounds
|
||
}
|
||
}
|
||
|
||
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 != RDReaderView.blankPageNum
|
||
&& pageNum != RDReaderView.blankEndPageNum
|
||
&& pageNum < environment.totalPages
|
||
}
|
||
|
||
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<Int> {
|
||
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<Int> = [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<Int> {
|
||
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<Int>) {
|
||
pageCurlCachedViews = pageCurlCachedViews.filter { key, value in
|
||
let keep = pageNumbers.contains(key)
|
||
if !keep {
|
||
value.removeFromSuperview()
|
||
}
|
||
return keep
|
||
}
|
||
|
||
preloadedPageViews = preloadedPageViews.filter { key, value in
|
||
let keep = pageNumbers.contains(key)
|
||
if !keep {
|
||
value.removeFromSuperview()
|
||
}
|
||
return keep
|
||
}
|
||
}
|
||
|
||
private func forecastTargets(around pageNum: Int, preferredForward: Bool?, environment: Environment) -> [Int] {
|
||
guard environment.totalPages > 0,
|
||
shouldCachePage(pageNum, environment: environment) else {
|
||
return []
|
||
}
|
||
|
||
var targets = Set<Int>()
|
||
|
||
var previousAnchor = pageNum
|
||
for _ in 0..<max(radius, 0) {
|
||
guard let previous = adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) else { break }
|
||
targets.formUnion(spreadPageNumbers(startingAt: previous, environment: environment))
|
||
previousAnchor = previous
|
||
}
|
||
|
||
var nextAnchor = pageNum
|
||
for _ in 0..<max(radius, 0) {
|
||
guard let next = adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment) else { break }
|
||
targets.formUnion(spreadPageNumbers(startingAt: next, environment: environment))
|
||
nextAnchor = next
|
||
}
|
||
|
||
if let preferredForward,
|
||
let edgeAnchor = preferredForward ? adjacentSpreadStart(from: nextAnchor, forward: true, environment: environment)
|
||
: adjacentSpreadStart(from: previousAnchor, forward: false, environment: environment) {
|
||
targets.formUnion(spreadPageNumbers(startingAt: edgeAnchor, environment: environment))
|
||
}
|
||
|
||
return targets.sorted()
|
||
}
|
||
}
|