- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
14 KiB
章节运行时详解
最后更新:2026-06-18
本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。
1. 概述
章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 RDEPUBReaderContext → RDEPUBReaderRuntime 架构中,是实现大书(如 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-256pageMetadataList: [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
)
三级缓存串联:
- Tier 1 命中:
store.chapterData(for: spineIndex)→ 直接返回 - Tier 1 页数缓存命中:
store.pageCount(for: cacheKey)→ 轻量路径(跳过分页计算) - Tier 2 命中:
summaryDiskCache?.read(for: cacheKey)→ 轻量路径 - 全部未命中:完整路径(渲染 + 分页 + 写缓存)
4.2 轻量路径 vs 完整路径
轻量路径(有缓存页范围时):
- 读取 HTML 并渲染为 NSAttributedString
- 使用缓存的 pageRanges 直接构建页面
- 跳过分页计算(最耗时的步骤)
完整路径(无缓存时):
- 使用
RDEPUBTextBookBuilder.buildChapter()完整构建 - 包含 HTML→NSAttributedString→分页→尾页规范化全流程
- 构建完成后写入磁盘摘要缓存
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?
解析流程:
- 从
BookPageMap查找 spineIndex 和 localPageIndex - 从
ChapterRuntimeStore获取已加载的章节数据 - 如果章节未加载,触发按需加载
7. 后台元数据解析优化
文件:RDEPUBReaderPaginationCoordinator.swift
7.1 整体流程
paginateMetadataOnly(token)
│
├─ 预计算所有章节 contentHash(串行)
├─ readAll(keys:) 批量读取磁盘缓存
├─ refreshBookPageMapInPlace(缓存部分)
├─ waitForReadingInteractionToSettle(0.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 版本 |