ReadViewSDK/Doc/CHAPTER_RUNTIME.md
shenlei c65c190b71 feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
2026-06-22 20:26:34 +08:00

14 KiB
Raw Blame History

章节运行时详解

最后更新2026-06-18

本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。


1. 概述

章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 RDEPUBReaderContextRDEPUBReaderRuntime 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。

核心设计目标

  • 快速打开:用户点击书籍后 1-2 秒内可开始阅读
  • 按需加载:只加载当前窗口内的章节,内存占用可控
  • 渐进补全:后台逐步补全所有章节的页码信息

关键文件

  • Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/ 目录
  • Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift

2. 三级缓存架构

┌─────────────────────────────────────────────────────────┐
│  Tier 1: 内存缓存 (RDEPUBChapterRuntimeStore)            │
│  ├─ chapterDataCache: [Int: RDEPUBRuntimeChapter]       │
│  ├─ pageCountCache: [CacheKey: RDEPUBRuntimePageCount]  │
│  ├─ imageCache: NSCache<NSString, UIImage> (50 上限)    │
│  └─ 窗口驱逐:只保留当前章节 ± windowRadius 的章节       │
└──────────────────────────┬──────────────────────────────┘
                           │ miss
┌──────────────────────────▼──────────────────────────────┐
│  Tier 2: 磁盘摘要缓存 (RDEPUBChapterSummaryDiskCache)    │
│  ├─ 路径: ~/Caches/RDEPUBChapterSummaryCache/{bookID}/   │
│  ├─ 文件名: SHA256(bookID_spineIdx_renderSig_contentHash)│
│  ├─ 格式: JSON (RDEPUBChapterSummary)                    │
│  ├─ 写入: 异步serial DispatchQueue                   │
│  └─ 读取: 同步readAll 批量读取)                       │
└──────────────────────────┬──────────────────────────────┘
                           │ miss
┌──────────────────────────▼──────────────────────────────┐
│  Tier 3: 全书分页缓存 (RDEPUBTextBookCache)              │
│  ├─ 路径: ~/Caches/RDEPUBTextBookCache/                  │
│  ├─ 格式: NSSecureCoding archive                         │
│  ├─ 内容: 每章的 pageRanges + breakReasons + semanticHints│
│  └─ 失效: schemaVersion 变更时全部失效                    │
└─────────────────────────────────────────────────────────┘

2.1 Tier 1: RDEPUBChapterRuntimeStore

文件RDEPUBChapterRuntimeStore.swift

内存缓存,存储当前窗口内的章节数据。

final class RDEPUBChapterRuntimeStore {
    // 章节数据缓存(完整 RuntimeChapter
    private let chapterDataCache = RDEPUBChapterDataCache()

    // 页数缓存(仅页范围,不含 NSAttributedString
    private let pageCountCache = RDEPUBPageCountCache()

    // 图片缓存NSCache上限 50 张)
    let imageCache = NSCache<NSString, UIImage>()

    // 章节加载队列串行userInitiated QoS
    let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
}

窗口驱逐策略

func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
    currentSpineIndex = spineIndex
    let lowerBound = max(0, spineIndex - radius)
    let upperBound = min(totalSpineCount - 1, spineIndex + radius)
    windowSpineIndices = Array(lowerBound...upperBound)
}
  • 保留范围:[spineIndex - windowRadius, spineIndex + windowRadius]
  • evictableSpineIndices() 返回窗口外的已缓存章节索引
  • 章节切换时调用 setCurrentChapter 更新窗口,然后驱逐窗口外章节

内存警告处理

func handleMemoryWarning() {
    evictAllExceptCurrent()    // 驱逐除当前章节外的所有缓存
    imageCache.removeAllObjects()  // 清空图片缓存
}

导航优先级抢占

当用户快速翻页时,新的导航请求可以抢占正在构建的章节:

func setNavigationTarget(spineIndex: Int)     // 设置导航目标
func consumeNavigationTarget() -> Int?        // 消费导航目标(构建完成后检查)

RDEPUBChapterLoader 在完成当前章节构建后,会检查是否有新的导航目标,如有则立即切换到新目标。

2.2 Tier 2: RDEPUBChapterSummaryDiskCache

文件RDEPUBChapterSummaryDiskCache.swift

