feat: configurable chapter window & parallel metadata parsing with benchmark

1. Configurable chapter window size (onDemandChapterWindowSize: 3-15)
   - Parameterized window radius in RDEPUBChapterRuntimeStore
   - Updated RDEPUBChapterWindowCoordinator to use configurable radius
   - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next
   - Even numbers round up to odd (4→5), min 3, max 15

2. Configurable metadata parsing concurrency (metadataParsingConcurrency)
   - Default equals CPU core count
   - Parallel execution via OperationQueue in paginateMetadataOnly
   - Each worker creates independent builder instance
   - NSLock protects result aggregation

3. Per-chapter and total wall-clock timing instrumentation
   - Separated render vs I/O timing per chapter
   - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs
   - Timing stored in RDEPUBReaderContext for test access

4. UI automation test infrastructure
   - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args
   - DemoReaderState exposes windowSize, parseMs, parseConcurrency
   - ConfigurableWindowTests: 5 test cases for window size 3/5/15
   - ConcurrentParsingTests: 4 test cases for concurrency 2/4
   - MetadataParseBenchmarkTests: serial vs parallel benchmark

5. Bug fixes
   - Fixed page snap-back during background parsing (isUserInteracting check)
   - Reduced BookPageMap refresh frequency from 16 to 32 chapters
   - Moved waitForReadingInteractionToSettle outside operation loop

