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:
@@ -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. 真机验证《凡人修仙传》的首屏总页数显示速度和精度
|
||||
Reference in New Issue
Block a user