磁盘摘要缓存,存储章节的分页元数据(不含完整 NSAttributedString

存储路径~/Library/Caches/RDEPUBChapterSummaryCache/{bookID}/

文件命名SHA256(bookID_spineIndex_renderSignature_contentHash).json

缓存内容RDEPUBChapterSummary

  • pageRanges: [NSRange] — 每页的文本范围
  • pageCount: Int — 总页数
  • fragmentOffsets: [String: Int] — fragment ID 到偏移量的映射
  • cfiMap: RDEPUBCFIMap? — CFI 映射
  • renderSignature: String — 渲染参数签名
  • chapterContentHash: String — 章节 HTML 的 SHA-256
  • pageMetadataList: [PageMetadataSummary] — 每页的语义元数据

写入策略

  • 异步写入serial DispatchQueue
  • 原子写入(先写临时文件,再 rename

批量读取

func readAll(keys: [RDEPUBChapterCacheKey]) -> (
    summaries: [Int: RDEPUBChapterSummary],
    partialBuilder: RDEPUBBookPageMap.Builder
)

一次读取所有章节的摘要,同时构建 BookPageMap.Builder,避免重复遍历。

2.3 Tier 3: RDEPUBTextBookCache

全书分页缓存,使用 NSSecureCoding 归档。包含每章的完整分页信息pageRanges、breakReasons、semanticHints。当 schemaVersion 变更时全部失效。


3. RDEPUBChapterCacheKey — 缓存键设计

文件RDEPUBChapterCacheKey.swift

struct RDEPUBChapterCacheKey: Hashable {
    let bookID: String              // 书籍唯一标识
    let spineIndex: Int             // 章节索引
    let renderSignature: String     // 渲染参数签名
    let chapterContentHash: String  // 章节 HTML 的 SHA-256
}

renderSignature 组成

let renderSignature = [
    style.font.fontName,        // 字体名称
    "\(style.font.pointSize)",  // 字号
    "\(lineHeightMultiple)",    // 行距倍数
    "\(style.lineSpacing)",     // 行间距
    layoutConfig.cacheSignature, // 布局参数签名
    "\(schemaVersion)"          // 缓存 schema 版本
].joined(separator: "|")

缓存失效语义

  • 换字体/字号 → renderSignature 变化 → 缓存失效
  • EPUB 内容更新 → contentHash 变化 → 缓存失效
  • 不同书籍 → bookID 不同 → 互不干扰
  • schemaVersion 变更 → 全部失效

4. RDEPUBChapterLoader — 章节加载器

文件RDEPUBChapterLoader.swift

4.1 加载流程

func loadChapter(
    spineIndex: Int,
    store: RDEPUBChapterRuntimeStore,
    priority: LoadPriority = .navigation,
    completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
)

三级缓存串联

  1. Tier 1 命中store.chapterData(for: spineIndex) → 直接返回
  2. Tier 1 页数缓存命中store.pageCount(for: cacheKey) → 轻量路径(跳过分页计算)
  3. Tier 2 命中summaryDiskCache?.read(for: cacheKey) → 轻量路径
  4. 全部未命中:完整路径(渲染 + 分页 + 写缓存)

4.2 轻量路径 vs 完整路径

轻量路径(有缓存页范围时):

  1. 读取 HTML 并渲染为 NSAttributedString
  2. 使用缓存的 pageRanges 直接构建页面
  3. 跳过分页计算(最耗时的步骤)

完整路径(无缓存时):

  1. 使用 RDEPUBTextBookBuilder.buildChapter() 完整构建
  2. 包含 HTML→NSAttributedString→分页→尾页规范化全流程
  3. 构建完成后写入磁盘摘要缓存

4.3 加载优先级

enum LoadPriority {
    case navigation  // 用户导航触发(最高优先级,可抢占)
    case preview     // 预览触发
    case prefetch    // 预取触发(最低优先级)
}

导航优先级抢占:当 priority == .navigation 时,完成构建后检查 consumeNavigationTarget(),如有新目标则立即切换。

4.4 同步加载

func loadChapterSynchronouslyForMigration(
    spineIndex: Int,
    store: RDEPUBChapterRuntimeStore?
) throws -> RDEPUBRuntimeChapter

使用信号量阻塞当前线程,等待章节加载完成。用于快速打开路径中加载首个可渲染章节。


5. RDEPUBBookPageMap — 轻量页码映射

文件RDEPUBBookPageMap.swift

轻量级全书页码映射,不持有 NSAttributedString内存占用约 100KB/1000 章。

5.1 数据结构

struct RDEPUBBookPageMapEntry {
    let spineIndex: Int
    let href: String
    let title: String
    let pageCount: Int
    let absolutePageStart: Int      // 该章节的起始绝对页码
    let fragmentOffsets: [String: Int]
}

struct RDEPUBBookPageMap {
    let entries: [RDEPUBBookPageMapEntry]
    let totalPages: Int
    var totalChapters: Int { entries.count }
}

5.2 页码查询

// 绝对页码 → 章节索引二分查找O(log n)
func spineIndex(forAbsolutePage absolutePage: Int) -> Int?

// 绝对页码 → 章节内本地页码
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int?

// (spineIndex, localPageIndex) → 绝对页码
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int?

5.3 Builder 增量构建

struct Builder {
    mutating func add(
        spineIndex: Int,
        href: String,
        title: String,
        pageCount: Int,
        fragmentOffsets: [String: Int]
    )

    func build() -> RDEPUBBookPageMap
}

Builder 模式支持增量添加章节信息,build() 时自动按 spineIndex 排序并计算绝对页码。


6. RDEPUBPageResolver — 页码解析器

文件RDEPUBPageResolver.swift

将绝对页码解析为章节和本地页码的组合。

struct RDEPUBResolvedPage {
    let spineIndex: Int
    let localPageIndex: Int
    let page: RDEPUBTextPage?
}

func resolvePage(absolutePageIndex: Int) -> RDEPUBResolvedPage?

解析流程:

  1. BookPageMap 查找 spineIndex 和 localPageIndex
  2. ChapterRuntimeStore 获取已加载的章节数据
  3. 如果章节未加载,触发按需加载

7. 后台元数据解析优化

文件RDEPUBReaderPaginationCoordinator.swift

7.1 整体流程

paginateMetadataOnly(token)
    │
    ├─ 预计算所有章节 contentHash串行
    ├─ readAll(keys:) 批量读取磁盘缓存
    ├─ refreshBookPageMapInPlace缓存部分
    ├─ waitForReadingInteractionToSettle0.8s 冷却)
    │
    ├─ OperationQueue (并发 N=CPU核心数):
    │   ├─ buildChapter(spineIndex)     // 渲染+分页
    │   ├─ chapterCacheKey(spineIndex)  // 复用预计算 hash
    │   ├─ summary.write()              // 异步写盘
    │   └─ 每 32 章刷新 BookPageMap
    │
    └─ 最终 refreshBookPageMapInPlace

7.2 预计算 contentHash

问题:每个章节在构建缓存键时需要读取 HTML 并计算 SHA-256重复 I/O 开销大。

优化:在后台解析开始时,串行预计算所有章节的 contentHash

var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
    let html = parser.htmlString(forRelativePath: href)
    contentHashBySpineIndex[spineIndex] = html?.sha256Hex ?? ""
}

