ReadViewSDK/Doc/CHAPTER_RUNTIME.md
shenlei d7fcda345d refactor: rename RDReaderView -> RDEpubReaderView, update pod config and docs
- 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
2026-07-10 19:44:53 +09:00

426 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 章节运行时详解
> 最后更新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缓存部分
├─ waitForReadingInteractionToSettle0.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 版本 |