diff --git a/Doc/index.md b/Doc/index.md index dc30931..6ad19f9 100644 --- a/Doc/index.md +++ b/Doc/index.md @@ -1,6 +1,6 @@ # ReadViewSDK 文档索引 -> 最后更新:2026-06-12 +> 最后更新:2026-06-15 --- @@ -26,6 +26,7 @@ | 文档 | 说明 | |------|------| | [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划:10 个任务、修改位置、实施步骤、风险点与验收标准 | +| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 | | [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s | ## 项目信息 diff --git a/Doc/大书远距目录跳转与后台补全优化方案.md b/Doc/大书远距目录跳转与后台补全优化方案.md new file mode 100644 index 0000000..93d811f --- /dev/null +++ b/Doc/大书远距目录跳转与后台补全优化方案.md @@ -0,0 +1,1017 @@ +# 大书远距目录跳转与后台补全优化方案 + +> 最后更新:2026-06-15 +> 适用范围:`RDEPUBReaderPaginationCoordinator`、`RDEPUBReaderRuntime`、`RDEPUBReaderLocationCoordinator`、`RDEPUBReaderController+DataSource` +> 问题背景:大书按需分页模式下,从较早章节远距跳转到未解析章节时,虽然首跳章节可对齐,但后续翻页仍可能回落到错误章节窗口。 + +--- + +## 1. 文档目标 + +本文给出“大书远距目录跳转与后台补全”的完整优化方案,目标不是只修一次目录跳转,而是统一以下三件事: + +1. 远距目录跳转时,前台必须优先保证目标章节可读。 +2. 跳转后的连续翻页必须保持章节窗口连续,不被旧的后台页图覆盖。 +3. 后台补全必须围绕当前阅读区段优先补洞,而不是只按全书顺序推进。 + +本文基于当前仓库代码整理,不以旧文档描述为准。 + +--- + +## 2. 当前实现概览 + +当前大书模式由两条并行路径组成: + +1. 前台按需窗口 +2. 后台全书元数据补全 + +对应代码主入口: + +- 前台快速打开与局部窗口建立: + [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift:180) +- 后台元数据补全: + [RDEPUBReaderPaginationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift:395) +- 目录跳转前的目标章节准备: + [RDEPUBReaderRuntime.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift:404) +- 阅读位置恢复: + [RDEPUBReaderLocationCoordinator.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift:19) +- 翻页后的状态同步: + [RDEPUBReaderController+DataSource.swift](/Users/shenlei/Work/ReadViewSDK/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift:293) + +当前设计的优点: + +- 首开速度快,用户不必等待全书解析完成。 +- 单章内容和轻量页图是解耦的,内存开销较低。 +- 目录跳转前可以临时重建目标章节附近的小窗口,已经能保证“首跳章节正确”。 + +当前设计的核心缺口: + +- 后台补全仍然以“全书 buildable spine 顺序推进”为主,没有围绕当前阅读区段动态重排。 +- `pendingFullPageMap` 是单一全局候选页图,不区分“当前阅读段”和“远处阅读段”。 +- 局部窗口和后台全局页图之间缺少稳定的切换协议,容易在跳转后下一次翻页时重新落回旧窗口。 + +--- + +## 3. 当前问题复盘 + +以“当前解析到第 10 章,用户从目录跳到第 1000 章”为例。 + +### 3.1 当前实际行为 + +1. 前台当前持有的是“第 10 章附近”的局部 `bookPageMap`。 +2. 用户点击目录项后,系统会先通过 `ensureOnDemandNavigationTargetAvailable(for:)` 临时重建“第 1000 章附近”的局部窗口。 +3. 因此首跳可以落到正确章节。 +4. 但后台的 `pendingFullPageMap` 可能仍然主要覆盖第 10 章附近,或者仅覆盖一部分全书章节。 +5. 用户开始翻页时,如果前台再次接纳这份不适合当前区段的全局候选页图,就可能出现: + - 当前页仍在 1000 章附近 + - 但后续页码解析和扩窗语义重新基于旧区段 + - 导致下一章或后几页回到错误章节 + +### 3.2 问题本质 + +不是“目录跳转没有解析对”,而是: + +1. 前台阅读权威窗口缺少短时锁定机制。 +2. 后台补全结果是单一全局页图,接管粒度过粗。 +3. 后台任务优先级不跟随阅读位置变化而重排。 +4. 多次快速跳转、内存压力和异常任务恢复的边界条件尚未定义清楚。 + +--- + +## 4. 设计目标 + +优化方案需要同时满足以下目标。 + +### 4.1 功能目标 + +1. 从任意章节远距跳转到未解析章节时,首跳章节必须正确。 +2. 跳转后的连续翻页必须沿目标章节窗口连续推进。 +3. 后台补全完成后,最终仍要收敛到完整全书页图。 +4. 多次快速跳转、边界章节跳转和后台异常恢复都必须可预测。 + +### 4.2 性能目标 + +1. 不回退到“跳一次目录就全书同步解析”。 +2. 继续复用 `RDEPUBChapterSummaryDiskCache` 和单章同步加载能力。 +3. 将前台阻塞和热区补全时延量化。 + +建议验收阈值: + +- 远距目录跳转前台阻塞: + - P50 `< 120ms` + - P95 `< 250ms` + - 单次最长 `< 400ms` +- 跳转后首屏可读时间: + - P50 `< 500ms` + - P95 `< 1200ms` +- 跳转后 `hot zone` 80% 覆盖时间: + - `< 3s` +- 冷区公平性: + - 每 `30s` 至少有一次 cold zone 进展 + +### 4.3 架构目标 + +1. 当前阅读窗口与后台全书补全分层。 +2. 页图接管必须有明确准入条件。 +3. 后台解析顺序能够围绕用户当前位置动态重排。 +4. 后台 segment 和页图升级必须具备内存预算和降级策略。 + +--- + +## 5. 总体方案 + +建议将现有“大书按需分页”演进为三层模型: + +1. `Active Reading Window` +2. `Segmented Background Coverage` +3. `Full Book Convergence` + +### 5.1 Active Reading Window + +这是前台唯一权威来源。 + +职责: + +- 决定当前页码、当前章节、后续翻页所依赖的真实窗口。 +- 由当前阅读位置驱动扩窗。 +- 在短时间内拒绝不安全的全局候选页图覆盖。 + +可继续复用现有 `bookPageMap + chapterRuntimeStore`,但语义上需要明确: + +- 当前 `context.bookPageMap` 不再被视为“全书候选结果”,而是“当前权威阅读窗口”。 +- 所有翻页、目录跳转、当前页位置解析都只信任它。 + +### 5.2 Segmented Background Coverage + +后台不再只维护一个“越来越完整的全书候选图”,而是维护多段覆盖信息。 + +建议新增概念: + +```swift +struct RDEPUBBackgroundCoverageSegment { + let lowerSpineIndex: Int + let upperSpineIndex: Int + let pageMap: RDEPUBBookPageMap + let resolvedSpineIndices: Set + let generatedAt: CFAbsoluteTime + let renderSignature: String + let estimatedMemoryBytes: Int +} +``` + +职责: + +- 表示某一段章节范围已经具备稳定的页码覆盖。 +- 可以被前台按需合并或替换。 +- 不必要求一开始就成为完整全书页图。 + +### 5.3 Full Book Convergence + +最终后台仍会生成一份全书完整覆盖,但它只作为最终收敛目标,不再是每次前台导航时都想接管的唯一对象。 + +建议将现有: + +- `pendingFullPageMap` + +演进为: + +- `pendingCoverageSegments` +- `pendingCompletePageMap` + +其中: + +- `pendingCoverageSegments` 用于当前阅读区段附近的安全升级。 +- `pendingCompletePageMap` 只在完整覆盖达成时才应用。 + +### 5.4 `renderSignature` 定义 + +文中涉及的 `renderSignature` 用于判断两个 segment 或完整页图是否属于同一分页语义版本。 + +建议构成如下: + +```swift +// renderSignature 由以下因素拼接后再 hash: +// 1. 排版参数:字体、字号、行高倍数、段间距、列数、列间距、内容 inset +// 2. 视口参数:pageSize、safeAreaInsets、单双页模式、fixed/reflowable 展示参数 +// 3. 算法版本:分页算法版本号、chapter summary schemaVersion +// 4. 阅读器关键策略:widow/orphan 避让开关、文本渲染引擎选择 +``` + +约束: + +1. `renderSignature` 相同,才允许 segment 合并或后台页图接管前台。 +2. `renderSignature` 不同,一律视为不同分页版本: + - 不合并 + - 不复用旧页图进行前台接管 + - 仅允许单章摘要作为重新构建输入 + +--- + +## 6. 关键策略 + +## 6.1 策略一:目录远跳后建立 Jump Session + +### 目标 + +目录从第 10 章跳到第 1000 章后,前台必须进入一个短期“跳转会话”,在此期间当前阅读窗口拥有最高优先级。 + +### 建议新增模型 + +```swift +struct RDEPUBJumpSession { + let anchorSpineIndex: Int + let createdAt: CFAbsoluteTime + let protectedSpineIndices: Set + let sequenceNumber: Int + let expiresAt: CFAbsoluteTime + let reason: Reason + + enum Reason { + case tableOfContentsJump + case bookmarkJump + case searchJump + } +} +``` + +### 行为规则 + +1. 目录跳转成功后创建 `JumpSession`。 +2. `protectedSpineIndices` 至少覆盖: + - 目标章节 + - 当前窗口前后相邻章节 +3. 在 `JumpSession` 结束前: + - 不允许任何不覆盖 `protectedSpineIndices` 的后台页图接管前台。 +4. 每次新的远距跳转都会替换旧的 `JumpSession`,不允许多个 Session 并存。 + +### 生命周期定义 + +`JumpSession` 结束条件建议精确定义为以下四类: + +1. `coverage-complete` + - 候选页图已完整覆盖 `protectedSpineIndices` + - 且当前页在新页图中可无损重定位 +2. `navigated-away` + - 用户连续沿同一方向翻页超过 `jumpSessionExitPageThreshold` + - 且当前 `spineIndex` 已不在 `protectedSpineIndices` +3. `timeout` + - 跳转后超过 `jumpSessionTimeout` + - 且最近 `idle` 持续时间大于 `jumpSessionIdleGracePeriod` +4. `superseded` + - 用户再次执行远距跳转 + - 新 Session 直接替换旧 Session + +默认建议值: + +- `jumpSessionExitPageThreshold = 6` +- `jumpSessionTimeout = 20s` +- `jumpSessionIdleGracePeriod = 1.5s` +- `protectedNeighborRadius = 1` + +说明: + +- “连续翻页”按同一方向的成功页变更次数计数,不按手势次数计数。 +- 若用户在保护区内前后来回翻页,则方向计数重置,Session 不结束。 +- 若用户离开保护区后又回到保护区,不恢复旧 Session,而是按当前阅读位置重新判断。 + +### 配置化建议 + +建议在 `RDEPUBReaderConfiguration` 中新增: + +```swift +struct RDEPUBJumpSessionPolicy { + let exitPageThreshold: Int + let timeout: TimeInterval + let idleGracePeriod: TimeInterval + let protectedNeighborRadius: Int +} +``` + +### 修改位置 + +- `RDEPUBReaderRuntime` +- `RDEPUBReaderLocationCoordinator` +- `RDEPUBReaderController+DataSource` + +--- + +## 6.2 策略二:后台补全改为“围绕当前阅读区段优先补洞” + +### 当前问题 + +现有 `paginateMetadataOnly(...)` 解析顺序本质上由: + +- `allBuildableIndices` +- `uncachedSpineIndices` + +决定,仍偏向全书静态顺序,而不是围绕当前阅读区段动态调整。 + +### 新策略 + +后台补全改为三段优先级: + +1. `hot zone` +2. `warm zone` +3. `cold zone` + +定义建议: + +- `hot zone`:当前章节附近,例如 `currentSpine ± hotRadius` +- `warm zone`:最近一次远距跳转区段,例如 `jumpAnchor ± warmRadius` +- `cold zone`:其余全书未补全章节 + +解析顺序改为: + +1. 先补 `hot zone` +2. 再补 `warm zone` +3. 最后扫 `cold zone` + +### Zone 参数来源与配置化 + +不建议把 `32`、`128` 写死。建议引入策略对象: + +```swift +struct RDEPUBBackgroundPriorityPolicy { + let hotRadius: Int + let warmRadius: Int + let maxWarmJumpAnchors: Int + let coldLaneShare: Double +} +``` + +推荐默认值: + +- 小书:`totalBuildableChapters < 300` + - `hotRadius = 12` + - `warmRadius = 32` +- 中书:`300...1200` + - `hotRadius = 24` + - `warmRadius = 96` +- 超大书:`> 1200` + - `hotRadius = 32` + - `warmRadius = 160` + +公式建议: + +- `hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48)` +- `warmRadius = min(max(hotRadius * 3, 32), 192)` + +说明: + +- `hotRadius` 关注当前连续翻页稳定性,应较小且聚焦。 +- `warmRadius` 关注远距跳转后的邻近补洞,可明显大于 `hotRadius`。 +- `coldLaneShare` 用于避免后台永远只补热区,建议默认 `0.15`。 + +### `coldLaneShare` 调度规则 + +`coldLaneShare` 建议按“任务数量比例”而不是“CPU 时间比例”实现,便于控制和验证。 + +建议规则: + +1. 每一轮调度按批次生成 work items。 +2. 设本轮总计划任务数为 `batchSize`,则: + - `coldCount = max(1, Int(round(Double(batchSize) * coldLaneShare)))` + - 剩余任务由 `hot/warm` 按优先级占用 +3. 冷区采用轮询推进: + - 维护 `coldCursor` + - 每轮从 `coldCursor` 开始扫描下一个未解析冷区章节 + - 批次结束后更新 `coldCursor` +4. 若当前没有可用 cold item: + - 额度让渡给 `warmSecondary` + - 不阻塞热区执行 + +这样可以保证: + +1. 热区始终优先 +2. 冷区不会永久饿死 +3. 调度行为可通过日志和测试稳定验证 + +### Zone 重叠与多次跳转规则 + +若多次跳转导致多个 warm zone 重叠,建议规则如下: + +1. `hot zone` 永远优先级最高。 +2. `warm zone` 只保留最近 `maxWarmJumpAnchors` 个跳转锚点,默认 `2`。 +3. 若多个 warm zone 重叠: + - 先按最近跳转时间排序 + - 再按与当前 `spineIndex` 距离排序 +4. 某章节若同时属于 `hot` 与 `warm`,只按 `hot` 处理一次。 + +### 修改建议 + +把: + +- `uncachedSpineIndices = allBuildableIndices.filter { ... }` + +改造成: + +```swift +let prioritizedSpineIndices = makeMetadataPriorityOrder( + allBuildableIndices: allBuildableIndices, + currentSpineIndex: currentVisibleSpineIndex, + warmJumpAnchors: recentJumpAnchors, + cachedSpineIndices: cachedSpineIndices, + policy: backgroundPriorityPolicy +) +``` + +新增方法建议: + +- `makeMetadataPriorityOrder(...)` +- `currentVisibleSpineIndexForBackgroundTasks()` + +--- + +## 6.3 策略三:多次快速跳转与后台任务重排 + +### 目标 + +解决用户在后台补全过程中快速多次跳转时的状态冲突问题。 + +### 行为规则 + +1. 只允许一个 `active JumpSession`。 +2. 每次新的远距跳转都会: + - 生成新的 `sequenceNumber` + - 替换旧 `JumpSession` + - 更新当前 `jumpAnchor` +3. 后台任务不要求“硬取消”已经进入单章构建中的工作单元,但要求: + - 未开始的任务必须支持重排 + - 已完成但低优先级的结果只进入 `backgroundCoverageStore`,不得直接接管前台 +4. `warm zone` 默认只保留最近 2 次远距跳转锚点: + - 最近一次为 `primary warm anchor` + - 上一次为 `secondary warm anchor` + - 更早锚点降级为 `cold` + +### 队列策略 + +建议将后台元数据任务拆为可重排 work item: + +```swift +struct RDEPUBMetadataParseWorkItem { + let spineIndex: Int + let generation: Int + let priorityBand: PriorityBand + + enum PriorityBand { + case hot + case warmPrimary + case warmSecondary + case cold + } +} +``` + +规则: + +1. 新跳转产生新 `generation`。 +2. 调度器优先消费当前 generation 的 `hot/warm`。 +3. 旧 generation 未执行的冷任务可直接丢弃并重建。 +4. 旧 generation 已完成的结果仍可入库,但只能作为缓存命中来源,不能直接提升为前台页图。 + +### 失败重试策略 + +后台单章元数据任务失败后,建议保留有限重试能力: + +1. 最大重试次数: + - `maxRetryCount = 3` +2. 重试间隔: + - 指数退避:`0.5s -> 2s -> 8s` +3. 重试优先级: + - `hot` 失败后保持在 `hot` + - `warm` 失败后保留原 band + - `cold` 失败后仍在 `cold` +4. 超过最大重试次数后: + - 标记为 `deferredFailure` + - 不阻断其他章节解析 + - 等用户再次进入相关区段时允许手动提升并重新尝试 + +--- + +## 6.4 策略四:页图接管从“整图替换”改为“分段升级” + +### 当前问题 + +当前前台页图升级主要依赖: + +- `refreshBookPageMapInPlace(_:)` +- `applyPendingFullPageMapIfNeeded()` + +这意味着升级动作仍偏向“整图替换”。 + +### 新策略 + +前台权威窗口只接受两类升级: + +1. 当前窗口连续扩张 +2. 当前窗口所在区段被更完整的覆盖段替换 + +不接受: + +1. 与当前窗口不连续的全局候选图直接替换 +2. 只覆盖当前章但不覆盖前后相邻章的页图接管 + +### 建议新增准入条件 + +对任意候选页图或 segment,接管前至少满足以下条件: + +1. 覆盖当前 `currentSpineIndex` +2. 覆盖 `currentSpineIndex - 1` 与 `currentSpineIndex + 1`,若这些章节是 buildable +3. 覆盖当前 `JumpSession.protectedSpineIndices`,若 Session 存在 +4. 当前页所在章节的本地页码边界与前台窗口一致,或可安全重定位 + +### 边界章节规则 + +对于第一页或最后一章,接管准入条件中的相邻章节检查需要收口: + +1. 若 `currentSpineIndex == 0` + - 只要求覆盖 `currentSpineIndex` 与 `currentSpineIndex + 1` +2. 若 `currentSpineIndex == lastBuildableSpineIndex` + - 只要求覆盖 `currentSpineIndex` 与 `currentSpineIndex - 1` +3. 若前后相邻章节存在但不是 buildable text spine + - 不将其纳入强制接管条件 +4. 若当前 `JumpSession.protectedSpineIndices` 本身已覆盖更严格范围 + - 以 `protectedSpineIndices` 为准 + +### 接管体验约束 + +1. 页图接管默认不做显式动画,避免视觉跳闪。 +2. 若接管导致当前页号变化但内容位置不变,不展示 toast。 +3. 若接管后当前页需要重定位,优先无动画 `transitionToPage`。 + +### 修改位置 + +- `RDEPUBReaderRuntime.applyPendingFullPageMapIfNeeded()` + +--- + +## 6.5 策略五:局部窗口扩展必须以当前窗口为基准 + +### 当前问题 + +目录跳转时虽然可以临时重建第 1000 章附近窗口,但后续扩窗仍可能受到旧状态影响。 + +### 新策略 + +扩窗必须以“当前权威窗口”作为唯一基准,规则如下: + +1. 若用户向后翻页,优先向后扩展窗口。 +2. 若用户向前翻页,优先向前扩展窗口。 +3. 扩窗时保留当前窗口已有章节,不重新回退到旧窗口。 + +### 建议实现 + +为 `bookPageMap` 增加窗口语义: + +```swift +struct RDEPUBActiveWindowDescriptor { + let lowerSpineIndex: Int + let upperSpineIndex: Int + let anchorSpineIndex: Int +} +``` + +在扩窗时: + +1. 根据当前方向选择新增章节集合。 +2. 只 append / prepend 缺失章节。 +3. 重建后的新 map 必须保留当前窗口已有顺序与绝对页号连续性。 + +--- + +## 6.6 策略六:后台解析结果与前台窗口彻底解耦 + +建议明确两类状态: + +1. `activeWindowPageMap` +2. `backgroundCoverageStore` + +其中: + +- `activeWindowPageMap` 只服务前台阅读 +- `backgroundCoverageStore` 只积累后台成果 + +前台永远不直接消费“后台刚生成的任意结果”,而是通过一个仲裁器判断是否可以吸收。 + +建议新增协调器: + +```swift +final class RDEPUBPageMapReconciliationCoordinator +``` + +职责: + +1. 判断某个后台 segment 是否可以并入当前窗口 +2. 判断完整页图是否满足接管条件 +3. 输出“保持当前窗口 / 扩窗 / 分段替换 / 全量替换”的决策 + +### Segment 合并策略 + +当两个 segment 重叠时: + +1. 若 `renderSignature` 相同: + - 对重叠章节优先保留更新时间更近的覆盖 +2. 若 `renderSignature` 不同: + - 不合并,视为不同版本,旧版本淘汰 +3. 若合并后章节数超过 `maxChaptersPerSegment`: + - 按当前阅读位置切成两个 segment + +--- + +## 6.7 策略七:Segment 内存管理与降级策略 + +### 当前风险 + +引入 `backgroundCoverageStore` 后,若没有明确内存策略,大书可能累积过多 segment。 + +### 建议约束 + +1. segment 数量上限: + - 默认 `maxResidentSegments = 8` +2. 单 segment 覆盖上限: + - 默认不超过 `256` 个章节 +3. 总覆盖内存预算: + - 默认 `8MB` 以内,仅针对页图与摘要聚合对象,不含单章 `NSAttributedString` + +### 淘汰策略 + +按以下顺序淘汰: + +1. 不覆盖 `activeWindow` +2. 不覆盖 `active JumpSession` +3. 最久未命中 +4. 与当前阅读位置距离最远 + +建议新增: + +```swift +struct RDEPUBBackgroundCoverageStorePolicy { + let maxResidentSegments: Int + let maxChaptersPerSegment: Int + let memoryBudgetBytes: Int +} +``` + +### 降级策略 + +在内存警告或预算超限时: + +1. 立即清空最远冷区 segment +2. 保留: + - `activeWindow` + - `JumpSession` 保护区 + - 最近一次 warm segment +3. 若仍超限: + - 停止创建新 segment + - 后台只继续写单章摘要缓存,不再持有内存聚合页图 + +### `estimatedMemoryBytes` 估算方式 + +`estimatedMemoryBytes` 不要求做到对象级精确统计,但必须有稳定近似公式,建议基于页图结构体规模估算: + +```swift +// 估算思路: +// bytes ≈ baseSegmentOverhead +// + entryCount * avgEntryCost +// + resolvedSpineCount * avgSetEntryCost +``` + +建议默认参数: + +- `baseSegmentOverhead = 256B` +- `avgEntryCost = 96B` +- `avgSetEntryCost = 16B` + +即: + +```swift +estimatedMemoryBytes = + 256 + + pageMap.entries.count * 96 + + resolvedSpineIndices.count * 16 +``` + +说明: + +1. 这里只估算 `pageMap` 与 segment 元数据,不包含单章 `NSAttributedString`。 +2. 该估算用于淘汰排序和预算闸门,不用于精确 profiling。 +3. 若后续 profiling 显示偏差明显,可统一调整常量,不影响外部接口。 + +### 与现有缓存的关系 + +磁盘层仍以单章摘要为准: + +- `RDEPUBChapterSummaryDiskCache` 不变 +- segment 只作为内存聚合态 +- 任何 segment 都必须可由单章摘要重新构建 + +--- + +## 7. 目标架构 + +```mermaid +flowchart LR + A["目录跳转 / 书签跳转 / 搜索跳转"] --> B["Jump Session"] + B --> C["Active Reading Window"] + C --> D["当前翻页 / 位置恢复 / 章节准备"] + + E["后台元数据解析"] --> F["Background Coverage Store"] + F --> G["PageMap Reconciliation Coordinator"] + C --> G + B --> G + G --> H["允许扩窗或分段升级"] + H --> C + + F --> I["完整全书页图"] + I --> G +``` + +这个架构下: + +- 当前阅读窗口始终是前台权威。 +- 后台解析只提供“可被采纳的覆盖能力”。 +- 页图接管由显式仲裁决定,而不是隐式替换。 + +--- + +## 8. 分阶段实施计划 + +建议分三期落地,避免一次性大改。 + +## Phase 1:稳定性优先 + +### 目标 + +先彻底消除“跳转对了,但后续翻页串章”。 + +### 范围 + +1. 引入 `JumpSession` +2. 将 `applyPendingFullPageMapIfNeeded()` 的接管条件升级为: + - 覆盖当前保护区 + - 覆盖当前相邻章节 +3. `extendPartialBookPageMapIfNeeded(...)` 只基于当前窗口连续扩张 + +### 不做 + +1. 不引入完整 segment store +2. 不修改磁盘缓存格式 + +### 验收 + +1. 从第 10 章跳到第 1000 章,连续翻 20 页不串章。 +2. 跳到未解析章节后,前后翻页都维持在目标区段。 +3. 后台完整解析完成前,当前阅读窗口不被旧区段页图覆盖。 +4. 快速连续跳转时,旧 Session 被新 Session 替换,后续翻页只跟随最新跳转区段。 + +## Phase 2:补全优先级重排 + +### 目标 + +让后台补全优先为当前阅读区段服务。 + +### 范围 + +1. `paginateMetadataOnly(...)` 引入 hot/warm/cold 优先级 +2. 支持阅读位置变化时动态重排后续任务 +3. 记录最近一次远距跳转锚点作为 warm zone + +### 验收 + +1. 目录跳到第 1000 章后,1000 附近章节在后台优先补齐。 +2. 继续翻页时,补全速度明显优于全书静态顺序。 +3. 多次快速跳转后,后台 work item 可以完成 generation 重排。 + +## Phase 3:分段覆盖与最终收敛 + +### 目标 + +把后台全书补全从“单一全局候选图”升级为“分段覆盖 + 最终全图收敛”。 + +### 范围 + +1. 新增 `backgroundCoverageStore` +2. 新增 `RDEPUBPageMapReconciliationCoordinator` +3. 引入 segment 级页图升级 + +### 验收 + +1. 前台窗口升级不再依赖整图替换。 +2. 大书多次远距跳转后,后台补全仍能稳定收敛。 +3. 内存警告下可安全降级到“仅保留当前窗口 + 单章摘要缓存”。 + +--- + +## 9. 需要修改的核心文件 + +优先级从高到低如下: + +1. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift` +2. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift` +3. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift` +4. `Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift` +5. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift` + +建议新增文件: + +1. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift` +2. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift` +3. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift` +4. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift` +5. `Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMetadataParseWorkItem.swift` + +--- + +## 10. 风险与规避 + +## 风险 1:状态模型变复杂 + +如果一次性同时引入 Jump Session、Segment Store、Reconciliation Coordinator,短期理解成本会上升。 + +规避: + +1. 先做 Phase 1 +2. Phase 2、3 再逐步抽离结构 + +## 风险 2:页码重定位抖动 + +如果后台页图接管条件不严,当前页可能发生视觉跳动。 + +规避: + +1. 只有覆盖当前保护区才允许接管 +2. 接管时先保存当前位置,再重新解析页码 +3. 若新页码与当前页偏差超过阈值,延迟接管 + +## 风险 3:后台优先级重排引发任务饥饿 + +如果一直只优先当前阅读区,远处章节可能长期不补全。 + +规避: + +1. 设置 hot/warm/cold 权重,而不是永久只做 hot +2. 每轮补全保留固定比例给 cold zone + +## 风险 4:缓存一致性 + +若 segment 覆盖和完整页图并存,缓存语义容易混淆。 + +规避: + +1. 磁盘缓存仍保持“单章摘要”不变 +2. segment 与 full map 只作为内存态聚合结果 + +## 风险 5:多次跳转导致状态抖动 + +若每次跳转都完全重建后台优先级,可能导致系统一直处于重排态。 + +规避: + +1. 只保留最近 2 次 warm anchor +2. 已进入执行的单章任务不强制中断 +3. 重排只影响未执行任务和接管优先级 + +## 风险 6:内存预算判断失真 + +若 segment 内存估算过粗,可能造成过晚淘汰。 + +规避: + +1. 对 segment 记录近似字节数 +2. 在内存警告时无条件执行强制降级 +3. 将单章 `NSAttributedString` 与 segment 聚合对象分开统计 + +--- + +## 11. 验收用例 + +至少补以下 UI 用例。 + +### Case 1:远距目录跳转后连续向后翻页 + +1. 首开大书进入第 10 章附近 +2. 目录跳到第 1000 章 +3. 连续向后翻 20 页 +4. 期望:章节持续落在 1000 章附近连续推进 + +### Case 2:远距目录跳转后先向前再向后翻页 + +1. 从第 10 章跳到第 1000 章 +2. 向前翻 5 页,再向后翻 10 页 +3. 期望:窗口连续,不回落到第 10 章区段 + +### Case 3:跳转后后台完整补全完成 + +1. 跳到远距章节 +2. 停留并等待后台补全完成 +3. 再继续翻页 +4. 期望:页图升级后位置连续,无跳章 + +### Case 4:多次远距跳转 + +1. 第 10 章跳到第 1000 章 +2. 再跳到第 300 章 +3. 再跳到第 1500 章 +4. 期望:每次都以新的阅读区段为权威窗口 + +### Case 5:跳转到第一章与最后一章 + +1. 从中间章节跳到第一章 +2. 再从第一章跳到最后一章 +3. 期望:边界章节不会因缺少相邻章节而错误结束 Session 或拒绝接管 + +### Case 6:快速连续跳转 + +1. 10 → 1000 → 300 → 1500,间隔小于 2 秒 +2. 期望: + - 只有最后一次跳转对应的 Session 生效 + - 后续翻页围绕 1500 章附近展开 + +### Case 7:后台任务失败或超时 + +1. 人为制造部分章节 build 失败 +2. 期望: + - 前台当前窗口仍可继续翻页 + - 失败章节保留重试机会 + - 不因为单章失败导致全局页图回退 + +### Case 8:内存告警 + +1. 在大书多次跳转后触发内存警告 +2. 期望: + - 冷区 segment 被淘汰 + - 当前窗口与 JumpSession 保护区保留 + - 后续仍可继续阅读 + +### Case 9:保护区内来回翻页 + +1. 跳到第 1000 章后,在保护区内前后翻页 20 次 +2. 期望: + - Jump Session 不因方向切换误结束 + - 页图不被不完整候选图接管 + +### Case 10:热区补全时效 + +1. 跳转到远距章节 +2. 记录 `hot zone` 章节覆盖时间 +3. 期望: + - 80% 覆盖时间满足性能阈值 + +--- + +## 12. 补充实现细节 + +## 12.1 `makeMetadataPriorityOrder(...)` 排序算法 + +建议排序键按以下顺序构造: + +1. `priorityBand` + - `hot` + - `warmPrimary` + - `warmSecondary` + - `cold` +2. `distanceToCurrentSpine` +3. `distanceToNewestJumpAnchor` +4. `spineIndex` + +可表达为: + +```swift +items.sorted { + ($0.bandRank, $0.distanceToCurrent, $0.distanceToNewestJump, $0.spineIndex) < + ($1.bandRank, $1.distanceToCurrent, $1.distanceToNewestJump, $1.spineIndex) +} +``` + +`cold` 区内部建议轮询式推进,而不是每次都从书头开始。 + +## 12.2 Segment 合并策略 + +当两个 segment 重叠时: + +1. 若 `renderSignature` 相同: + - 对重叠章节优先保留更新时间更近的覆盖 +2. 若 `renderSignature` 不同: + - 不合并,视为不同版本,旧版本淘汰 +3. 若合并后章节数超过 `maxChaptersPerSegment`: + - 按当前阅读位置切成两个 segment + +## 12.3 用户感知体验 + +1. 页图接管默认不做显式动画,避免“页码重新解释”造成视觉跳闪。 +2. 若后台补全正在推进,可选地暴露调试态指标,不建议默认面向用户展示进度 UI。 +3. 若发生降级或后台重排,不打断前台阅读。 + +--- + +## 13. 推荐实施结论 + +建议按以下顺序执行: + +1. 先做 Phase 1,稳定远距跳转后的连续翻页。 +2. 再做 Phase 2,让后台补全顺序围绕当前阅读区段优先推进。 +3. 最后做 Phase 3,把单一 `pendingFullPageMap` 演进成“分段覆盖 + 最终全图收敛”。 + +最关键的设计原则只有一句话: + +**前台当前阅读窗口必须始终拥有解释权,后台补全只能在安全条件满足后逐步接管,不能反向覆盖当前阅读语义。** diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift index d8c113e..2408b47 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+ContentDelegates.swift @@ -101,9 +101,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { func textContentView( _ contentView: RDEPUBTextContentView, didActivateAttachmentText text: String, - sourceRect: CGRect + sourceRect: CGRect, + sourcePoint: CGPoint ) { - presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect) + presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint) } func textContentView( @@ -339,7 +340,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { present(alert, animated: true) } - private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect) { + private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) { hideAttachmentTooltipIfNeeded() let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds) @@ -351,23 +352,40 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate { let tooltip = RDEPUBAttachmentTooltipView() tooltip.alpha = 0 - tooltip.configure(text: text, maxWidth: min(view.bounds.width - 48, 320)) + let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12 + tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320)) let anchorRect = sourceView.convert(sourceRect, to: view) - let horizontalPadding: CGFloat = 24 + let rawAnchorPoint = sourceView.convert(sourcePoint, to: view) + let anchorPoint = CGPoint( + x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX), + y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY) + ) let verticalSpacing: CGFloat = 6 let tooltipSize = tooltip.frame.size - let idealX = anchorRect.midX - tooltipSize.width / 2 + let idealX = anchorPoint.x - tooltipSize.width / 2 let minX = horizontalPadding let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width) let originX = min(max(idealX, minX), maxX) - let originY = max(view.safeAreaInsets.top + 12, anchorRect.minY - tooltipSize.height - verticalSpacing) + let topSafeY = view.safeAreaInsets.top + 12 + let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12 + let availableSpaceAbove = anchorRect.minY - topSafeY + let availableSpaceBelow = bottomSafeY - anchorRect.maxY + let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow + let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top + let originY: CGFloat + switch tooltipPlacement { + case .bottom: + originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing) + case .top: + originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing) + } let arrowTipX = min( - max(anchorRect.midX - originX, tooltip.minimumArrowX), + max(anchorPoint.x - originX, tooltip.minimumArrowX), tooltipSize.width - tooltip.minimumArrowX ) - tooltip.setArrowTipX(arrowTipX) + tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement) tooltip.frame.origin = CGPoint(x: originX, y: originY) overlay.addSubview(tooltip) view.addSubview(overlay) @@ -431,11 +449,17 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView { } private final class RDEPUBAttachmentTooltipView: UIView { + enum ArrowPlacement { + case top + case bottom + } + private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20) private let arrowSize = CGSize(width: 20, height: 10) private let cornerRadius: CGFloat = 18 private(set) var minimumArrowX: CGFloat = 28 private var arrowTipX: CGFloat? + private var arrowPlacement: ArrowPlacement = .bottom private let textLabel: UILabel = { let label = UILabel() label.numberOfLines = 0 @@ -465,17 +489,20 @@ private final class RDEPUBAttachmentTooltipView: UIView { shapeLayer.path = bubblePath(in: bounds).cgPath shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor + let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0) + let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0) let labelFrame = bounds.inset(by: UIEdgeInsets( - top: contentInsets.top, + top: topInset, left: contentInsets.left, - bottom: contentInsets.bottom + arrowSize.height, + bottom: bottomInset, right: contentInsets.right )) textLabel.frame = labelFrame } - func setArrowTipX(_ value: CGFloat) { + func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) { arrowTipX = value + arrowPlacement = placement setNeedsLayout() } @@ -492,12 +519,23 @@ private final class RDEPUBAttachmentTooltipView: UIView { } private func bubblePath(in rect: CGRect) -> UIBezierPath { - let bubbleRect = CGRect( - x: rect.minX, - y: rect.minY, - width: rect.width, - height: rect.height - arrowSize.height - ) + let bubbleRect: CGRect + switch arrowPlacement { + case .bottom: + bubbleRect = CGRect( + x: rect.minX, + y: rect.minY, + width: rect.width, + height: rect.height - arrowSize.height + ) + case .top: + bubbleRect = CGRect( + x: rect.minX, + y: rect.minY + arrowSize.height, + width: rect.width, + height: rect.height - arrowSize.height + ) + } let arrowMidX = min( max(arrowTipX ?? bubbleRect.midX, minimumArrowX), bubbleRect.width - minimumArrowX @@ -505,42 +543,82 @@ private final class RDEPUBAttachmentTooltipView: UIView { let arrowHalfWidth = arrowSize.width / 2 let path = UIBezierPath() - path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) - path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), - radius: cornerRadius, - startAngle: -.pi / 2, - endAngle: 0, - clockwise: true - ) - path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), - radius: cornerRadius, - startAngle: 0, - endAngle: .pi / 2, - clockwise: true - ) - path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY)) - path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height)) - path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY)) - path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), - radius: cornerRadius, - startAngle: .pi / 2, - endAngle: .pi, - clockwise: true - ) - path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) - path.addArc( - withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), - radius: cornerRadius, - startAngle: .pi, - endAngle: -.pi / 2, - clockwise: true - ) + switch arrowPlacement { + case .bottom: + path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) + path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), + radius: cornerRadius, + startAngle: -.pi / 2, + endAngle: 0, + clockwise: true + ) + path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), + radius: cornerRadius, + startAngle: 0, + endAngle: .pi / 2, + clockwise: true + ) + path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY)) + path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height)) + path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY)) + path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), + radius: cornerRadius, + startAngle: .pi / 2, + endAngle: .pi, + clockwise: true + ) + path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), + radius: cornerRadius, + startAngle: .pi, + endAngle: -.pi / 2, + clockwise: true + ) + case .top: + path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) + path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY)) + path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height)) + path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY)) + path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), + radius: cornerRadius, + startAngle: -.pi / 2, + endAngle: 0, + clockwise: true + ) + path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), + radius: cornerRadius, + startAngle: 0, + endAngle: .pi / 2, + clockwise: true + ) + path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), + radius: cornerRadius, + startAngle: .pi / 2, + endAngle: .pi, + clockwise: true + ) + path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) + path.addArc( + withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), + radius: cornerRadius, + startAngle: .pi, + endAngle: -.pi / 2, + clockwise: true + ) + } path.close() return path } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift index 6c3faf8..112898b 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+DataSource.swift @@ -296,31 +296,41 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate { reconcileTextPaginationSizeIfNeeded(for: pageNum) // 用户开始导航时,应用后台解析完成的完整 map + let previousCurrentPage = readerView.currentPage runtime.applyPendingFullPageMapIfNeeded() + let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum + if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum { + return + } if readerContext.bookPageMap != nil { - _ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1) - runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1) + _ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: effectivePageNum + 1) + runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1) } let totalPages = pageCountOfReaderView(readerView: readerView) - if totalPages > 0, pageNum == totalPages - 1 { + if totalPages > 0, effectivePageNum == totalPages - 1 { delegate?.epubReaderDidReachEnd(self) } if (textBook != nil || readerContext.bookPageMap != nil), - let location = resolvedTextLocation(forPageNumber: pageNum + 1) { + let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) { persist(location: location) - synchronizeTextReadingState(pageNumber: pageNum + 1, location: location) + synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location) + // 记录翻页到 JumpSession + runtime.locationCoordinator.recordPageChangeIfNeeded() return } - if let location = fallbackLocation(for: pageNum) { + if let location = fallbackLocation(for: effectivePageNum) { persist(location: location) } if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving { readingSession?.transition(to: .idle) } + + // 记录翻页到 JumpSession + runtime.locationCoordinator.recordPageChangeIfNeeded() } /// 屏幕方向即将变化回调,捕获待恢复的展示位置 diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift new file mode 100644 index 0000000..3413825 --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundCoverageStore.swift @@ -0,0 +1,307 @@ +import Foundation + +/// 后台覆盖段:表示某一段章节范围的稳定页码覆盖 +struct RDEPUBBackgroundCoverageSegment { + /// 段的下界 spineIndex + let lowerSpineIndex: Int + /// 段的上界 spineIndex + let upperSpineIndex: Int + /// 页图数据 + let pageMap: RDEPUBBookPageMap + /// 已解析的 spineIndex 集合 + let resolvedSpineIndices: Set + /// 创建时间 + let generatedAt: CFAbsoluteTime + /// 渲染签名 + let renderSignature: String + /// 预估内存占用(字节) + let estimatedMemoryBytes: Int + + /// 是否包含指定 spineIndex + func contains(spineIndex: Int) -> Bool { + spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex + } + + /// 与指定 spineIndex 的距离 + func distance(to spineIndex: Int) -> Int { + if contains(spineIndex: spineIndex) { return 0 } + return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex)) + } +} + +/// 覆盖存储策略 +struct RDEPUBBackgroundCoverageStorePolicy { + /// 最大常驻段数 + let maxResidentSegments: Int + /// 单段最大章节数 + let maxChaptersPerSegment: Int + /// 内存预算(字节) + let memoryBudgetBytes: Int + + /// 默认策略 + static let `default` = RDEPUBBackgroundCoverageStorePolicy( + maxResidentSegments: 8, + maxChaptersPerSegment: 256, + memoryBudgetBytes: 8 * 1024 * 1024 // 8MB + ) +} + +/// 后台覆盖存储管理器 +final class RDEPUBBackgroundCoverageStore { + private unowned let context: RDEPUBReaderContext + + /// 存储策略 + private let policy: RDEPUBBackgroundCoverageStorePolicy + + /// 存储的段列表 + private var segments: [RDEPUBBackgroundCoverageSegment] = [] + + /// 当前总内存占用 + private var currentMemoryBytes: Int = 0 + + /// 最后访问时间(用于 LRU 淘汰) + private var lastAccessTime: [Int: CFAbsoluteTime] = [:] + + init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) { + self.context = context + self.policy = policy + } + + /// 添加段 + func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) { + // 检查是否需要淘汰 + evictIfNeeded(forNewSegment: segment) + + // 检查是否与现有段重叠,合并或替换 + var merged = false + for (index, existing) in segments.enumerated() { + if canMerge(existing, segment) { + if let mergedSegment = mergeSegments(existing, segment) { + segments[index] = mergedSegment + currentMemoryBytes = currentMemoryBytes - existing.estimatedMemoryBytes + mergedSegment.estimatedMemoryBytes + merged = true + RDEPUBBackgroundTrace.log( + "CoverageStore", + "merged segments: \(existing.lowerSpineIndex)-\(existing.upperSpineIndex) + \(segment.lowerSpineIndex)-\(segment.upperSpineIndex)" + ) + break + } + } + } + + if !merged { + segments.append(segment) + currentMemoryBytes += segment.estimatedMemoryBytes + } + + // 更新访问时间 + lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent() + + RDEPUBBackgroundTrace.log( + "CoverageStore", + "added segment \(segment.lowerSpineIndex)-\(segment.upperSpineIndex) total=\(segments.count) memory=\(currentMemoryBytes)B" + ) + } + + /// 查找覆盖指定 spineIndex 的段 + func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? { + let segment = segments.first { $0.contains(spineIndex: spineIndex) } + if let segment { + lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent() + } + return segment + } + + /// 查找覆盖指定 spineIndex 集合的段 + func findSegment(covering spineIndices: Set) -> RDEPUBBackgroundCoverageSegment? { + let segment = segments.first { segment in + spineIndices.allSatisfy { segment.contains(spineIndex: $0) } + } + if let segment { + lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent() + } + return segment + } + + /// 获取所有段 + func allSegments() -> [RDEPUBBackgroundCoverageSegment] { + segments + } + + /// 清除所有段 + func clearAll() { + segments.removeAll() + currentMemoryBytes = 0 + lastAccessTime.removeAll() + } + + /// 清除冷区段(不覆盖当前阅读位置和保护区的段) + func clearColdSegments( + activeWindowSpineIndices: Set, + protectedSpineIndices: Set + ) { + let keepIndices = activeWindowSpineIndices.union(protectedSpineIndices) + segments.removeAll { segment in + let isCold = !segment.resolvedSpineIndices.contains(where: { keepIndices.contains($0) }) + if isCold { + currentMemoryBytes -= segment.estimatedMemoryBytes + lastAccessTime.removeValue(forKey: segment.lowerSpineIndex) + } + return isCold + } + } + + /// 处理内存警告 + func handleMemoryWarning( + activeWindowSpineIndices: Set, + protectedSpineIndices: Set + ) { + RDEPUBBackgroundTrace.log( + "CoverageStore", + "memory warning: clearing cold segments, current=\(currentMemoryBytes)B" + ) + + // 第一步:清除冷区段 + clearColdSegments( + activeWindowSpineIndices: activeWindowSpineIndices, + protectedSpineIndices: protectedSpineIndices + ) + + // 如果仍然超限,清除更多段 + if currentMemoryBytes > policy.memoryBudgetBytes { + // 按距离排序,清除最远的段 + let sorted = segments.sorted { lhs, rhs in + let lhsDistance = lhs.resolvedSpineIndices.map { idx in + activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max + }.min() ?? Int.max + let rhsDistance = rhs.resolvedSpineIndices.map { idx in + activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max + }.min() ?? Int.max + return lhsDistance > rhsDistance + } + + for segment in sorted { + if currentMemoryBytes <= policy.memoryBudgetBytes { break } + currentMemoryBytes -= segment.estimatedMemoryBytes + lastAccessTime.removeValue(forKey: segment.lowerSpineIndex) + segments.removeAll { $0.lowerSpineIndex == segment.lowerSpineIndex } + } + } + + RDEPUBBackgroundTrace.log( + "CoverageStore", + "after cleanup: segments=\(segments.count) memory=\(currentMemoryBytes)B" + ) + } + + /// 检查是否需要淘汰 + private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) { + // 检查段数限制 + while segments.count >= policy.maxResidentSegments { + evictLeastRecentlyUsed() + } + + // 检查内存限制 + while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes { + evictLeastRecentlyUsed() + } + } + + /// 淘汰最近最少使用的段 + private func evictLeastRecentlyUsed() { + guard !segments.isEmpty else { return } + + // 找到最久未访问的段 + var oldestTime = CFAbsoluteTimeGetCurrent() + var oldestIndex = 0 + for (index, segment) in segments.enumerated() { + let accessTime = lastAccessTime[segment.lowerSpineIndex] ?? 0 + if accessTime < oldestTime { + oldestTime = accessTime + oldestIndex = index + } + } + + let evicted = segments.remove(at: oldestIndex) + currentMemoryBytes -= evicted.estimatedMemoryBytes + lastAccessTime.removeValue(forKey: evicted.lowerSpineIndex) + + RDEPUBBackgroundTrace.log( + "CoverageStore", + "evicted segment \(evicted.lowerSpineIndex)-\(evicted.upperSpineIndex)" + ) + } + + /// 检查两个段是否可以合并 + private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool { + // 渲染签名必须相同 + guard lhs.renderSignature == rhs.renderSignature else { return false } + + // 检查是否重叠或相邻 + let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 && + rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1 + return overlap + } + + /// 合并两个段 + private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? { + let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex) + let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex) + let newChapterCount = newUpper - newLower + 1 + + // 检查合并后是否超过单段最大章节数 + if newChapterCount > policy.maxChaptersPerSegment { + // 按当前阅读位置切分 + return nil + } + + // 合并 resolvedSpineIndices + let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices) + + // 合并页图 + let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs + let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs + let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap) + + return RDEPUBBackgroundCoverageSegment( + lowerSpineIndex: newLower, + upperSpineIndex: newUpper, + pageMap: newPageMap, + resolvedSpineIndices: newResolved, + generatedAt: max(lhs.generatedAt, rhs.generatedAt), + renderSignature: lhs.renderSignature, + estimatedMemoryBytes: estimateMemoryBytes(pageMap: newPageMap, resolvedCount: newResolved.count) + ) + } + + /// 合并两个页图 + private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap { + var builder = RDEPUBBookPageMap.Builder() + var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:] + + for entry in older.entries { + entriesBySpineIndex[entry.spineIndex] = entry + } + for entry in newer.entries { + entriesBySpineIndex[entry.spineIndex] = entry + } + + for spineIndex in entriesBySpineIndex.keys.sorted() { + guard let entry = entriesBySpineIndex[spineIndex] else { continue } + builder.add( + spineIndex: entry.spineIndex, + href: entry.href, + title: entry.title, + pageCount: entry.pageCount, + fragmentOffsets: entry.fragmentOffsets + ) + } + + return builder.build() + } + + /// 估算内存占用 + private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int { + 256 + pageMap.entries.count * 96 + resolvedCount * 16 + } +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift new file mode 100644 index 0000000..208d74b --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBBackgroundPriorityPolicy.swift @@ -0,0 +1,193 @@ +import Foundation + +/// 后台补全优先级策略 +/// +/// 控制后台元数据解析的优先级排序,确保当前阅读区段优先补全 +struct RDEPUBBackgroundPriorityPolicy { + /// 热区半径:当前章节附近的范围 + let hotRadius: Int + /// 温区半径:远距跳转锚点附近的范围 + let warmRadius: Int + /// 最大温区跳转锚点数量 + let maxWarmJumpAnchors: Int + /// 冷区份额:每轮补全中冷区任务的比例 + let coldLaneShare: Double + + /// 默认策略 + static let `default` = RDEPUBBackgroundPriorityPolicy( + hotRadius: 24, + warmRadius: 96, + maxWarmJumpAnchors: 2, + coldLaneShare: 0.15 + ) + + /// 根据总章节数动态计算策略 + static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy { + let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48) + let warmRadius = min(max(hotRadius * 3, 32), 192) + return RDEPUBBackgroundPriorityPolicy( + hotRadius: hotRadius, + warmRadius: warmRadius, + maxWarmJumpAnchors: 2, + coldLaneShare: 0.15 + ) + } +} + +/// 优先级带 +enum RDEPUBPriorityBand: Int, Comparable { + case hot = 0 + case warmPrimary = 1 + case warmSecondary = 2 + case cold = 3 + + static func < (lhs: RDEPUBPriorityBand, rhs: RDEPUBPriorityBand) -> Bool { + lhs.rawValue < rhs.rawValue + } +} + +/// 温区跳转锚点 +struct RDEPUBWarmJumpAnchor { + let spineIndex: Int + let timestamp: CFAbsoluteTime + let sequenceNumber: Int +} + +/// 元数据解析工作项 +struct RDEPUBMetadataParseWorkItem { + let spineIndex: Int + let generation: Int + let priorityBand: RDEPUBPriorityBand + + /// 排序键 + var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) { + (priorityBand.rawValue, 0, 0, spineIndex) + } +} + +/// 后台补全优先级管理器 +final class RDEPUBBackgroundPriorityManager { + private unowned let context: RDEPUBReaderContext + + /// 当前策略 + private(set) var policy: RDEPUBBackgroundPriorityPolicy + + /// 温区跳转锚点列表(按时间倒序) + private var warmAnchors: [RDEPUBWarmJumpAnchor] = [] + + /// 当前 generation + private(set) var currentGeneration: Int = 0 + + /// 冷区游标 + private var coldCursor: Int = 0 + + init(context: RDEPUBReaderContext) { + self.context = context + self.policy = .default + } + + /// 更新策略 + func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) { + policy = newPolicy + } + + /// 添加温区跳转锚点 + func addWarmAnchor(spineIndex: Int) { + let anchor = RDEPUBWarmJumpAnchor( + spineIndex: spineIndex, + timestamp: CFAbsoluteTimeGetCurrent(), + sequenceNumber: currentGeneration + ) + + warmAnchors.insert(anchor, at: 0) + + // 保留最近 N 个锚点 + if warmAnchors.count > policy.maxWarmJumpAnchors { + warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors)) + } + + currentGeneration += 1 + coldCursor = 0 // 重置冷区游标 + + RDEPUBBackgroundTrace.log( + "PriorityManager", + "addWarmAnchor spine=\(spineIndex) generation=\(currentGeneration) anchors=\(warmAnchors.count)" + ) + } + + /// 生成优先级排序的 spineIndex 列表 + func makeMetadataPriorityOrder( + allBuildableIndices: [Int], + currentSpineIndex: Int?, + cachedSpineIndices: Set + ) -> [Int] { + let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } + guard !uncachedIndices.isEmpty else { return [] } + + // 为每个 spineIndex 计算优先级带 + let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in + let band = classifySpineIndex( + spineIndex: spineIndex, + currentSpineIndex: currentSpineIndex + ) + return (spineIndex, band) + } + + // 排序 + let sorted = items.sorted { lhs, rhs in + // 先按优先级带排序 + if lhs.band != rhs.band { + return lhs.band < rhs.band + } + + // 同一带内按距离排序 + let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max + let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max + if lhsDistanceToCurrent != rhsDistanceToCurrent { + return lhsDistanceToCurrent < rhsDistanceToCurrent + } + + // 距离相同时按 spineIndex 排序 + return lhs.spineIndex < rhs.spineIndex + } + + return sorted.map { $0.spineIndex } + } + + /// 分类 spineIndex 到优先级带 + private func classifySpineIndex( + spineIndex: Int, + currentSpineIndex: Int? + ) -> RDEPUBPriorityBand { + // 检查是否在热区 + if let current = currentSpineIndex { + let distance = abs(spineIndex - current) + if distance <= policy.hotRadius { + return .hot + } + } + + // 检查是否在温区 + for (index, anchor) in warmAnchors.enumerated() { + let distance = abs(spineIndex - anchor.spineIndex) + if distance <= policy.warmRadius { + return index == 0 ? .warmPrimary : .warmSecondary + } + } + + // 冷区 + return .cold + } + + /// 获取当前温区锚点(用于后台任务调度) + func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] { + warmAnchors + } + + /// 重置状态 + func reset() { + warmAnchors.removeAll() + currentGeneration = 0 + coldCursor = 0 + } +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift new file mode 100644 index 0000000..0f347ab --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift @@ -0,0 +1,233 @@ +import Foundation + +/// 远距跳转会话:保护前台阅读窗口不被后台页图覆盖。 +/// +/// 当用户执行远距跳转(目录、书签、搜索)后创建,在保护期内: +/// - 不允许任何不覆盖保护区的后台页图接管前台 +/// - 后台补全围绕当前阅读区段优先 +struct RDEPUBJumpSession { + /// 跳转目标的 spineIndex + let anchorSpineIndex: Int + /// 创建时间 + let createdAt: CFAbsoluteTime + /// 受保护的 spineIndex 集合 + let protectedSpineIndices: Set + /// 序列号,用于区分多次跳转 + let sequenceNumber: Int + /// 过期时间 + let expiresAt: CFAbsoluteTime + /// 跳转原因 + let reason: Reason + + /// 跳转原因枚举 + enum Reason { + case tableOfContentsJump + case bookmarkJump + case searchJump + } + + /// 结束条件枚举 + enum EndReason { + /// 候选页图已完整覆盖保护区 + case coverageComplete + /// 用户连续翻页离开保护区 + case navigatedAway + /// 超时 + case timeout + /// 被新的跳转取代 + case superseded + } +} + +/// JumpSession 策略配置 +public struct RDEPUBJumpSessionPolicy: Equatable { + /// 连续翻页离开保护区的阈值 + public let exitPageThreshold: Int + /// 跳转超时时间 + public let timeout: TimeInterval + /// 空闲宽限期 + public let idleGracePeriod: TimeInterval + /// 保护区相邻章节半径 + public let protectedNeighborRadius: Int + + /// 默认策略 + public static let `default` = RDEPUBJumpSessionPolicy( + exitPageThreshold: 6, + timeout: 20, + idleGracePeriod: 1.5, + protectedNeighborRadius: 1 + ) + + public init( + exitPageThreshold: Int = 6, + timeout: TimeInterval = 20, + idleGracePeriod: TimeInterval = 1.5, + protectedNeighborRadius: Int = 1 + ) { + self.exitPageThreshold = exitPageThreshold + self.timeout = timeout + self.idleGracePeriod = idleGracePeriod + self.protectedNeighborRadius = protectedNeighborRadius + } +} + +/// JumpSession 管理器 +final class RDEPUBJumpSessionManager { + private unowned let context: RDEPUBReaderContext + + /// 当前活跃的 JumpSession + private(set) var activeSession: RDEPUBJumpSession? + + /// 全局序列号 + private var nextSequenceNumber: Int = 0 + + /// 连续翻页计数器 + private var consecutivePageCount: Int = 0 + + /// 上次翻页方向 + private var lastPageDirection: PageDirection? + + /// 上次用户活动时间 + private var lastActivityTime: CFAbsoluteTime = 0 + + /// 页面方向 + enum PageDirection { + case forward + case backward + } + + init(context: RDEPUBReaderContext) { + self.context = context + } + + /// 创建新的 JumpSession + @discardableResult + func createSession( + anchorSpineIndex: Int, + reason: RDEPUBJumpSession.Reason, + totalSpineCount: Int + ) -> RDEPUBJumpSession { + let policy = context.configuration.jumpSessionPolicy + let now = CFAbsoluteTimeGetCurrent() + + // 计算保护区 + var protectedIndices: Set = [anchorSpineIndex] + for offset in 1...policy.protectedNeighborRadius { + let lower = anchorSpineIndex - offset + let upper = anchorSpineIndex + offset + if lower >= 0 { + protectedIndices.insert(lower) + } + if upper < totalSpineCount { + protectedIndices.insert(upper) + } + } + + nextSequenceNumber += 1 + let session = RDEPUBJumpSession( + anchorSpineIndex: anchorSpineIndex, + createdAt: now, + protectedSpineIndices: protectedIndices, + sequenceNumber: nextSequenceNumber, + expiresAt: now + policy.timeout, + reason: reason + ) + + activeSession = session + consecutivePageCount = 0 + lastPageDirection = nil + lastActivityTime = now + + RDEPUBBackgroundTrace.log( + "JumpSession", + "created anchor=\(anchorSpineIndex) protected=\(protectedIndices.count) seq=\(nextSequenceNumber)" + ) + + return session + } + + /// 记录用户翻页 + func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) { + guard activeSession != nil else { return } + + let direction: PageDirection = toSpineIndex >= fromSpineIndex ? .forward : .backward + lastActivityTime = CFAbsoluteTimeGetCurrent() + + if direction == lastPageDirection { + consecutivePageCount += 1 + } else { + consecutivePageCount = 1 + lastPageDirection = direction + } + } + + /// 检查是否允许页图接管 + func shouldAllowPageMapTakeover(candidateSpineIndices: Set) -> Bool { + guard let session = activeSession else { + return true // 没有活跃 Session,允许接管 + } + + // 检查候选页图是否覆盖保护区 + let protectedIndices = session.protectedSpineIndices + let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) / + Double(protectedIndices.count) + + // 必须覆盖至少 80% 的保护区 + return coverageRatio >= 0.8 + } + + /// 检查是否应该结束 Session + func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? { + guard let session = activeSession else { return nil } + + let now = CFAbsoluteTimeGetCurrent() + let policy = context.configuration.jumpSessionPolicy + + // 1. 检查超时 + if now >= session.expiresAt { + if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod { + RDEPUBBackgroundTrace.log( + "JumpSession", + "end: timeout seq=\(session.sequenceNumber)" + ) + return .timeout + } + } + + // 2. 检查是否离开保护区 + if !session.protectedSpineIndices.contains(currentSpineIndex) { + if consecutivePageCount >= policy.exitPageThreshold { + RDEPUBBackgroundTrace.log( + "JumpSession", + "end: navigated-away seq=\(session.sequenceNumber) pages=\(consecutivePageCount)" + ) + return .navigatedAway + } + } else { + // 在保护区内,重置连续翻页计数 + consecutivePageCount = 0 + } + + return nil + } + + /// 结束当前 Session + func endSession(_ reason: RDEPUBJumpSession.EndReason) { + guard let session = activeSession else { return } + RDEPUBBackgroundTrace.log( + "JumpSession", + "ended seq=\(session.sequenceNumber) reason=\(reason)" + ) + activeSession = nil + consecutivePageCount = 0 + lastPageDirection = nil + } + + /// 清除 Session(用于重新加载等场景) + func clearSession() { + activeSession = nil + consecutivePageCount = 0 + lastPageDirection = nil + nextSequenceNumber = 0 + } +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift new file mode 100644 index 0000000..c77f0ff --- /dev/null +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPageMapReconciliationCoordinator.swift @@ -0,0 +1,228 @@ +import Foundation + +/// 页图接管决策 +enum RDEPUBPageMapTakeoverDecision { + /// 保持当前窗口不变 + case keepCurrentWindow + /// 扩窗:将候选段合并到当前窗口 + case expandWindow(RDEPUBBackgroundCoverageSegment) + /// 分段替换:用候选段替换当前窗口的部分内容 + case segmentReplace(RDEPUBBackgroundCoverageSegment) + /// 全量替换:用完整页图替换当前窗口 + case fullReplace(RDEPUBBookPageMap) +} + +/// 页图协调器:负责判断后台解析结果是否可以接管前台窗口 +final class RDEPUBPageMapReconciliationCoordinator { + private unowned let context: RDEPUBReaderContext + + init(context: RDEPUBReaderContext) { + self.context = context + } + + /// 判断是否可以接管前台窗口 + func evaluateTakeover( + candidatePageMap: RDEPUBBookPageMap?, + candidateSegment: RDEPUBBackgroundCoverageSegment?, + currentWindow: RDEPUBBookPageMap?, + jumpSession: RDEPUBJumpSession? + ) -> RDEPUBPageMapTakeoverDecision { + // 如果没有当前窗口,允许接管 + guard let currentWindow else { + if let candidatePageMap { + return .fullReplace(candidatePageMap) + } + return .keepCurrentWindow + } + + // 获取当前阅读位置 + let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() + .flatMap { context.normalizedSpineIndex(for: $0) } + + let lastBuildableSpineIndex = context.publication?.spine.indices + .reversed() + .first(where: { index in + guard let item = context.publication?.spine[index] else { return false } + return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) + }) ?? 0 + + // 检查 JumpSession 保护 + if let jumpSession { + let protectedIndices = jumpSession.protectedSpineIndices + if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) { + // 在保护区内,检查候选是否覆盖保护区 + if let candidateSegment { + let candidateIndices = candidateSegment.resolvedSpineIndices + let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) / + Double(protectedIndices.count) + if coverageRatio < 0.8 { + RDEPUBBackgroundTrace.log( + "Reconciliation", + "blocked: candidate doesn't cover protected area (coverage=\(coverageRatio))" + ) + return .keepCurrentWindow + } + } + } + } + + // 检查边界章节覆盖 + if let currentSpineIndex { + let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex + + if requiresAdjacentCoverage { + if let candidateSegment { + let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1) + let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1) + if !hasPrev || !hasNext { + RDEPUBBackgroundTrace.log( + "Reconciliation", + "blocked: missing adjacent chapter coverage" + ) + return .keepCurrentWindow + } + } + } + } + + // 检查渲染签名一致性 + if let candidateSegment { + let currentRenderSignature = context.currentRenderSignature() + if candidateSegment.renderSignature != currentRenderSignature { + RDEPUBBackgroundTrace.log( + "Reconciliation", + "blocked: render signature mismatch" + ) + return .keepCurrentWindow + } + } + + // 评估接管类型 + if let candidateSegment { + return evaluateSegmentTakeover( + candidateSegment: candidateSegment, + currentWindow: currentWindow, + currentSpineIndex: currentSpineIndex, + lastBuildableSpineIndex: lastBuildableSpineIndex + ) + } + + if let candidatePageMap { + return evaluateFullPageMapTakeover( + candidatePageMap: candidatePageMap, + currentWindow: currentWindow, + currentSpineIndex: currentSpineIndex, + lastBuildableSpineIndex: lastBuildableSpineIndex + ) + } + + return .keepCurrentWindow + } + + /// 评估分段接管 + private func evaluateSegmentTakeover( + candidateSegment: RDEPUBBackgroundCoverageSegment, + currentWindow: RDEPUBBookPageMap, + currentSpineIndex: Int?, + lastBuildableSpineIndex: Int + ) -> RDEPUBPageMapTakeoverDecision { + let currentIndices = Set(currentWindow.entries.map { $0.spineIndex }) + let candidateIndices = candidateSegment.resolvedSpineIndices + + // 检查是否覆盖当前阅读位置 + if let currentSpineIndex { + if !candidateIndices.contains(currentSpineIndex) { + return .keepCurrentWindow + } + } + + // 检查是否覆盖相邻章节 + if let currentSpineIndex { + let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0 + let hasNext = candidateIndices.contains(currentSpineIndex + 1) || + currentSpineIndex == lastBuildableSpineIndex + if !hasPrev || !hasNext { + return .keepCurrentWindow + } + } + + // 检查是否与当前窗口连续 + let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) || + currentIndices.contains(candidateSegment.upperSpineIndex + 1) || + candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) || + candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min) + + if isContinuous { + // 连续,可以扩窗 + return .expandWindow(candidateSegment) + } else { + // 不连续,检查是否覆盖当前窗口的大部分 + let overlap = currentIndices.intersection(candidateIndices) + let overlapRatio = Double(overlap.count) / Double(currentIndices.count) + if overlapRatio > 0.5 { + // 覆盖大部分,可以替换 + return .segmentReplace(candidateSegment) + } + } + + return .keepCurrentWindow + } + + /// 评估全量页图接管 + private func evaluateFullPageMapTakeover( + candidatePageMap: RDEPUBBookPageMap, + currentWindow: RDEPUBBookPageMap, + currentSpineIndex: Int?, + lastBuildableSpineIndex: Int + ) -> RDEPUBPageMapTakeoverDecision { + let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex }) + + // 检查是否覆盖当前阅读位置 + if let currentSpineIndex { + if !candidateIndices.contains(currentSpineIndex) { + return .keepCurrentWindow + } + } + + // 检查是否覆盖相邻章节 + if let currentSpineIndex { + let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0 + let hasNext = candidateIndices.contains(currentSpineIndex + 1) || + currentSpineIndex == lastBuildableSpineIndex + if !hasPrev || !hasNext { + return .keepCurrentWindow + } + } + + // 检查是否完整覆盖 + let isComplete = candidateIndices.count >= currentWindow.entries.count + if isComplete { + return .fullReplace(candidatePageMap) + } + + return .keepCurrentWindow + } + + /// 生成保护区域的 spineIndex 集合 + func protectedSpineIndices( + currentSpineIndex: Int?, + jumpSession: RDEPUBJumpSession? + ) -> Set { + var indices: Set = [] + + if let currentSpineIndex { + indices.insert(currentSpineIndex) + // 添加相邻章节 + if currentSpineIndex > 0 { + indices.insert(currentSpineIndex - 1) + } + indices.insert(currentSpineIndex + 1) + } + + if let jumpSession { + indices.formUnion(jumpSession.protectedSpineIndices) + } + + return indices + } +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift index a160d7a..1fc95d6 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderLocationCoordinator.swift @@ -10,6 +10,9 @@ import Foundation final class RDEPUBReaderLocationCoordinator { private unowned let context: RDEPUBReaderContext + /// 上次翻页时的 spineIndex,用于检测跨章翻页 + private var lastPageChangeSpineIndex: Int? + init(context: RDEPUBReaderContext) { self.context = context } @@ -23,6 +26,9 @@ final class RDEPUBReaderLocationCoordinator { ) -> Bool { guard let controller = context.controller, let readerView = context.readerView else { return false } + if context.bookPageMap != nil { + _ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location) + } guard let targetPageNumber = controller.pageNumber(for: location) else { readerView.transitionToPage(pageNum: 0) context.readingSession?.transition(to: .idle) @@ -50,6 +56,10 @@ final class RDEPUBReaderLocationCoordinator { context.readingSession?.transition(to: .jumping) } readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated) + + // 记录翻页到 JumpSession + recordPageChangeIfNeeded() + return true } @@ -95,4 +105,40 @@ final class RDEPUBReaderLocationCoordinator { controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem) controller.updateReaderChrome() } + + /// 记录翻页到 JumpSession + func recordPageChangeIfNeeded() { + guard let runtime = context.runtime, + let bookPageMap = context.bookPageMap, + let readerView = context.readerView else { return } + + let currentPageNumber = readerView.currentPage + 1 + guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else { + return + } + + if let lastSpineIndex = lastPageChangeSpineIndex, + lastSpineIndex != currentSpineIndex { + runtime.jumpSessionManager.recordPageChange( + fromSpineIndex: lastSpineIndex, + toSpineIndex: currentSpineIndex + ) + } + + lastPageChangeSpineIndex = currentSpineIndex + + // 检查是否应该结束 JumpSession + let isIdle = context.secondsSinceLastUserNavigation() > 2.0 + if let endReason = runtime.jumpSessionManager.checkSessionEnd( + currentSpineIndex: currentSpineIndex, + isIdle: isIdle + ) { + runtime.jumpSessionManager.endSession(endReason) + } + } + + /// 重置翻页状态(用于重新加载等场景) + func resetPageChangeState() { + lastPageChangeSpineIndex = nil + } } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift index aed7c7a..495a9ed 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift @@ -9,11 +9,69 @@ import Foundation /// - 刷新可见内容并保持位置 /// - 重建外部纯文本图书 final class RDEPUBReaderPaginationCoordinator { + private final class MetadataParseState { + var summariesBySpineIndex: [Int: RDEPUBChapterSummary] + var totalResolvedCount: Int + var lastAppliedCount: Int + + init( + summariesBySpineIndex: [Int: RDEPUBChapterSummary], + totalResolvedCount: Int, + lastAppliedCount: Int + ) { + self.summariesBySpineIndex = summariesBySpineIndex + self.totalResolvedCount = totalResolvedCount + self.lastAppliedCount = lastAppliedCount + } + } + + private final class MetadataParseCancellationController { + let token: UUID + + private let lock = NSLock() + private weak var queue: OperationQueue? + private var cancelled = false + + init(token: UUID) { + self.token = token + } + + func attach(queue: OperationQueue) { + let shouldCancelImmediately: Bool + lock.lock() + self.queue = queue + shouldCancelImmediately = cancelled + lock.unlock() + + if shouldCancelImmediately { + queue.cancelAllOperations() + } + } + + func cancel() { + let queueToCancel: OperationQueue? + lock.lock() + cancelled = true + queueToCancel = queue + lock.unlock() + queueToCancel?.cancelAllOperations() + } + + var isCancelled: Bool { + lock.lock() + let value = cancelled + lock.unlock() + return value + } + } + private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8 /// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。 static var pageMapRefreshInterval: Int = 32 private unowned let context: RDEPUBReaderContext + private let metadataParseControlLock = NSLock() + private var activeMetadataParseCancellationController: MetadataParseCancellationController? init(context: RDEPUBReaderContext) { self.context = context @@ -375,8 +433,12 @@ final class RDEPUBReaderPaginationCoordinator { publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } } - private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) { + private func waitForReadingInteractionToSettle( + using context: RDEPUBReaderContext, + cancellationController: MetadataParseCancellationController? = nil + ) { while context.controller != nil, + cancellationController?.isCancelled != true, context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown { Thread.sleep(forTimeInterval: 0.08) } @@ -390,12 +452,17 @@ final class RDEPUBReaderPaginationCoordinator { // MARK: - 元数据专用解析(Phase 0) + /// 失败重试配置 + private static let maxRetryCount = 3 + private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0] + /// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets), /// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。 func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) { let context = self.context guard let parser = context.parser, let publication = context.publication else { return } + let cancellationController = beginMetadataParseCancellationController(for: token) let pageSize = context.currentTextPageSize() let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) @@ -409,12 +476,21 @@ final class RDEPUBReaderPaginationCoordinator { DispatchQueue.global(qos: .utility).async { [weak self] in guard let self else { return } - guard context.controller != nil else { return } + defer { self.finishMetadataParseCancellationController(cancellationController) } + guard context.controller != nil, + !cancellationController.isCancelled, + context.paginationToken == token else { return } // 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256 let prewarmStart = CFAbsoluteTimeGetCurrent() var contentHashBySpineIndex: [Int: String] = [:] for spineIndex in allBuildableIndices { + guard !cancellationController.isCancelled, + context.paginationToken == token, + context.controller != nil else { + RDEPUBBackgroundTrace.log("MetadataParse", "abort during content hash prewarm") + return + } guard let href = publication.spine.indices.contains(spineIndex) ? publication.spine[spineIndex].href : nil, let html = parser.htmlString(forRelativePath: href) else { @@ -447,16 +523,18 @@ final class RDEPUBReaderPaginationCoordinator { let cachedSummaries = restored?.summaries ?? [:] let cachedSpineIndices = Set(cachedSummaries.keys) let resultLock = NSLock() - var summariesBySpineIndex = cachedSummaries - var totalResolvedCount = cachedSpineIndices.count - var lastAppliedCount = cachedSpineIndices.count + let parseState = MetadataParseState( + summariesBySpineIndex: cachedSummaries, + totalResolvedCount: cachedSpineIndices.count, + lastAppliedCount: cachedSpineIndices.count + ) if !cachedSpineIndices.isEmpty { RDEPUBBackgroundTrace.log( "MetadataParse", "resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)" ) - let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex) + let cachedMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } @@ -464,9 +542,36 @@ final class RDEPUBReaderPaginationCoordinator { } } - let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } + // 使用优先级排序获取未缓存的 spineIndex + let prioritizedSpineIndices: [Int] + if let priorityManager = context.runtime?.backgroundPriorityManager { + let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation() + .flatMap { context.normalizedSpineIndex(for: $0) } + prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder( + allBuildableIndices: allBuildableIndices, + currentSpineIndex: currentSpineIndex, + cachedSpineIndices: cachedSpineIndices + ) + RDEPUBBackgroundTrace.log( + "MetadataParse", + "prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)" + ) + } else { + prioritizedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } + } - self.waitForReadingInteractionToSettle(using: context) + let uncachedSpineIndices = prioritizedSpineIndices + + self.waitForReadingInteractionToSettle( + using: context, + cancellationController: cancellationController + ) + guard !cancellationController.isCancelled, + context.controller != nil, + context.paginationToken == token else { + RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start") + return + } let wallClockStart = CFAbsoluteTimeGetCurrent() var totalRenderMs: Double = 0 @@ -480,19 +585,29 @@ final class RDEPUBReaderPaginationCoordinator { queue.name = "com.rdreader.metadata.parse" queue.qualityOfService = .utility queue.maxConcurrentOperationCount = workerCount + cancellationController.attach(queue: queue) let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval for (offset, spineIndex) in uncachedSpineIndices.enumerated() { - queue.addOperation { + let operation = BlockOperation() + operation.addExecutionBlock { [weak operation] in guard context.controller != nil, - context.paginationToken == token else { + context.paginationToken == token, + !cancellationController.isCancelled, + operation?.isCancelled != true else { return } do { RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)") let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in + guard context.controller != nil, + context.paginationToken == token, + !cancellationController.isCancelled, + operation?.isCancelled != true else { + return nil + } let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig) let renderStart = CFAbsoluteTimeGetCurrent() guard let result = try chapterBuilder.buildChapter( @@ -504,6 +619,13 @@ final class RDEPUBReaderPaginationCoordinator { ) else { return nil } + guard context.controller != nil, + context.paginationToken == token, + !cancellationController.isCancelled, + operation?.isCancelled != true else { + RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)") + return nil + } let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000 let chapter = result.chapter @@ -522,6 +644,13 @@ final class RDEPUBReaderPaginationCoordinator { chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) + guard context.controller != nil, + context.paginationToken == token, + !cancellationController.isCancelled, + operation?.isCancelled != true else { + RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)") + return nil + } let writeStart = CFAbsoluteTimeGetCurrent() summaryDiskCache?.write(summary: summary, for: cacheKey) let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000 @@ -540,15 +669,22 @@ final class RDEPUBReaderPaginationCoordinator { } guard let renderResult else { return } + guard context.controller != nil, + context.paginationToken == token, + !cancellationController.isCancelled, + operation?.isCancelled != true else { + return + } // 锁内只做写入和计数,快照数据后锁外构建 pageMap var snapshot: [Int: RDEPUBChapterSummary]? resultLock.lock() - summariesBySpineIndex[spineIndex] = renderResult - totalResolvedCount += 1 - if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count { - lastAppliedCount = totalResolvedCount - snapshot = summariesBySpineIndex + parseState.summariesBySpineIndex[spineIndex] = renderResult + parseState.totalResolvedCount += 1 + if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval + || parseState.totalResolvedCount == allBuildableIndices.count { + parseState.lastAppliedCount = parseState.totalResolvedCount + snapshot = parseState.summariesBySpineIndex } resultLock.unlock() @@ -561,20 +697,54 @@ final class RDEPUBReaderPaginationCoordinator { timingLock.unlock() DispatchQueue.main.async { guard context.paginationToken == token, - context.controller != nil else { return } + context.controller != nil, + !cancellationController.isCancelled else { return } context.runtime?.refreshBookPageMapInPlace(partialMap) } } } catch { + guard !cancellationController.isCancelled, + context.paginationToken == token, + context.controller != nil, + operation?.isCancelled != true else { + return + } timingLock.lock() failedChapters += 1 timingLock.unlock() RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") + + // 添加到重试队列(带退避延迟) + self.scheduleRetry( + spineIndex: spineIndex, + retryCount: 0, + token: token, + context: context, + parser: parser, + publication: publication, + pageSize: pageSize, + layoutConfig: layoutConfig, + style: style, + renderSignature: renderSignature, + summaryDiskCache: summaryDiskCache, + contentHashBySpineIndex: contentHashBySpineIndex, + resultLock: resultLock, + parseState: parseState, + allBuildableIndices: allBuildableIndices, + catalog: catalog, + refreshInterval: refreshInterval, + cancellationController: cancellationController + ) } } + queue.addOperation(operation) } queue.waitUntilAllOperationsAreFinished() - summaryDiskCache?.flushPendingWrites() + if !cancellationController.isCancelled, + context.paginationToken == token, + context.controller != nil { + summaryDiskCache?.flushPendingWrites() + } let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000) timingLock.lock() @@ -595,19 +765,39 @@ final class RDEPUBReaderPaginationCoordinator { context.lastMetadataParseConcurrency = workerCount guard context.controller != nil, - context.paginationToken == token else { + context.paginationToken == token, + !cancellationController.isCancelled else { RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed") return } let finalMergeStart = CFAbsoluteTimeGetCurrent() - let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex) + let pageMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex) let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000) RDEPUBBackgroundTrace.log( "MetadataParse", "complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)" ) + // 存储到 BackgroundCoverageStore + if let coverageStore = context.runtime?.backgroundCoverageStore { + let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys) + let lowerSpine = resolvedSpineIndices.min() ?? 0 + let upperSpine = resolvedSpineIndices.max() ?? 0 + let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16 + + let segment = RDEPUBBackgroundCoverageSegment( + lowerSpineIndex: lowerSpine, + upperSpineIndex: upperSpine, + pageMap: pageMap, + resolvedSpineIndices: resolvedSpineIndices, + generatedAt: CFAbsoluteTimeGetCurrent(), + renderSignature: renderSignature, + estimatedMemoryBytes: estimatedBytes + ) + coverageStore.addSegment(segment) + } + DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } @@ -616,6 +806,172 @@ final class RDEPUBReaderPaginationCoordinator { } } + /// 调度失败章节的重试 + private func scheduleRetry( + spineIndex: Int, + retryCount: Int, + token: UUID, + context: RDEPUBReaderContext, + parser: RDEPUBParser, + publication: RDEPUBPublication, + pageSize: CGSize, + layoutConfig: RDEPUBTextLayoutConfig, + style: RDEPUBTextRenderStyle, + renderSignature: String, + summaryDiskCache: RDEPUBChapterSummaryDiskCache?, + contentHashBySpineIndex: [Int: String], + resultLock: NSLock, + parseState: MetadataParseState, + allBuildableIndices: [Int], + catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)], + refreshInterval: Int, + cancellationController: MetadataParseCancellationController + ) { + guard retryCount < Self.maxRetryCount else { + RDEPUBBackgroundTrace.log( + "MetadataParse", + "spine=\(spineIndex) max retries reached, marking as deferredFailure" + ) + return + } + + let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)] + RDEPUBBackgroundTrace.log( + "MetadataParse", + "scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s" + ) + + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in + guard let self else { return } + guard context.controller != nil, + context.paginationToken == token, + !cancellationController.isCancelled else { + return + } + + do { + let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig) + guard let result = try chapterBuilder.buildChapter( + parser: parser, + publication: publication, + spineIndex: spineIndex, + pageSize: pageSize, + style: style + ) else { + return + } + guard context.controller != nil, + context.paginationToken == token, + !cancellationController.isCancelled else { + RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)") + return + } + + let chapter = result.chapter + let precomputedHash = contentHashBySpineIndex[spineIndex] ?? "" + let cacheKey = context.chapterCacheKey( + forSpineIndex: spineIndex, + precomputedContentHash: precomputedHash, + renderSignature: renderSignature + ) + let summary = RDEPUBChapterSummary( + pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) }, + pageCount: chapter.pages.count, + fragmentOffsets: chapter.fragmentOffsets, + renderSignature: cacheKey.renderSignature, + schemaVersion: RDEPUBChapterSummary.currentSchemaVersion, + chapterContentHash: cacheKey.chapterContentHash, + pageMetadataList: chapter.pages.map { .from($0.metadata) } + ) + guard context.controller != nil, + context.paginationToken == token, + !cancellationController.isCancelled else { + RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)") + return + } + summaryDiskCache?.write(summary: summary, for: cacheKey) + + resultLock.lock() + parseState.summariesBySpineIndex[spineIndex] = summary + parseState.totalResolvedCount += 1 + let shouldRefresh = + parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval + || parseState.totalResolvedCount == allBuildableIndices.count + if shouldRefresh { + parseState.lastAppliedCount = parseState.totalResolvedCount + } + resultLock.unlock() + + if shouldRefresh { + let partialMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex) + DispatchQueue.main.async { + guard context.paginationToken == token, + context.controller != nil, + !cancellationController.isCancelled else { return } + context.runtime?.refreshBookPageMapInPlace(partialMap) + } + } + + RDEPUBBackgroundTrace.log( + "MetadataParse", + "retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)" + ) + } catch { + RDEPUBBackgroundTrace.log( + "MetadataParse", + "retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)" + ) + // 继续重试 + self.scheduleRetry( + spineIndex: spineIndex, + retryCount: retryCount + 1, + token: token, + context: context, + parser: parser, + publication: publication, + pageSize: pageSize, + layoutConfig: layoutConfig, + style: style, + renderSignature: renderSignature, + summaryDiskCache: summaryDiskCache, + contentHashBySpineIndex: contentHashBySpineIndex, + resultLock: resultLock, + parseState: parseState, + allBuildableIndices: allBuildableIndices, + catalog: catalog, + refreshInterval: refreshInterval, + cancellationController: cancellationController + ) + } + } + } + + func cancelActiveMetadataParseWork() { + metadataParseControlLock.lock() + let controller = activeMetadataParseCancellationController + activeMetadataParseCancellationController = nil + metadataParseControlLock.unlock() + controller?.cancel() + } + + private func beginMetadataParseCancellationController(for token: UUID) -> MetadataParseCancellationController { + let controller = MetadataParseCancellationController(token: token) + metadataParseControlLock.lock() + let previous = activeMetadataParseCancellationController + activeMetadataParseCancellationController = controller + metadataParseControlLock.unlock() + previous?.cancel() + return controller + } + + private func finishMetadataParseCancellationController(_ controller: MetadataParseCancellationController) { + metadataParseControlLock.lock() + if activeMetadataParseCancellationController === controller { + activeMetadataParseCancellationController = nil + } + metadataParseControlLock.unlock() + } + private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? { guard let summaryDiskCache = context.runtime?.summaryDiskCache, let parser = context.parser else { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift index cbeec3b..4257074 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift @@ -22,6 +22,10 @@ final class RDEPUBReaderRuntime { lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context) lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context) lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context) + lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context) + lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context) + lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context) + lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context) init(context: RDEPUBReaderContext) { self.context = context @@ -326,15 +330,44 @@ final class RDEPUBReaderRuntime { let readerView = context.readerView, let controller = context.controller else { return } - context.pendingFullPageMap = nil + // 使用协调器判断是否允许接管 + let decision = reconciliationCoordinator.evaluateTakeover( + candidatePageMap: pendingMap, + candidateSegment: nil, + currentWindow: context.bookPageMap, + jumpSession: jumpSessionManager.activeSession + ) - // 保存当前位置(在旧 map 下解析) + switch decision { + case .keepCurrentWindow: + RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow") + return + + case .fullReplace(let newPageMap): + RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace") + applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller) + + case .expandWindow, .segmentReplace: + // 这些情况在当前实现中不会发生,因为我们传入的是 candidatePageMap + RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision") + break + } + } + + /// 应用全量页图替换 + private func applyFullPageMapReplacement( + _ newPageMap: RDEPUBBookPageMap, + readerView: RDReaderView, + controller: RDEPUBReaderController + ) { let currentLocation = locationCoordinator.currentVisibleLocation() + context.pendingFullPageMap = nil + // 替换 map 和快照 context.textBook = nil - context.bookPageMap = pendingMap - context.replaceActiveSnapshot(makeSnapshot(from: pendingMap)) + context.bookPageMap = newPageMap + context.replaceActiveSnapshot(makeSnapshot(from: newPageMap)) // 用位置在新 map 中重新解析正确的页码 if let currentLocation { @@ -347,6 +380,18 @@ final class RDEPUBReaderRuntime { } else { readerView.reloadPageCountOnly() } + + // 检查是否应该结束 JumpSession(coverage-complete) + if let currentLocation, + let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation), + let activeSession = jumpSessionManager.activeSession { + let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex }) + let protectedIndices = activeSession.protectedSpineIndices + let isFullyCovered = protectedIndices.isSubset(of: candidateIndices) + if isFullyCovered { + jumpSessionManager.endSession(.coverageComplete) + } + } } /// 完成分页流程并恢复阅读位置 @@ -401,6 +446,95 @@ final class RDEPUBReaderRuntime { viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature) } + @discardableResult + func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool { + guard context.bookPageMap != nil, + let publication = context.publication, + let targetSpineIndex = context.normalizedSpineIndex(for: location) else { + return false + } + + // 检查是否是远距跳转 + let currentSpineIndex = locationCoordinator.currentVisibleLocation() + .flatMap { context.normalizedSpineIndex(for: $0) } + let isDistantJump = if let current = currentSpineIndex { + abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2 + } else { + false + } + + if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil { + // 如果是远距跳转且目标已在当前窗口中,创建 JumpSession + if isDistantJump { + jumpSessionManager.createSession( + anchorSpineIndex: targetSpineIndex, + reason: .tableOfContentsJump, + totalSpineCount: publication.spine.count + ) + } + return true + } + + if let pendingMap = context.pendingFullPageMap, + pendingMap.entry(forSpineIndex: targetSpineIndex) != nil { + applyPendingFullPageMapIfNeeded() + if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil { + if isDistantJump { + jumpSessionManager.createSession( + anchorSpineIndex: targetSpineIndex, + reason: .tableOfContentsJump, + totalSpineCount: publication.spine.count + ) + } + return true + } + } + + let buildableSpineIndices = publication.spine.indices.filter { index in + let item = publication.spine[index] + return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml")) + } + guard let anchorPosition = buildableSpineIndices.firstIndex(of: targetSpineIndex) else { + return false + } + + let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize( + context.configuration.onDemandChapterWindowSize + ) + let chapters = loadPartialWindowChapters( + around: anchorPosition, + in: buildableSpineIndices, + targetSpineIndex: targetSpineIndex, + windowSize: normalizedWindowSize + ) + guard !chapters.isEmpty else { + return false + } + + chapterRuntimeStore.setCurrentChapter( + spineIndex: targetSpineIndex, + totalSpineCount: publication.spine.count, + windowRadius: context.configuration.chapterWindowRadius + ) + let partialMap = makePartialPageMap(from: chapters) + context.bookPageMap = partialMap + context.replaceActiveSnapshot(makeSnapshot(from: partialMap)) + context.readerView?.reloadData() + + // 远距跳转成功后创建 JumpSession + if isDistantJump { + jumpSessionManager.createSession( + anchorSpineIndex: targetSpineIndex, + reason: .tableOfContentsJump, + totalSpineCount: publication.spine.count + ) + // 添加温区锚点 + backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex) + } + + return partialMap.entry(forSpineIndex: targetSpineIndex) != nil + } + @discardableResult func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool { guard let bookPageMap = context.bookPageMap, @@ -468,22 +602,40 @@ final class RDEPUBReaderRuntime { guard currentMap.totalChapters < buildableSpineIndices.count else { return } - guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else { + + // 确定当前阅读方向,优先向当前方向扩展 + let currentSpineIndex = locationCoordinator.currentVisibleLocation() + .flatMap { context.normalizedSpineIndex(for: $0) } + let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages + let isNearStart = currentPageNumber <= minimumTrailingPages + + // 根据阅读方向决定扩展策略 + var spineIndicesToAppend: [Int] = [] + if isNearEnd { + // 向后扩展 + let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1 + spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)) + } else if isNearStart { + // 向前扩展 + let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max + let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex } + spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount)) + } else { + // 不在边界,不扩展 return } - let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1 - let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount) - guard !nextSpineIndices.isEmpty else { + guard !spineIndicesToAppend.isEmpty else { return } + RDEPUBBackgroundTrace.log( "Runtime", - "extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))" + "extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")" ) var appendedEntries: [RDEPUBBookPageMapEntry] = [] - for spineIndex in nextSpineIndices { + for spineIndex in spineIndicesToAppend { do { let chapter = try chapterLoader.loadChapterSynchronouslyForMigration( spineIndex: spineIndex, @@ -545,9 +697,80 @@ final class RDEPUBReaderRuntime { } func clearOnDemandPageModeState() { + paginationCoordinator.cancelActiveMetadataParseWork() chapterRuntimeStore.invalidateAllForSettingsChange() context.bookPageMap = nil context.pendingFullPageMap = nil + jumpSessionManager.clearSession() + backgroundPriorityManager.reset() + backgroundCoverageStore.clearAll() + } + + /// 处理内存警告 + func handleMemoryWarning() { + let currentSpineIndex = locationCoordinator.currentVisibleLocation() + .flatMap { context.normalizedSpineIndex(for: $0) } + let activeWindowIndices: Set = if let currentSpineIndex { + [currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1] + } else { + [] + } + let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? [] + + backgroundCoverageStore.handleMemoryWarning( + activeWindowSpineIndices: activeWindowIndices, + protectedSpineIndices: protectedIndices + ) + } + + private func loadPartialWindowChapters( + around anchorPosition: Int, + in buildableSpineIndices: [Int], + targetSpineIndex: Int, + windowSize: Int + ) -> [RDEPUBRuntimeChapter] { + let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0) + let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count) + let startIndex = max(0, upperBound - max(windowSize, 1)) + let window = Array(buildableSpineIndices[startIndex.. RDEPUBBookPageMap { + var builder = RDEPUBBookPageMap.Builder() + for chapter in chapters { + builder.add( + spineIndex: chapter.spineIndex, + href: chapter.href, + title: chapter.title, + pageCount: chapter.pages.count, + fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets + ) + } + return builder.build() } private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift index c682679..74c6cb4 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift +++ b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift @@ -114,6 +114,11 @@ public struct RDEPUBReaderConfiguration: Equatable { /// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。 public var metadataParsingConcurrency: Int + // MARK: JumpSession 策略 + + /// JumpSession 策略配置 + public var jumpSessionPolicy: RDEPUBJumpSessionPolicy + // MARK: 安全策略 /// 允许直接打开的外部 URL scheme 集合,默认仅允许 https @@ -172,6 +177,7 @@ public struct RDEPUBReaderConfiguration: Equatable { textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText, onDemandChapterWindowSize: Int = 3, metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount, + jumpSessionPolicy: RDEPUBJumpSessionPolicy = .default, allowedExternalURLSchemes: Set = ["https"], requiresExternalLinkConfirmation: Bool = true, allowsInspectableWebViews: Bool = false, @@ -197,6 +203,7 @@ public struct RDEPUBReaderConfiguration: Equatable { self.textRenderingEngine = textRenderingEngine self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize) self.metadataParsingConcurrency = max(1, metadataParsingConcurrency) + self.jumpSessionPolicy = jumpSessionPolicy self.allowedExternalURLSchemes = allowedExternalURLSchemes self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation self.allowsInspectableWebViews = allowsInspectableWebViews diff --git a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift index b1b393e..234531e 100644 --- a/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift +++ b/Sources/RDReaderView/EPUBUI/TextPage/RDEPUBTextContentView.swift @@ -17,7 +17,8 @@ protocol RDEPUBTextContentViewDelegate: AnyObject { func textContentView( _ contentView: RDEPUBTextContentView, didActivateAttachmentText text: String, - sourceRect: CGRect + sourceRect: CGRect, + sourcePoint: CGPoint ) func textContentView( _ contentView: RDEPUBTextContentView, @@ -552,7 +553,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate { } if let attachmentText = attachmentText(at: point), let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) { - delegate?.textContentView(self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect) + delegate?.textContentView( + self, + didActivateAttachmentText: attachmentText, + sourceRect: sourceRect, + sourcePoint: convert(point, from: overlayView) + ) return } guard let highlight = highlight(at: point),