6. Design doc: dual-layer PageMap (estimated + precise mixed)
This commit is contained in:
shen
2026-06-03 23:38:11 +08:00
parent feb05eaf87
commit d20196ee34
17 changed files with 932 additions and 153 deletions
@@ -0,0 +1,379 @@
# 双层 PageMap 方案:估算 + 精确混合
> 目标:大书首次打开时,快速给出全书总页数(< 1s),同时保留当前窗口章节的精确分页结果。
> 约束:估算值不覆盖已精确分页的 quick-window 章节,不写入磁盘缓存,不伪装成精确 summary。
---
## 1. 问题
当前 `paginateMetadataOnly` 对全书逐章做完整渲染 + CoreText 分页 + 写磁盘缓存。2470 章在真机上串行约 867s(并发 6 约 256s)。在此期间:
- 总页数持续变化(从 partial 到 full
- 进度条不准确
- 目录页码缺失
用户看到的是"总页数从 0 慢慢涨到 18595",体验差。
---
## 2. 方案概述
```
quick open(现有,不变)
→ 当前窗口 3-5 章精确分页
→ applyBookPageMap(partialMap, restoreLocation:)
→ 用户可立即阅读
估算补全(新增,< 1s
→ 遍历所有非窗口章节,快速估算 pageCount
→ 与 quick open 的精确值合并为 mixedMap
→ refreshBookPageMapInPlace(mixedMap)
→ 用户立即看到接近真实的全书总页数
精确解析(现有,后台逐步)
→ paginateMetadataOnly 逐章精确渲染
→ 每 32 章用精确值替换 mixedMap 中的估算值
→ refreshBookPageMapInPlace(updatedMap)
→ 总页数微调至精确值
```
---
## 3. 核心约束
### 3.1 不覆盖已精确分页的章节
quick open 已经对当前窗口章(`initialWindowSpineIndices` 返回的 3-5 章)做了完整渲染 + CoreText 分页,生成了精确的 `partialMap`。这个 map 立刻用于 `applyBookPageMap(restoreLocation:)` 恢复阅读位置。
估算层**不能**用全书估算 map 整体替换这个 partial map,否则:
- 当前窗口章的精确 pageCount 被冲掉
- `pageNumber(for:)` 的绝对页号映射漂移
- `prepareOnDemandChapter(forAbsolutePageNumber:)` 定位出错
正确做法:**精确局部 + 估算尾部**的混合 map。窗口章保留精确值,其余章节填估算值。
### 3.2 估算值只存内存,不落盘
估算的 pageCount 必须**只存在于内存态的 BookPageMap**中,不能写入:
- `RDEPUBChapterSummaryDiskCache`(否则 loader 误以为有精确 pageRanges 可复用)
- `RDEPUBPageCountCache`(否则 loader 跳过完整分页)
- 任何磁盘持久化存储
后续 `paginateMetadataOnly` 的精确结果会逐章替换估算值,并写入磁盘缓存。磁盘上永远只有精确数据。
### 3.3 估算方法必须足够快
目标:< 1s 完成全书估算(2470 章)。每章允许 ~0.4ms。
可行方法:遍历 spine,对每章只做 HTML 纯文本提取(`NSString` 去标签),用 `textLength / estimatedCharsPerPage` 得到页数。不做 CSS 渲染、不做NSAttributedString 构建、不做 CoreText 分页。
---
## 4. 数据流
```
┌─────────────────────────────┐
│ quick open (现有) │
│ 当前窗口章 → 精确 partialMap │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ estimateRemainingChapters │
│ 非窗口章 → 估算 pageCount │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ mergePreciseAndEstimated │
│ 精确窗口 + 估算尾部 → mixedMap │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ refreshBookPageMapInPlace │
│ 用户立即看到全书总页数 │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ paginateMetadataOnly (现有) │
│ 逐章精确渲染,替换估算值 │
│ 每 32 章刷新一次 │
└──────────────┬──────────────┘
┌──────────────▼──────────────┐
│ 最终精确 BookPageMap │
└─────────────────────────────┘
```
---
## 5. 实现细节
### 5.1 估算方法
`RDEPUBReaderPaginationCoordinator` 中新增:
```swift
/// 使
/// spineIndex -> estimatedPageCount
private func estimateChapterPageCounts(
for spineIndices: [Int],
publication: RDEPUBPublication,
parser: RDEPUBParser,
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> [Int: Int] {
let charsPerPage = estimatedCharsPerPage(pageSize: pageSize, style: style)
var result: [Int: Int] = [:]
for spineIndex in spineIndices {
let item = publication.spine[spineIndex]
guard item.linear,
item.mediaType.contains("html") || item.mediaType.contains("xhtml"),
let htmlString = parser.htmlString(forRelativePath: item.href) else {
continue
}
// HTML
let plainText = htmlString
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
.trimmingCharacters(in: .whitespacesAndNewlines)
let textLength = plainText.count
let estimatedPages = max(1, Int(ceil(Double(textLength) / Double(charsPerPage))))
result[spineIndex] = estimatedPages
}
return result
}
///
private func estimatedCharsPerPage(
pageSize: CGSize,
style: RDEPUBTextRenderStyle
) -> Double {
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let columns = max(1, layoutConfig.numberOfColumns)
let columnGap = layoutConfig.columnGap
let insets = layoutConfig.edgeInsets
let usableWidth = pageSize.width - insets.left - insets.right - CGFloat(columns - 1) * columnGap
let usableHeight = pageSize.height - insets.top - insets.bottom
let lineHeight = style.fontSize * style.lineHeightMultiple
let linesPerPage = Int(usableHeight / lineHeight) * columns
// 0.5 * fontSize 0.6 * fontSize
// 0.55
let avgCharWidth = style.fontSize * 0.55
let charsPerLine = max(1, Int(usableWidth / avgCharWidth))
return Double(linesPerPage * charsPerLine)
}
```
### 5.2 混合 Map 构建
```swift
/// BookPageMap
private func buildMixedPageMap(
preciseEntries: [Int: RDEPUBBookPageMapEntry], // quick open
estimatedPageCounts: [Int: Int], // pageCount
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for item in catalog {
if let precise = preciseEntries[item.spineIndex] {
//
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: precise.pageCount,
fragmentOffsets: precise.fragmentOffsets
)
} else if let estimatedCount = estimatedPageCounts[item.spineIndex] {
// fragmentOffsets
builder.add(
spineIndex: item.spineIndex,
href: item.href,
title: item.title,
pageCount: estimatedCount,
fragmentOffsets: [:]
)
}
}
return builder.build()
}
```
### 5.3 集成点
`paginateTextPublication` 中,quick open 完成后、`paginateMetadataOnly` 之前插入:
```swift
// quick open partialMap
let quickWindowChapters = try loadInitialRuntimeChapters(...)
let partialMap = makePartialPageMap(from: quickWindowChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
// map
let windowSpineIndices = Set(quickWindowChapters.map(\.spineIndex))
let allBuildable = allBuildableSpineIndices(in: publication)
let remainingSpineIndices = allBuildable.filter { !windowSpineIndices.contains($0) }
let estimatedCounts = estimateChapterPageCounts(
for: remainingSpineIndices,
publication: publication,
parser: parser,
pageSize: pageSize,
style: style
)
let preciseEntries = makePreciseEntries(from: quickWindowChapters)
let catalog = allBuildable.map { ... } // paginateMetadataOnly catalog
let mixedMap = buildMixedPageMap(
preciseEntries: preciseEntries,
estimatedPageCounts: estimatedCounts,
catalog: catalog
)
DispatchQueue.main.async {
runtime.refreshBookPageMapInPlace(mixedMap)
}
//
paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
```
### 5.4 精确值替换估算值
`paginateMetadataOnly` 现有逻辑不需要大改。只需确保:
1. `summariesBySpineIndex` 初始包含缓存命中 + 估算值(作为 fallback
2. 每完成一章精确解析,用精确值覆盖该 spineIndex 的条目
3. `buildPageMap` 时,精确值自然替换估算值
具体改动:在 `paginateMetadataOnly``cachedSummaries` 之后,把估算值也注入 `summariesBySpineIndex`,但标记为估算(比如用一个 `Set<Int>` 记录哪些是估算值)。精确解析完成后,精确值会自动覆盖同 key 的估算值。
```swift
// paginateMetadataOnly cachedSummaries
var summariesBySpineIndex = cachedSummaries
//
for (spineIndex, pageCount) in estimatedPageCounts {
if !cachedSpineIndices.contains(spineIndex) {
// summary pageCount
// pageRanges fragmentOffsets
summariesBySpineIndex[spineIndex] = RDEPUBChapterSummary.estimated(
pageCount: pageCount
)
}
}
```
需要在 `RDEPUBChapterSummary` 上新增一个工厂方法:
```swift
extension RDEPUBChapterSummary {
/// pageCount pageRanges
///
static func estimated(pageCount: Int) -> RDEPUBChapterSummary {
RDEPUBChapterSummary(
pageRanges: [],
pageCount: pageCount,
fragmentOffsets: [:],
renderSignature: "estimated",
schemaVersion: currentSchemaVersion,
chapterContentHash: "estimated",
pageMetadataList: []
)
}
var isEstimated: Bool { renderSignature == "estimated" }
}
```
### 5.5 磁盘缓存保护
`paginateMetadataOnly` 的写盘逻辑中,跳过估算 summary:
```swift
//
if !summary.isEstimated {
summaryDiskCache?.write(summary: summary, for: cacheKey)
}
```
---
## 6. 需要修改的文件
| 文件 | 改动 | 行数估算 |
|---|---|---|
| `RDEPUBReaderPaginationCoordinator.swift` | 新增 `estimateChapterPageCounts``estimatedCharsPerPage``buildMixedPageMap`;在 `paginateTextPublication` 中集成;`paginateMetadataOnly` 中注入估算值并跳过估算写盘 | ~80 行 |
| `RDEPUBChapterSummary` (in `RDEPUBChapterSummaryDiskCache.swift`) | 新增 `estimated(pageCount:)` 工厂方法和 `isEstimated` 属性 | ~10 行 |
| `RDEPUBBookPageMap` (in `RDEPUBBookPageMap.swift`) | 确认 `Builder.add()` 支持 `fragmentOffsets: [:]`(空字典) | 可能 0 行(已支持) |
---
## 7. 不需要修改的文件
| 文件 | 原因 |
|---|---|
| `RDEPUBChapterLoader` | 没有精确缓存时自动走完整渲染 + 分页,已有 fallback |
| `RDEPUBChapterRuntimeStore` | 不涉及 |
| `RDEPUBReaderRuntime` | `refreshBookPageMapInPlace` 已支持替换式刷新 |
| `RDEPUBChapterWindowCoordinator` | 不涉及 |
| `RDReaderView` | 不涉及 |
| `RDEPUBReaderConfiguration` | 不涉及 |
---
## 8. 验证方式
### 8.1 单元验证
- 打开《凡人修仙传》,记录从 `applyBookPageMap``refreshBookPageMapInPlace(mixedMap)` 的耗时,应 < 1s
- 验证 mixedMap 中窗口章的 pageCount 与精确 partialMap 一致
- 验证 mixedMap 中非窗口章的 pageCount 是估算值(与精确值有偏差但量级正确)
- 验证 `paginateMetadataOnly` 完成后,所有章节的 pageCount 被精确值替换
### 8.2 UI 验证
- 首次打开大书,总页数在 1s 内从 0 跳到接近真实值(如 18000+),而非逐步增长
- 当前窗口章的翻页、位置恢复不受影响
- 后台精确解析期间,总页数微调(如 18000 → 18595),无大幅跳变
- 二次打开仍走精确缓存路径,不走估算
### 8.3 基准对比
| 指标 | 当前实现 | 双层方案 |
|---|---|---|
| 首次显示全书总页数 | ~256s(并发 6 | < 1s |
| 总页数精度 | 100%(精确) | 首屏 ~95%(估算),后台 100% |
| 当前章体验 | 不受影响 | 不受影响 |
| 磁盘缓存 | 只有精确值 | 只有精确值(估算不落盘) |
---
## 9. 风险与缓解
### 9.1 估算页数与精确页数偏差大
**风险**:某些章节(图片多、CSS 复杂、中英文混排)的估算页数可能与精确值偏差 20%+。
**缓解**
- 估算只影响总页数显示,不影响阅读体验
- 后台精确解析会逐步替换,偏差是暂时的
- 可在 UI 上标注"页数计算中..."降低用户预期
### 9.2 纯文本提取不准确
**风险**`<script>``<style>` 标签内的文本被误计入,导致估算偏高。
**缓解**:正则去标签时先移除 `<script>...</script>``<style>...</style>` 块。
### 9.3 估算值干扰精确值的合并
**风险**`paginateMetadataOnly` 注入估算值后,如果某章精确解析失败,该章会保留估算值而非报错。
**缓解**:在 `buildPageMap` 中检查 `isEstimated`,对仍然为估算值的章节标记为 unknown(页数 0),而非保留可能不准确的估算值。
---
## 10. 实施顺序
1. `RDEPUBChapterSummary` 新增 `estimated(pageCount:)``isEstimated`
2. `RDEPUBReaderPaginationCoordinator` 新增估算方法
3.`paginateTextPublication` 的 quick open 之后、`paginateMetadataOnly` 之前插入混合 map 构建
4. `paginateMetadataOnly` 中注入估算值、跳过估算写盘
5. 真机验证《凡人修仙传》的首屏总页数显示速度和精度