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)
15 KiB
双层 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 中新增:
/// 快速估算非窗口章节的页数,只使用纯文本长度,不渲染。
/// 返回 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 构建
/// 构建混合 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 之前插入:
// 现有: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 现有逻辑不需要大改。只需确保:
summariesBySpineIndex初始包含缓存命中 + 估算值(作为 fallback)- 每完成一章精确解析,用精确值覆盖该 spineIndex 的条目
buildPageMap时,精确值自然替换估算值
具体改动:在 paginateMetadataOnly 的 cachedSummaries 之后,把估算值也注入 summariesBySpineIndex,但标记为估算(比如用一个 Set<Int> 记录哪些是估算值)。精确解析完成后,精确值会自动覆盖同 key 的估算值。
// 在 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 上新增一个工厂方法:
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:
// 写盘前检查
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. 实施顺序
RDEPUBChapterSummary新增estimated(pageCount:)和isEstimatedRDEPUBReaderPaginationCoordinator新增估算方法- 在
paginateTextPublication的 quick open 之后、paginateMetadataOnly之前插入混合 map 构建 paginateMetadataOnly中注入估算值、跳过估算写盘- 真机验证《凡人修仙传》的首屏总页数显示速度和精度