ReadViewSDK/Doc/双层PageMap方案_估算加精确混合.md
shen d20196ee34 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)
2026-06-03 23:38:11 +08:00

15 KiB
Raw Blame History

双层 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 集成点

paginateTextPublicationquick 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 现有逻辑不需要大改。只需确保:

  1. summariesBySpineIndex 初始包含缓存命中 + 估算值(作为 fallback
  2. 每完成一章精确解析,用精确值覆盖该 spineIndex 的条目
  3. buildPageMap 时,精确值自然替换估算值

具体改动:在 paginateMetadataOnlycachedSummaries 之后,把估算值也注入 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 新增 estimateChapterPageCountsestimatedCharsPerPagebuildMixedPageMap;在 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 单元验证

  • 打开《凡人修仙传》,记录从 applyBookPageMaprefreshBookPageMapInPlace(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. 真机验证《凡人修仙传》的首屏总页数显示速度和精度