- Rename source module from RDReaderView to RDEpubReaderView - Move all source files from Sources/RDReaderView/ to Sources/RDEpubReaderView/ - Update podspec: RDReaderView.podspec -> RDEpubReaderView.podspec - Update Podfile, demo project, and CocoaPods config for new pod name - Delete old RDReaderView pod support files from ReadViewDemo/Pods - Add new RDEpubReaderView pod support files - Update documentation (API ref, architecture, UML, conventions, etc.) - Add FixedLayoutRotationTests - Update .gitignore: exclude .DS_Store, manual unpack backups, _ssoft-output
426 lines
14 KiB
Markdown
426 lines
14 KiB
Markdown
# 章节运行时详解
|
||
|
||
> 最后更新:2026-06-18
|
||
|
||
本文档详细描述 ReadViewSDK 章节运行时子系统的架构、缓存策略、加载流程和优化机制。
|
||
|
||
---
|
||
|
||
## 1. 概述
|
||
|
||
章节运行时是 EPUBUI 层的核心子系统,负责章节的按需加载、缓存管理和页码映射。它位于 `RDEPUBReaderContext` → `RDEPUBReaderRuntime` 架构中,是实现大书(如 1000+ 章的网络小说)流畅阅读的关键。
|
||
|
||
**核心设计目标**:
|
||
- 快速打开:用户点击书籍后 1-2 秒内可开始阅读
|
||
- 按需加载:只加载当前窗口内的章节,内存占用可控
|
||
- 渐进补全:后台逐步补全所有章节的页码信息
|
||
|
||
**关键文件**:
|
||
- `Sources/RDEpubReaderView/EPUBUI/ReaderController/ChapterRuntime/` 目录
|
||
- `Sources/RDEpubReaderView/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`
|
||
|
||
内存缓存,存储当前窗口内的章节数据。
|
||
|
||
```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)
|
||
}
|
||
```
|
||
|
||
**窗口驱逐策略**:
|
||
|
||
```swift
|
||
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` 更新窗口,然后驱逐窗口外章节
|
||
|
||
**内存警告处理**:
|
||
|
||
```swift
|
||
func handleMemoryWarning() {
|
||
evictAllExceptCurrent() // 驱逐除当前章节外的所有缓存
|
||
imageCache.removeAllObjects() // 清空图片缓存
|
||
}
|
||
```
|
||
|
||
**导航优先级抢占**:
|
||
|
||
当用户快速翻页时,新的导航请求可以抢占正在构建的章节:
|
||
|
||
```swift
|
||
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)
|
||
|
||
**批量读取**:
|
||
|
||
```swift
|
||
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`
|
||
|
||
```swift
|
||
struct RDEPUBChapterCacheKey: Hashable {
|
||
let bookID: String // 书籍唯一标识
|
||
let spineIndex: Int // 章节索引
|
||
let renderSignature: String // 渲染参数签名
|
||
let chapterContentHash: String // 章节 HTML 的 SHA-256
|
||
}
|
||
```
|
||
|
||
**renderSignature 组成**:
|
||
|
||
```swift
|
||
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 加载流程
|
||
|
||
```swift
|
||
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 加载优先级
|
||
|
||
```swift
|
||
enum LoadPriority {
|
||
case navigation // 用户导航触发(最高优先级,可抢占)
|
||
case preview // 预览触发
|
||
case prefetch // 预取触发(最低优先级)
|
||
}
|
||
```
|
||
|
||
**导航优先级抢占**:当 `priority == .navigation` 时,完成构建后检查 `consumeNavigationTarget()`,如有新目标则立即切换。
|
||
|
||
### 4.4 同步加载
|
||
|
||
```swift
|
||
func loadChapterSynchronouslyForMigration(
|
||
spineIndex: Int,
|
||
store: RDEPUBChapterRuntimeStore?
|
||
) throws -> RDEPUBRuntimeChapter
|
||
```
|
||
|
||
使用信号量阻塞当前线程,等待章节加载完成。用于快速打开路径中加载首个可渲染章节。
|
||
|
||
---
|
||
|
||
## 5. RDEPUBBookPageMap — 轻量页码映射
|
||
|
||
**文件**:`RDEPUBBookPageMap.swift`
|
||
|
||
轻量级全书页码映射,不持有 NSAttributedString,内存占用约 100KB/1000 章。
|
||
|
||
### 5.1 数据结构
|
||
|
||
```swift
|
||
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 页码查询
|
||
|
||
```swift
|
||
// 绝对页码 → 章节索引(二分查找,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 增量构建
|
||
|
||
```swift
|
||
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`
|
||
|
||
将绝对页码解析为章节和本地页码的组合。
|
||
|
||
```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 整体流程
|
||
|
||
```
|
||
RDEPUBMetadataParseWorker.start(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:
|
||
|
||
```swift
|
||
var contentHashBySpineIndex: [Int: String] = [:]
|
||
for spineIndex in allBuildableIndices {
|
||
let html = parser.htmlString(forRelativePath: href)
|
||
contentHashBySpineIndex[spineIndex] = html?.rd_sha256Hex ?? ""
|
||
}
|
||
```
|
||
|
||
后续所有 `chapterCacheKey` 调用都使用预计算值。
|
||
|
||
### 7.3 冻结 renderSignature
|
||
|
||
**问题**:用户在后台解析进行中更改字号/行距,会导致部分章节用旧签名、部分用新签名写入缓存。
|
||
|
||
**解决**:在 `RDEPUBMetadataParseWorker` 初始化时冻结签名:
|
||
|
||
```swift
|
||
let renderSignature = context.currentRenderSignature()
|
||
// 后续所有 chapterCacheKey 调用使用此固定值
|
||
```
|
||
|
||
token 机制确保设置变更会触发新的解析任务(新 token),旧任务自动废弃。
|
||
|
||
### 7.4 锁区瘦身
|
||
|
||
**原始实现**:`resultLock` 内调用 `buildPageMap()`,遍历全量 catalog 和 summaries。
|
||
|
||
**优化**:锁内只做写入和计数,快照数据后锁外构建 pageMap:
|
||
|
||
```swift
|
||
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 可配置刷新间隔
|
||
|
||
```swift
|
||
static var pageMapRefreshInterval: Int = 32 // 默认 32 章
|
||
```
|
||
|
||
可实测调优:32 / 48 / 64。值越大,UI 刷新频率越低,后台解析吞吐越高。
|
||
|
||
### 7.6 用户交互冷却
|
||
|
||
等待用户操作冷却 0.8 秒后再开始后台解析,避免与用户翻页操作竞争资源:
|
||
|
||
```swift
|
||
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 版本 |
|