后续所有 chapterCacheKey 调用都使用预计算值。

7.3 冻结 renderSignature

问题:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。

解决:在 paginateMetadataOnly 开始时冻结签名:

let renderSignature = context.currentRenderSignature()
// 后续所有 chapterCacheKey 调用使用此固定值

token 机制确保设置变更会触发新的解析任务(新 token旧任务自动废弃。

7.4 锁区瘦身

原始实现resultLock 内调用 buildPageMap(),遍历全量 catalog 和 summaries。

优化:锁内只做写入和计数,快照数据后锁外构建 pageMap

var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if shouldRefresh {
    snapshot = summariesBySpineIndex  // 快照
}
resultLock.unlock()

if let snapshot {
    let partialMap = buildPageMap(from: catalog, summaries: snapshot) // 锁外
}

7.5 可配置刷新间隔

static var pageMapRefreshInterval: Int = 32  // 默认 32 章

可实测调优32 / 48 / 64。值越大UI 刷新频率越低,后台解析吞吐越高。

7.6 用户交互冷却

等待用户操作冷却 0.8 秒后再开始后台解析,避免与用户翻页操作竞争资源:

try await Task.sleep(nanoseconds: 800_000_000)

8. 关键配置参数

参数 位置 默认值 说明
onDemandChapterWindowSize RDEPUBReaderConfiguration 3 按需加载窗口大小奇数3-15
chapterWindowRadius 计算属性 1 内存缓存窗口半径windowSize / 2
metadataParsingConcurrency RDEPUBReaderConfiguration CPU 核心数 后台解析并发数
pageMapRefreshInterval 静态变量 32 每 N 章刷新一次 UI
imageCache.countLimit RDEPUBChapterRuntimeStore 50 图片缓存上限
冷却时间 硬编码 0.8s 用户交互冷却时间
schemaVersion RDEPUBChapterSummary - 缓存 schema 版本