ReadViewSDK/Doc/大书远距目录跳转与后台补全优化方案.md
shenlei c64460988a feat: 实现大书远距目录跳转与后台补全优化方案
Phase 1: 稳定性优先
- 新增 RDEPUBJumpSession 保护机制,防止远距跳转后翻页串章
- 升级页图接管条件,增加 JumpSession 保护区检查
- 窗口扩展改为基于当前权威窗口方向

Phase 2: 补全优先级重排
- 新增 RDEPUBBackgroundPriorityPolicy 策略配置
- 实现 hot/warm/cold zone 优先级排序
- 添加失败重试机制(指数退避,最多3次)

Phase 3: 分段覆盖与最终收敛
- 新增 RDEPUBBackgroundCoverageStore 分段存储
- 新增 RDEPUBPageMapReconciliationCoordinator 页图接管仲裁
- 实现 LRU 淘汰和内存警告处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-15 16:28:11 +08:00

29 KiB
Raw Blame History

大书远距目录跳转与后台补全优化方案

最后更新2026-06-15 适用范围:RDEPUBReaderPaginationCoordinatorRDEPUBReaderRuntimeRDEPUBReaderLocationCoordinatorRDEPUBReaderController+DataSource 问题背景:大书按需分页模式下,从较早章节远距跳转到未解析章节时,虽然首跳章节可对齐,但后续翻页仍可能回落到错误章节窗口。


1. 文档目标

本文给出“大书远距目录跳转与后台补全”的完整优化方案,目标不是只修一次目录跳转,而是统一以下三件事:

  1. 远距目录跳转时,前台必须优先保证目标章节可读。
  2. 跳转后的连续翻页必须保持章节窗口连续,不被旧的后台页图覆盖。
  3. 后台补全必须围绕当前阅读区段优先补洞,而不是只按全书顺序推进。

本文基于当前仓库代码整理,不以旧文档描述为准。


2. 当前实现概览

当前大书模式由两条并行路径组成:

  1. 前台按需窗口
  2. 后台全书元数据补全

对应代码主入口:

当前设计的优点:

  • 首开速度快,用户不必等待全书解析完成。
  • 单章内容和轻量页图是解耦的,内存开销较低。
  • 目录跳转前可以临时重建目标章节附近的小窗口,已经能保证“首跳章节正确”。

当前设计的核心缺口:

  • 后台补全仍然以“全书 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

后台不再只维护一个“越来越完整的全书候选图”,而是维护多段覆盖信息。

建议新增概念:

struct RDEPUBBackgroundCoverageSegment {
    let lowerSpineIndex: Int
    let upperSpineIndex: Int
    let pageMap: RDEPUBBookPageMap
    let resolvedSpineIndices: Set<Int>
    let generatedAt: CFAbsoluteTime
    let renderSignature: String
    let estimatedMemoryBytes: Int
}

职责:

  • 表示某一段章节范围已经具备稳定的页码覆盖。
  • 可以被前台按需合并或替换。
  • 不必要求一开始就成为完整全书页图。

5.3 Full Book Convergence

最终后台仍会生成一份全书完整覆盖,但它只作为最终收敛目标,不再是每次前台导航时都想接管的唯一对象。

建议将现有:

  • pendingFullPageMap

演进为:

  • pendingCoverageSegments
  • pendingCompletePageMap

其中:

  • pendingCoverageSegments 用于当前阅读区段附近的安全升级。
  • pendingCompletePageMap 只在完整覆盖达成时才应用。

5.4 renderSignature 定义

文中涉及的 renderSignature 用于判断两个 segment 或完整页图是否属于同一分页语义版本。

建议构成如下:

// 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 章后,前台必须进入一个短期“跳转会话”,在此期间当前阅读窗口拥有最高优先级。

建议新增模型

struct RDEPUBJumpSession {
    let anchorSpineIndex: Int
    let createdAt: CFAbsoluteTime
    let protectedSpineIndices: Set<Int>
    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 中新增:

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 参数来源与配置化

不建议把 32128 写死。建议引入策略对象:

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. 某章节若同时属于 hotwarm,只按 hot 处理一次。

修改建议

把:

