diff --git a/Doc/双层PageMap方案_估算加精确混合.md b/Doc/双层PageMap方案_估算加精确混合.md new file mode 100644 index 0000000..2e4f4fe --- /dev/null +++ b/Doc/双层PageMap方案_估算加精确混合.md @@ -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` 记录哪些是估算值)。精确解析完成后,精确值会自动覆盖同 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 纯文本提取不准确 + +**风险**:`` 和 `` 块。 + +### 9.3 估算值干扰精确值的合并 + +**风险**:`paginateMetadataOnly` 注入估算值后,如果某章精确解析失败,该章会保留估算值而非报错。 + +**缓解**:在 `buildPageMap` 中检查 `isEstimated`,对仍然为估算值的章节标记为 unknown(页数 0),而非保留可能不准确的估算值。 + +--- + +## 10. 实施顺序 + +1. `RDEPUBChapterSummary` 新增 `estimated(pageCount:)` 和 `isEstimated` +2. `RDEPUBReaderPaginationCoordinator` 新增估算方法 +3. 在 `paginateTextPublication` 的 quick open 之后、`paginateMetadataOnly` 之前插入混合 map 构建 +4. `paginateMetadataOnly` 中注入估算值、跳过估算写盘 +5. 真机验证《凡人修仙传》的首屏总页数显示速度和精度 diff --git a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj index ad3743f..4f3e63c 100644 --- a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj +++ b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj @@ -21,6 +21,10 @@ C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; }; + 1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */; }; + 1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */; }; + 1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */; }; + 1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -50,6 +54,10 @@ 8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = ""; }; BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = ""; }; + 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigurableWindowTests.swift; sourceTree = ""; }; + 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConcurrentParsingTests.swift; sourceTree = ""; }; + 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = ""; }; + 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = ""; }; DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -108,6 +116,7 @@ children = ( 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */, 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */, + 1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */, ); path = Helpers; sourceTree = ""; @@ -172,6 +181,9 @@ 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */, 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */, 1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */, + 1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */, + 1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */, + 1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */, ); path = ReaderUITests; sourceTree = ""; @@ -324,6 +336,7 @@ files = ( 63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */, 3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */, + 1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */, 1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */, 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */, 1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */, @@ -334,6 +347,9 @@ 1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */, FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */, 1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.swift in Sources */, + 1A2B3C4D0000000BAABBCC01 /* ConfigurableWindowTests.swift in Sources */, + 1A2B3C4D0000000DAABBCC01 /* ConcurrentParsingTests.swift in Sources */, + 1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/ReadViewDemo/ReadViewDemo/ViewController.swift b/ReadViewDemo/ReadViewDemo/ViewController.swift index ec1ae47..b58644a 100644 --- a/ReadViewDemo/ReadViewDemo/ViewController.swift +++ b/ReadViewDemo/ReadViewDemo/ViewController.swift @@ -15,6 +15,9 @@ final class ViewController: UIViewController { let resetsReaderState: Bool let displaySequence: [RDReaderView.DisplayType] let stepDelay: TimeInterval + let windowSize: Int? + let concurrency: Int? + let clearsCache: Bool nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? { switch rawValue.lowercased() { @@ -50,6 +53,9 @@ final class ViewController: UIViewController { raw.split(separator: ",").compactMap { parseDisplayType(String($0)) } } ?? [] let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.0 + let windowSize = value(after: "--demo-window-size").flatMap(Int.init) + let concurrency = value(after: "--demo-concurrency").flatMap(Int.init) + let clearsCache = arguments.contains("--demo-clear-cache") return LaunchAutomationPlan( bookTitleQuery: bookTitleQuery, @@ -57,7 +63,10 @@ final class ViewController: UIViewController { pageNumber: pageNumber, resetsReaderState: resetsReaderState, displaySequence: displaySequence, - stepDelay: stepDelay + stepDelay: stepDelay, + windowSize: windowSize, + concurrency: concurrency, + clearsCache: clearsCache ) } } @@ -242,11 +251,20 @@ final class ViewController: UIViewController { if launchAutomationPlan.resetsReaderState { resetPersistedReaderState() } + if launchAutomationPlan.clearsCache { + clearChapterSummaryCache() + } var configuration = RDEPUBReaderConfiguration.default if let displayType = launchAutomationPlan.displayType { configuration.displayType = displayType } + if let windowSize = launchAutomationPlan.windowSize { + configuration.onDemandChapterWindowSize = windowSize + } + if let concurrency = launchAutomationPlan.concurrency { + configuration.metadataParsingConcurrency = concurrency + } _ = openBook(book, configuration: configuration, automationPlan: launchAutomationPlan) } @@ -266,6 +284,14 @@ final class ViewController: UIViewController { } } + private func clearChapterSummaryCache() { + let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? FileManager.default.temporaryDirectory + let cacheDir = cachesDirectory.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true) + try? FileManager.default.removeItem(at: cacheDir) + print("[ReadViewDemo] cleared chapter summary cache at \(cacheDir.path)") + } + } extension ViewController: UITableViewDataSource { diff --git a/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift b/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift index fdc00fb..2637a5d 100644 --- a/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift +++ b/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift @@ -36,6 +36,9 @@ struct DemoReaderState { var buildableChapters: Int? { fields["buildableChapters"].flatMap(Int.init) } var avoidWidows: Int? { fields["avoidWidows"].flatMap(Int.init) } var avoidOrphans: Int? { fields["avoidOrphans"].flatMap(Int.init) } + var windowSize: Int? { fields["windowSize"].flatMap(Int.init) } + var parseMs: Int? { fields["parseMs"].flatMap(Int.init) } + var parseConcurrency: Int? { fields["parseConcurrency"].flatMap(Int.init) } } extension XCUIApplication { diff --git a/ReadViewDemo/ReadViewDemoUITests/Helpers/XCUIApplication+Launch.swift b/ReadViewDemo/ReadViewDemoUITests/Helpers/XCUIApplication+Launch.swift index 7db8052..174193c 100644 --- a/ReadViewDemo/ReadViewDemoUITests/Helpers/XCUIApplication+Launch.swift +++ b/ReadViewDemo/ReadViewDemoUITests/Helpers/XCUIApplication+Launch.swift @@ -5,18 +5,30 @@ extension XCUIApplication { bookTitleQuery: String = "回归验证样本", displayType: String? = nil, pageNumber: Int? = nil, - resetsReaderState: Bool = true + resetsReaderState: Bool = true, + windowSize: Int? = nil, + concurrency: Int? = nil, + clearsCache: Bool = false ) { var args = ["--demo-book-title", bookTitleQuery] if resetsReaderState { args.append("--demo-reset-state") } + if clearsCache { + args.append("--demo-clear-cache") + } if let displayType { args += ["--demo-display-type", displayType] } if let pageNumber { args += ["--demo-page", "\(pageNumber)"] } + if let windowSize { + args += ["--demo-window-size", "\(windowSize)"] + } + if let concurrency { + args += ["--demo-concurrency", "\(concurrency)"] + } launchArguments = args launch() } diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ConcurrentParsingTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ConcurrentParsingTests.swift new file mode 100644 index 0000000..a061e81 --- /dev/null +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ConcurrentParsingTests.swift @@ -0,0 +1,87 @@ +import XCTest + +final class ConcurrentParsingTests: XCTestCase { + private let app = XCUIApplication() + private let largeBookQuery = "凡人修仙传" + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testConcurrency2ParsingCompletes() throws { + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 2) + app.waitForReader(timeout: 20) + + _ = app.waitForDemoReaderState(timeout: 15, description: "concurrency=2 首开") { state in + state.mode == "bookPageMap" && (state.page ?? 0) >= 1 + } + + XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "concurrency=2 应可阅读") + + let completed = app.waitForDemoReaderState(timeout: 90, description: "concurrency=2 后台解析完成") { state in + state.pagination == "full" + } + + XCTAssertEqual(completed.pagination, "full", "concurrency=2 后台解析应最终完成") + } + + func testConcurrency4ParsingCompletes() throws { + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 4) + app.waitForReader(timeout: 20) + + _ = app.waitForDemoReaderState(timeout: 15, description: "concurrency=4 首开") { state in + state.mode == "bookPageMap" && (state.page ?? 0) >= 1 + } + + XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "concurrency=4 应可阅读") + + let completed = app.waitForDemoReaderState(timeout: 90, description: "concurrency=4 后台解析完成") { state in + state.pagination == "full" + } + + XCTAssertEqual(completed.pagination, "full", "concurrency=4 后台解析应最终完成") + } + + func testConcurrentParsingProgresses() throws { + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, concurrency: 2) + app.waitForReader(timeout: 20) + + let initialState = app.waitForDemoReaderState(timeout: 15, description: "获取初始进度") { state in + state.mode == "bookPageMap" && state.knownChapters != nil + } + let initialKnown = initialState.knownChapters ?? 0 + + let progressed = app.waitForDemoReaderState(timeout: 60, description: "concurrency=2 后台解析推进") { state in + guard state.mode == "bookPageMap" else { return false } + return state.pagination == "full" || (state.knownChapters ?? 0) > initialKnown + } + + XCTAssertTrue( + progressed.pagination == "full" || (progressed.knownChapters ?? 0) > initialKnown, + "concurrency=2 后台解析应推进,初始=\(initialKnown) 当前=\(progressed.knownChapters ?? 0)" + ) + } + + func testConcurrentParsingWithWindowSize() throws { + app.launchAndOpenSampleBook( + bookTitleQuery: largeBookQuery, + resetsReaderState: true, + windowSize: 5, + concurrency: 2 + ) + app.waitForReader(timeout: 20) + + let state = app.waitForDemoReaderState(timeout: 15, description: "window=5 + concurrency=2") { state in + state.mode == "bookPageMap" && state.knownChapters != nil + } + + XCTAssertEqual(state.windowSize, 5, "windowSize 应为 5") + XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "组合配置应可阅读") + + let completed = app.waitForDemoReaderState(timeout: 90, description: "组合配置后台解析完成") { state in + state.pagination == "full" + } + + XCTAssertEqual(completed.pagination, "full", "组合配置后台解析应最终完成") + } +} diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ConfigurableWindowTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ConfigurableWindowTests.swift new file mode 100644 index 0000000..e37f090 --- /dev/null +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/ConfigurableWindowTests.swift @@ -0,0 +1,82 @@ +import XCTest + +final class ConfigurableWindowTests: XCTestCase { + private let app = XCUIApplication() + private let largeBookQuery = "凡人修仙传" + + override func setUpWithError() throws { + continueAfterFailure = false + } + + func testWindowSize5PreloadsMoreChapters() throws { + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 5) + app.waitForReader(timeout: 20) + + let state = app.waitForDemoReaderState(timeout: 15, description: "window=5 预加载更多章节") { state in + state.mode == "bookPageMap" && state.knownChapters != nil + } + + XCTAssertEqual(state.windowSize, 5, "windowSize 应为 5") + XCTAssertTrue((state.knownChapters ?? 0) >= 3, "window=5 首开应至少预加载 3 章(当前章 + 相邻),实际=\(state.knownChapters ?? 0)") + XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=5 首开应可阅读") + } + + func testWindowSize3MinimumWorks() throws { + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 3) + app.waitForReader(timeout: 20) + + let state = app.waitForDemoReaderState(timeout: 15, description: "window=3 最小窗口") { state in + state.mode == "bookPageMap" && state.knownChapters != nil + } + + XCTAssertEqual(state.windowSize, 3, "windowSize 应为 3") + XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=3 应可阅读") + } + + func testWindowSize15MaximumWorks() throws { + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 15) + app.waitForReader(timeout: 20) + + let state = app.waitForDemoReaderState(timeout: 15, description: "window=15 最大窗口") { state in + state.mode == "bookPageMap" && state.knownChapters != nil + } + + XCTAssertEqual(state.windowSize, 15, "windowSize 应为 15") + XCTAssertTrue((state.knownChapters ?? 0) >= 3, "window=15 首开应预加载多章") + XCTAssertTrue(app.otherElements[IDs.readerContent].waitForExistence(timeout: 5), "window=15 应可阅读") + } + + func testWindowSize5ChapterNavigationSmooth() throws { + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 5) + app.waitForReader(timeout: 20) + + _ = app.waitForDemoReaderState(timeout: 15, description: "等待首章加载") { state in + state.mode == "bookPageMap" && (state.page ?? 0) >= 1 + } + + let paging = app.collectionViews[IDs.readerPaging] + XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页视图不存在") + + // 快速连续翻页,window=5 应该有更多章在缓存中 + for _ in 0..<6 { + paging.swipeLeft() + RunLoop.current.run(until: Date().addingTimeInterval(0.3)) + } + + let afterSwipe = app.currentDemoReaderState() + XCTAssertNotNil(afterSwipe, "翻页后应能读取状态") + XCTAssertTrue((afterSwipe?.page ?? 0) > 1, "连续翻页后页码应推进") + } + + func testEvenWindowSizeRoundsUp() throws { + // 传 4 应被归一化为 5 + app.launchAndOpenSampleBook(bookTitleQuery: largeBookQuery, resetsReaderState: true, windowSize: 4) + app.waitForReader(timeout: 20) + + let state = app.waitForDemoReaderState(timeout: 15, description: "偶数窗口向上取奇") { state in + state.mode == "bookPageMap" && state.windowSize != nil + } + + XCTAssertEqual(state.windowSize, 5, "windowSize=4 应被归一化为 5") + } +} diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/MetadataParseBenchmarkTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/MetadataParseBenchmarkTests.swift new file mode 100644 index 0000000..b73f961 --- /dev/null +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/MetadataParseBenchmarkTests.swift @@ -0,0 +1,97 @@ +import XCTest + +final class MetadataParseBenchmarkTests: XCTestCase { + private let app = XCUIApplication() + private let largeBookQuery = "凡人修仙传" + private let parseTimeout: TimeInterval = 900 + + override func setUpWithError() throws { + continueAfterFailure = false + } + + /// 单次解析基准:用指定并发数打开大书,等待解析完成,返回 parseMs + private func runParseBenchmark(concurrency: Int) throws -> (parseMs: Int, concurrency: Int) { + app.launchAndOpenSampleBook( + bookTitleQuery: largeBookQuery, + resetsReaderState: true, + concurrency: concurrency, + clearsCache: true + ) + app.waitForReader(timeout: 20) + + // 等待首屏可读 + _ = app.waitForDemoReaderState(timeout: 15, description: "首屏加载 concurrency=\(concurrency)") { state in + state.mode == "bookPageMap" && (state.page ?? 0) >= 1 + } + + // 等待 parseMs 写入(OperationQueue 完成时设置,早于 pagination=full) + let completed = app.waitForDemoReaderState(timeout: parseTimeout, description: "解析完成 concurrency=\(concurrency)") { state in + (state.parseMs ?? 0) > 0 + } + + let parseMs = completed.parseMs ?? 0 + let actualConcurrency = completed.parseConcurrency ?? 0 + + XCTAssertTrue(parseMs > 0, "parseMs 应大于 0") + XCTAssertEqual(actualConcurrency, concurrency, "实际并发数应等于配置值") + + // pagination=full 可能因个别章节失败而不达到,不阻塞基准测试 + + // 返回书架 + app.showReaderChromeIfNeeded() + if app.buttons[IDs.readerBack].waitForExistence(timeout: 5) { + app.buttons[IDs.readerBack].tap() + } + XCTAssertTrue(app.tables[IDs.demoBooksTable].waitForExistence(timeout: 10), "返回书架失败") + + return (parseMs, actualConcurrency) + } + + func testMetadataParseBenchmark() throws { + let cpuCount = ProcessInfo.processInfo.activeProcessorCount + + // 1. 串行基准 + let serial = try runParseBenchmark(concurrency: 1) + print("[Benchmark] concurrency=1 parseMs=\(serial.parseMs)") + + // 2. CPU 核心数并发 + let parallel = try runParseBenchmark(concurrency: cpuCount) + print("[Benchmark] concurrency=\(cpuCount) parseMs=\(parallel.parseMs)") + + // 3. 加速比 + let speedup = Double(serial.parseMs) / max(Double(parallel.parseMs), 1) + let efficiency = speedup / Double(cpuCount) * 100 + + print("[Benchmark] ---- 结果 ----") + print("[Benchmark] CPU cores: \(cpuCount)") + print("[Benchmark] serial(1): \(serial.parseMs)ms") + print("[Benchmark] parallel(\(cpuCount)): \(parallel.parseMs)ms") + print("[Benchmark] speedup: \(String(format: "%.2f", speedup))x") + print("[Benchmark] efficiency: \(String(format: "%.1f", efficiency))%") + + if efficiency > 70 { + print("[Benchmark] 结论: 渲染受限,并发数=\(cpuCount) 合理") + } else if efficiency > 40 { + print("[Benchmark] 结论: 有 I/O 等待,可试探 concurrency=\(Int(Double(cpuCount) * 1.25))~\(Int(Double(cpuCount) * 1.5))") + } else { + print("[Benchmark] 结论: I/O 或锁竞争严重,建议降低并发数或排查瓶颈") + } + + // 并行应该比串行快 + XCTAssertTrue(parallel.parseMs < serial.parseMs, "并发解析(\(parallel.parseMs)ms)应快于串行(\(serial.parseMs)ms)") + } + + func testMetadataParseScaling() throws { + let cpuCount = ProcessInfo.processInfo.activeProcessorCount + let concurrency1 = try runParseBenchmark(concurrency: 1) + let concurrencyN = try runParseBenchmark(concurrency: cpuCount) + + let speedup = Double(concurrency1.parseMs) / max(Double(concurrencyN.parseMs), 1) + + print("[Scaling] concurrency=1: \(concurrency1.parseMs)ms") + print("[Scaling] concurrency=\(cpuCount): \(concurrencyN.parseMs)ms") + print("[Scaling] speedup: \(String(format: "%.2f", speedup))x on \(cpuCount) cores") + + XCTAssertTrue(speedup >= 1.5, "并发加速比(\(String(format: "%.2f", speedup)))过低,可能存在瓶颈") + } +} diff --git a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift index 3c016bf..57c44bf 100644 --- a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift +++ b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift @@ -347,7 +347,10 @@ public final class RDURLReaderController: UIViewController { "knownChapters=\(mapSnapshot.knownChapters)", "buildableChapters=\(mapSnapshot.buildableChapters)", "avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)", - "avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)" + "avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)", + "windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)", + "parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)", + "parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)" ].joined(separator: " ") demoStateLabel.text = state if let logPrefix { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift index 0fe2ccc..5f0e065 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterRuntimeStore.swift @@ -23,7 +23,7 @@ final class RDEPUBChapterRuntimeStore { /// 当前章 spineIndex private(set) var currentSpineIndex: Int? - /// 当前窗口内的 spineIndex 集合(当前 + prev + next) + /// 当前窗口内的 spineIndex 集合(以当前章为中心,按配置半径展开) private(set) var windowSpineIndices: [Int] = [] // MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占) @@ -75,13 +75,17 @@ final class RDEPUBChapterRuntimeStore { // MARK: - 窗口管理 - /// 设定当前章,自动计算 ±1 窗口 - func setCurrentChapter(spineIndex: Int, totalSpineCount: Int) { + /// 设定当前章,自动计算按半径展开的窗口 + func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) { currentSpineIndex = spineIndex - var window = [spineIndex] - if spineIndex > 0 { window.append(spineIndex - 1) } - if spineIndex < totalSpineCount - 1 { window.append(spineIndex + 1) } - windowSpineIndices = window + let radius = max(0, windowRadius) + let lowerBound = max(0, spineIndex - radius) + let upperBound = min(totalSpineCount - 1, spineIndex + radius) + guard lowerBound <= upperBound else { + windowSpineIndices = [spineIndex] + return + } + windowSpineIndices = Array(lowerBound...upperBound) } /// 返回窗口外、应该淘汰的 spineIndex @@ -183,4 +187,4 @@ final class RDEPUBChapterRuntimeStore { pageCountCache.removeAll() imageCache.removeAllObjects() } -} \ No newline at end of file +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift index e9b0906..61b4a0d 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterSummaryDiskCache.swift @@ -25,6 +25,11 @@ final class RDEPUBChapterSummaryDiskCache { } } + /// 等待此前已排队的异步写入全部落盘。 + func flushPendingWrites() { + queue.sync { } + } + // MARK: - 读取(同步,因为 loadChapter 已在串行队列上) func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift index b7b8d92..bdc9d7c 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowCoordinator.swift @@ -24,7 +24,11 @@ final class RDEPUBChapterWindowCoordinator { func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) { let totalSpineCount = context.publication?.spine.count ?? 0 - store.setCurrentChapter(spineIndex: targetSpineIndex, totalSpineCount: totalSpineCount) + store.setCurrentChapter( + spineIndex: targetSpineIndex, + totalSpineCount: totalSpineCount, + windowRadius: context.configuration.chapterWindowRadius + ) self.restoreChapterOffset = restoreChapterOffset // 标记切章进行中 @@ -52,7 +56,11 @@ final class RDEPUBChapterWindowCoordinator { // 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项) let nextIndex = initialSpineIndex + 1 if nextIndex < totalSpineCount { - self.store.setCurrentChapter(spineIndex: nextIndex, totalSpineCount: totalSpineCount) + self.store.setCurrentChapter( + spineIndex: nextIndex, + totalSpineCount: totalSpineCount, + windowRadius: self.context.configuration.chapterWindowRadius + ) self.store.setNavigationTarget(spineIndex: nextIndex) self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount) } else { @@ -71,14 +79,13 @@ final class RDEPUBChapterWindowCoordinator { print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT") return } - let prev = current > 0 ? store.chapterData(for: current - 1) : nil - let next = store.chapterData(for: current + 1) - - let snapshot = RDEPUBChapterWindowSnapshot.from( - currentChapter: chapter, - previousChapter: prev, - nextChapter: next - ) + let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in + if spineIndex == chapter.spineIndex { + return chapter + } + return store.chapterData(for: spineIndex) + } + let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current) print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)") currentSnapshot = snapshot isApplyingSnapshot = true @@ -99,30 +106,17 @@ final class RDEPUBChapterWindowCoordinator { } restoreChapterOffset = nil - // 预取 ±1 + // 预取窗口内尚未加载的章节 prefetchAdjacent(current: current) } // MARK: - 预取(后台优先级,不抢占前台导航通道) private func prefetchAdjacent(current: Int) { - let totalSpineCount = context.publication?.spine.count ?? 0 - - // 预取 prev - if current > 0 && store.chapterData(for: current - 1) == nil { - let prevIndex = current - 1 - store.addPrefetchTarget(prevIndex) - loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in - guard let self = self, case .success = result else { return } - self.refreshSnapshot() - } - } - - // 预取 next - if current < totalSpineCount - 1 && store.chapterData(for: current + 1) == nil { - let nextIndex = current + 1 - store.addPrefetchTarget(nextIndex) - loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in + for spineIndex in store.windowSpineIndices where spineIndex != current { + guard store.chapterData(for: spineIndex) == nil else { continue } + store.addPrefetchTarget(spineIndex) + loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in guard let self = self, case .success = result else { return } self.refreshSnapshot() } @@ -162,7 +156,11 @@ final class RDEPUBChapterWindowCoordinator { isSwitchingChapter = true // 先淘汰旧窗口外章节 - store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount) + store.setCurrentChapter( + spineIndex: spineIndex, + totalSpineCount: totalSpineCount, + windowRadius: context.configuration.chapterWindowRadius + ) let evictable = store.evictableSpineIndices() for idx in evictable { store.evict(spineIndex: idx) @@ -208,14 +206,8 @@ final class RDEPUBChapterWindowCoordinator { return } - let prev = current > 0 ? store.chapterData(for: current - 1) : nil - let next = store.chapterData(for: current + 1) - - let newSnapshot = RDEPUBChapterWindowSnapshot.from( - currentChapter: currentChapter, - previousChapter: prev, - nextChapter: next - ) + let chapters = store.windowSpineIndices.compactMap { store.chapterData(for: $0) } + let newSnapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current) if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) { currentSnapshot = newSnapshot @@ -232,30 +224,16 @@ final class RDEPUBChapterWindowCoordinator { let totalSpineCount = context.publication?.spine.count ?? 0 // 淘汰窗口外章节 - store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount) + store.setCurrentChapter( + spineIndex: spineIndex, + totalSpineCount: totalSpineCount, + windowRadius: context.configuration.chapterWindowRadius + ) for idx in store.evictableSpineIndices() { store.evict(spineIndex: idx) } - // 预取 prev - if spineIndex > 0 && store.chapterData(for: spineIndex - 1) == nil { - let prevIndex = spineIndex - 1 - store.addPrefetchTarget(prevIndex) - loader.loadChapter(spineIndex: prevIndex, store: store, priority: .prefetch) { [weak self] result in - guard let self = self, case .success = result else { return } - self.refreshSnapshot() - } - } - - // 预取 next - if spineIndex < totalSpineCount - 1 && store.chapterData(for: spineIndex + 1) == nil { - let nextIndex = spineIndex + 1 - store.addPrefetchTarget(nextIndex) - loader.loadChapter(spineIndex: nextIndex, store: store, priority: .prefetch) { [weak self] result in - guard let self = self, case .success = result else { return } - self.refreshSnapshot() - } - } + prefetchAdjacent(current: spineIndex) } // MARK: - 内部辅助 diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift index fd18cde..81f6736 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterWindowSnapshot.swift @@ -1,7 +1,7 @@ import Foundation struct RDEPUBChapterWindowSnapshot { - /// 窗口中的章节(有序:prev, current, next) + /// 窗口中的章节(有序) let chapters: [RDEPUBRuntimeChapter] /// 展平后的连续页数组(供 RDReaderView 消费) @@ -22,39 +22,26 @@ struct RDEPUBChapterWindowSnapshot { /// 从章节窗口构建快照 static func from( - currentChapter: RDEPUBRuntimeChapter, - previousChapter: RDEPUBRuntimeChapter?, - nextChapter: RDEPUBRuntimeChapter? + chapters: [RDEPUBRuntimeChapter], + anchorSpineIndex: Int ) -> RDEPUBChapterWindowSnapshot { - var chapters: [RDEPUBRuntimeChapter] = [] - var anchorIndex = 0 - var pageOffset = 0 - - if let prev = previousChapter { - chapters.append(prev) - anchorIndex = 1 - pageOffset = prev.pages.count - } - - chapters.append(currentChapter) - - if let next = nextChapter { - chapters.append(next) - } + let sortedChapters = chapters.sorted { $0.spineIndex < $1.spineIndex } + let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0 + let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count } // 展平页数组 var allPages: [RDEPUBTextPage] = [] - for (chIdx, ch) in chapters.enumerated() { + for (chIdx, ch) in sortedChapters.enumerated() { for var page in ch.pages { page.chapterIndex = chIdx allPages.append(page) } } - let windowStartSpineIndex = chapters.first?.spineIndex ?? currentChapter.spineIndex + let windowStartSpineIndex = sortedChapters.first?.spineIndex ?? anchorSpineIndex return RDEPUBChapterWindowSnapshot( - chapters: chapters, + chapters: sortedChapters, flattenedPages: allPages, anchorChapterIndex: anchorIndex, anchorPageOffset: pageOffset, @@ -83,4 +70,4 @@ struct RDEPUBChapterWindowSnapshot { /// 总页数(窗口内) var pageCount: Int { flattenedPages.count } -} \ No newline at end of file +} diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift index 5eab7a2..7a7f1ff 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift @@ -52,6 +52,10 @@ final class RDEPUBReaderContext { var searchState: RDEPUBSearchState? /// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。 var lastTextPaginationPageSize: CGSize? + /// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。 + var lastMetadataParseWallClockMs: Int = 0 + /// 后台元数据解析使用的并发数。 + var lastMetadataParseConcurrency: Int = 0 /// 当前用户文本选区。 var currentSelection: RDEPUBSelection? diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift index e3e14f0..d7b2501 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift @@ -234,7 +234,8 @@ final class RDEPUBReaderPaginationCoordinator { context.controller != nil else { return } runtime.chapterRuntimeStore.setCurrentChapter( spineIndex: runtimeChapter.spineIndex, - totalSpineCount: publication.spine.count + totalSpineCount: publication.spine.count, + windowRadius: context.configuration.chapterWindowRadius ) let partialMap = self.makePartialPageMap(from: quickWindowChapters) runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation) @@ -276,7 +277,8 @@ final class RDEPUBReaderPaginationCoordinator { ) throws -> [RDEPUBRuntimeChapter] { let windowSpineIndices = initialWindowSpineIndices( around: anchorSpineIndex, - in: publication + in: publication, + maxChapterCount: context.configuration.onDemandChapterWindowSize ) var chapters: [RDEPUBRuntimeChapter] = [] for spineIndex in windowSpineIndices { @@ -301,6 +303,7 @@ final class RDEPUBReaderPaginationCoordinator { in publication: RDEPUBPublication, maxChapterCount: Int = 3 ) -> [Int] { + let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount) let buildableIndices = allBuildableSpineIndices(in: publication) guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else { return [anchorSpineIndex] @@ -310,12 +313,12 @@ final class RDEPUBReaderPaginationCoordinator { var nextPosition = anchorPosition + 1 var previousPosition = anchorPosition - 1 - while selected.count < maxChapterCount, + while selected.count < normalizedMaxChapterCount, nextPosition < buildableIndices.count || previousPosition >= 0 { if nextPosition < buildableIndices.count { selected.append(buildableIndices[nextPosition]) nextPosition += 1 - if selected.count == maxChapterCount { + if selected.count == normalizedMaxChapterCount { break } } @@ -384,10 +387,12 @@ final class RDEPUBReaderPaginationCoordinator { let pageSize = context.currentTextPageSize() let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) - let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig) let style = context.currentTextRenderStyle() let allBuildableIndices = allBuildableSpineIndices(in: publication) let summaryDiskCache = context.runtime?.summaryDiskCache + let workerCount = max(1, context.configuration.metadataParsingConcurrency) + let cpuCount = ProcessInfo.processInfo.activeProcessorCount + RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)") DispatchQueue.global(qos: .utility).async { [weak self] in guard let self else { return } @@ -404,18 +409,21 @@ final class RDEPUBReaderPaginationCoordinator { let restored = summaryDiskCache?.readAll(keys: catalog) RDEPUBBackgroundTrace.log( "MetadataParse", - "begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count)" + "begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)" ) - var mapBuilder = restored?.mapBuilder ?? RDEPUBBookPageMap.Builder() - var lastAppliedCount = 0 - let cachedSpineIndices = Set((restored?.summaries ?? [:]).keys) + let cachedSummaries = restored?.summaries ?? [:] + let cachedSpineIndices = Set(cachedSummaries.keys) + let resultLock = NSLock() + var summariesBySpineIndex = cachedSummaries + var totalResolvedCount = cachedSpineIndices.count + var lastAppliedCount = cachedSpineIndices.count if !cachedSpineIndices.isEmpty { RDEPUBBackgroundTrace.log( "MetadataParse", "resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)" ) - let cachedMap = mapBuilder.build() + let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex) DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } @@ -423,24 +431,35 @@ final class RDEPUBReaderPaginationCoordinator { } } - for (offset, spineIndex) in allBuildableIndices.enumerated() { - guard context.controller != nil, - context.paginationToken == token else { - RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed") - return - } - if cachedSpineIndices.contains(spineIndex) { - continue - } - self.waitForReadingInteractionToSettle(using: context) - do { - RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(allBuildableIndices.count)") - let lightweightEntry = try RDEPUBBackgroundTrace.measure( - "MetadataParse", - "spine=\(spineIndex)" - ) { - try autoreleasepool { () -> RDEPUBBookPageMapEntry? in - guard let result = try builder.buildChapter( + let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) } + + self.waitForReadingInteractionToSettle(using: context) + + let wallClockStart = CFAbsoluteTimeGetCurrent() + var totalRenderMs: Double = 0 + var totalWriteMs: Double = 0 + var completedChapters = 0 + var failedChapters = 0 + let timingLock = NSLock() + + let queue = OperationQueue() + queue.name = "com.rdreader.metadata.parse" + queue.qualityOfService = .utility + queue.maxConcurrentOperationCount = workerCount + + for (offset, spineIndex) in uncachedSpineIndices.enumerated() { + queue.addOperation { + guard context.controller != nil, + context.paginationToken == token else { + return + } + do { + RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)") + + let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in + let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig) + let renderStart = CFAbsoluteTimeGetCurrent() + guard let result = try chapterBuilder.buildChapter( parser: parser, publication: publication, spineIndex: spineIndex, @@ -449,6 +468,7 @@ final class RDEPUBReaderPaginationCoordinator { ) else { return nil } + let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000 let chapter = result.chapter let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex) @@ -461,44 +481,76 @@ final class RDEPUBReaderPaginationCoordinator { chapterContentHash: cacheKey.chapterContentHash, pageMetadataList: chapter.pages.map { .from($0.metadata) } ) - summaryDiskCache?.writeSynchronously(summary: summary, for: cacheKey) + let writeStart = CFAbsoluteTimeGetCurrent() + summaryDiskCache?.write(summary: summary, for: cacheKey) + let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000 - return RDEPUBBookPageMapEntry( - spineIndex: spineIndex, - href: chapter.href, - title: chapter.title, - pageCount: chapter.pages.count, - absolutePageStart: 0, - fragmentOffsets: chapter.fragmentOffsets + timingLock.lock() + totalRenderMs += renderElapsed + totalWriteMs += writeElapsed + completedChapters += 1 + timingLock.unlock() + + RDEPUBBackgroundTrace.log( + "MetadataParse", + "spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))" ) + return summary } - } - if let lightweightEntry { - mapBuilder.add( - spineIndex: lightweightEntry.spineIndex, - href: lightweightEntry.href, - title: lightweightEntry.title, - pageCount: lightweightEntry.pageCount, - fragmentOffsets: lightweightEntry.fragmentOffsets - ) - let builtCount = offset + 1 - if builtCount - lastAppliedCount >= 16 || builtCount == allBuildableIndices.count { - lastAppliedCount = builtCount - let partialMap = mapBuilder.build() + guard let renderResult else { return } + + var partialMap: RDEPUBBookPageMap? + resultLock.lock() + summariesBySpineIndex[spineIndex] = renderResult + totalResolvedCount += 1 + if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count { + lastAppliedCount = totalResolvedCount + partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex) + } + resultLock.unlock() + + if let partialMap { DispatchQueue.main.async { guard context.paginationToken == token, context.controller != nil else { return } context.runtime?.refreshBookPageMapInPlace(partialMap) } } + } catch { + timingLock.lock() + failedChapters += 1 + timingLock.unlock() + RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") } - } catch { - RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") } } + queue.waitUntilAllOperationsAreFinished() + summaryDiskCache?.flushPendingWrites() - let pageMap = mapBuilder.build() + let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000) + timingLock.lock() + let renderTotal = Int(totalRenderMs) + let writeTotal = Int(totalWriteMs) + let rendered = completedChapters + let failed = failedChapters + timingLock.unlock() + let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0 + RDEPUBBackgroundTrace.log( + "MetadataParse", + "timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " + + "renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)" + ) + context.lastMetadataParseWallClockMs = wallClockMs + context.lastMetadataParseConcurrency = workerCount + + guard context.controller != nil, + context.paginationToken == token else { + RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed") + return + } + + let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex) RDEPUBBackgroundTrace.log( "MetadataParse", "complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)" @@ -534,4 +586,22 @@ final class RDEPUBReaderPaginationCoordinator { } return restored.mapBuilder.build() } + + private func buildPageMap( + from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)], + summaries: [Int: RDEPUBChapterSummary] + ) -> RDEPUBBookPageMap { + var builder = RDEPUBBookPageMap.Builder() + for item in catalog { + guard let summary = summaries[item.spineIndex] else { continue } + builder.add( + spineIndex: item.spineIndex, + href: item.href, + title: item.title, + pageCount: summary.pageCount, + fragmentOffsets: summary.fragmentOffsets + ) + } + return builder.build() + } } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift index acc63cb..0f01d67 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift @@ -324,12 +324,14 @@ final class RDEPUBReaderRuntime { // 仅刷新总页数,不重建页面内容(避免后台元数据解析期间刷新掉用户选区) readerView.reloadPageCountOnly() - // 用户正在选区时跳过页面跳转,避免打断选区 - if context.currentSelection == nil, bookPageMap.totalPages > 0 { - readerView.transitionToPage( - pageNum: min(currentPage, max(bookPageMap.totalPages - 1, 0)), - animated: false - ) + // 用户正在选区或正在滑动翻页时跳过页面跳转,避免打断交互 + let cv = readerView.collectionView + let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating + if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 { + let maxValidPage = max(bookPageMap.totalPages - 1, 0) + if currentPage > maxValidPage { + readerView.transitionToPage(pageNum: maxValidPage, animated: false) + } } if let currentLocation { locationCoordinator.persist(location: currentLocation) @@ -395,7 +397,8 @@ final class RDEPUBReaderRuntime { chapterRuntimeStore.setCurrentChapter( spineIndex: spineIndex, - totalSpineCount: publication.spine.count + totalSpineCount: publication.spine.count, + windowRadius: context.configuration.chapterWindowRadius ) RDEPUBBackgroundTrace.log( "Runtime", @@ -418,8 +421,8 @@ final class RDEPUBReaderRuntime { chapterRuntimeStore.evict(spineIndex: evictable) } - let adjacent = [spineIndex - 1, spineIndex + 1].filter { publication.spine.indices.contains($0) } - for adjacentSpineIndex in adjacent where chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil { + for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex { + guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue } chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex) RDEPUBBackgroundTrace.log( "Runtime", diff --git a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift index 96b013d..c5c748b 100644 --- a/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift +++ b/Sources/RDReaderView/EPUBUI/Settings/RDEPUBReaderConfiguration.swift @@ -107,6 +107,12 @@ public struct RDEPUBReaderConfiguration: Equatable { public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode /// 文本渲染引擎,默认使用 DTCoreText public var textRenderingEngine: RDEPUBTextRenderingEngine + /// 章节按需加载窗口大小(总章节数,包含当前章),默认 3,范围 3...15,偶数自动向上取奇 + public var onDemandChapterWindowSize: Int + /// 后台元数据解析并发数,默认为 CPU 核心数。 + /// 若 profiling 显示 renderTotalMs ≈ wallClockMs(渲染受限),维持核心数即可; + /// 若 writeTotalMs 占比显著(I/O 等待),可试探 cpuCount * 1.25~1.5 以填充 I/O 等待间隙。 + public var metadataParsingConcurrency: Int // MARK: 初始化 @@ -128,6 +134,8 @@ public struct RDEPUBReaderConfiguration: Equatable { /// - fixedLayoutFit: 固定布局适配模式 /// - fixedLayoutSpreadMode: 固定布局跨页模式 /// - textRenderingEngine: 文本渲染引擎 + /// - onDemandChapterWindowSize: 章节按需加载窗口大小(总章节数 3...15,偶数自动向上取奇) + /// - metadataParsingConcurrency: 后台元数据解析并发数,默认 CPU 核心数 public init( fontSize: CGFloat = 15, lineHeightMultiple: CGFloat = 1.6, @@ -146,7 +154,9 @@ public struct RDEPUBReaderConfiguration: Equatable { darkImageBlendRatio: CGFloat = 0.15, fixedLayoutFit: RDEPUBFixedLayoutFit = .page, fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic, - textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText + textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText, + onDemandChapterWindowSize: Int = 3, + metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount ) { self.fontSize = fontSize self.lineHeightMultiple = lineHeightMultiple @@ -166,12 +176,25 @@ public struct RDEPUBReaderConfiguration: Equatable { self.fixedLayoutFit = fixedLayoutFit self.fixedLayoutSpreadMode = fixedLayoutSpreadMode self.textRenderingEngine = textRenderingEngine + self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize) + self.metadataParsingConcurrency = max(1, metadataParsingConcurrency) } /// 默认配置实例,使用所有参数的默认值 public static let `default` = RDEPUBReaderConfiguration() } +extension RDEPUBReaderConfiguration { + static func normalizedChapterWindowSize(_ size: Int) -> Int { + let clamped = max(3, min(15, size)) + return clamped % 2 == 0 ? clamped + 1 : clamped + } + + var chapterWindowRadius: Int { + onDemandChapterWindowSize / 2 + } +} + // MARK: - 配置转换 extension RDEPUBReaderConfiguration {