  • uncachedSpineIndices = allBuildableIndices.filter { ... }

改造成:

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

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 - 1currentSpineIndex + 1,若这些章节是 buildable
  3. 覆盖当前 JumpSession.protectedSpineIndices,若 Session 存在
  4. 当前页所在章节的本地页码边界与前台窗口一致,或可安全重定位

边界章节规则

对于第一页或最后一章,接管准入条件中的相邻章节检查需要收口:

  1. currentSpineIndex == 0
    • 只要求覆盖 currentSpineIndexcurrentSpineIndex + 1
  2. currentSpineIndex == lastBuildableSpineIndex
    • 只要求覆盖 currentSpineIndexcurrentSpineIndex - 1
  3. 若前后相邻章节存在但不是 buildable text spine
    • 不将其纳入强制接管条件
  4. 若当前 JumpSession.protectedSpineIndices 本身已覆盖更严格范围
    • protectedSpineIndices 为准

接管体验约束

  1. 页图接管默认不做显式动画,避免视觉跳闪。
  2. 若接管导致当前页号变化但内容位置不变,不展示 toast。
  3. 若接管后当前页需要重定位,优先无动画 transitionToPage

修改位置

  • RDEPUBReaderRuntime.applyPendingFullPageMapIfNeeded()

6.5 策略五:局部窗口扩展必须以当前窗口为基准

当前问题

目录跳转时虽然可以临时重建第 1000 章附近窗口,但后续扩窗仍可能受到旧状态影响。

新策略

扩窗必须以“当前权威窗口”作为唯一基准,规则如下:

  1. 若用户向后翻页,优先向后扩展窗口。
  2. 若用户向前翻页,优先向前扩展窗口。
  3. 扩窗时保留当前窗口已有章节,不重新回退到旧窗口。

建议实现

bookPageMap 增加窗口语义:

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 只积累后台成果

前台永远不直接消费“后台刚生成的任意结果”,而是通过一个仲裁器判断是否可以吸收。

建议新增协调器:

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. 与当前阅读位置距离最远

建议新增:

struct RDEPUBBackgroundCoverageStorePolicy {
    let maxResidentSegments: Int
    let maxChaptersPerSegment: Int
    let memoryBudgetBytes: Int
}

降级策略

在内存警告或预算超限时:

  1. 立即清空最远冷区 segment
  2. 保留:
    • activeWindow
    • JumpSession 保护区
    • 最近一次 warm segment
  3. 若仍超限:
    • 停止创建新 segment
    • 后台只继续写单章摘要缓存,不再持有内存聚合页图

estimatedMemoryBytes 估算方式

estimatedMemoryBytes 不要求做到对象级精确统计,但必须有稳定近似公式,建议基于页图结构体规模估算:

// 估算思路:
// bytes ≈ baseSegmentOverhead
//       + entryCount * avgEntryCost
//       + resolvedSpineCount * avgSetEntryCost

建议默认参数:

  • baseSegmentOverhead = 256B
  • avgEntryCost = 96B
  • avgSetEntryCost = 16B

即:

estimatedMemoryBytes =
    256 +
    pageMap.entries.count * 96 +
    resolvedSpineIndices.count * 16

说明:

  1. 这里只估算 pageMap 与 segment 元数据,不包含单章 NSAttributedString
  2. 该估算用于淘汰排序和预算闸门,不用于精确 profiling。
  3. 若后续 profiling 显示偏差明显,可统一调整常量,不影响外部接口。

与现有缓存的关系

磁盘层仍以单章摘要为准:

  • RDEPUBChapterSummaryDiskCache 不变
  • segment 只作为内存聚合态
  • 任何 segment 都必须可由单章摘要重新构建

7. 目标架构

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

可表达为:

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 演进成“分段覆盖 + 最终全图收敛”。

最关键的设计原则只有一句话:

前台当前阅读窗口必须始终拥有解释权,后台补全只能在安全条件满足后逐步接管,不能反向覆盖当前阅读语义。