feat: configurable chapter window & parallel metadata parsing with benchmark

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

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

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

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

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

6. Design doc: dual-layer PageMap (estimated + precise mixed)
This commit is contained in:
shen 2026-06-03 23:38:11 +08:00
parent feb05eaf87
commit d20196ee34
17 changed files with 932 additions and 153 deletions

View File

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

View File

@ -21,6 +21,10 @@
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; }; C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; }; DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; }; 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 */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy 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; }; 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 = "<group>"; }; 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; }; BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConfigurableWindowTests.swift; sourceTree = "<group>"; };
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ConcurrentParsingTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MetadataParseBenchmarkTests.swift; sourceTree = "<group>"; };
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DemoReaderState.swift; sourceTree = "<group>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; }; FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -108,6 +116,7 @@
children = ( children = (
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */, 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */,
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */, 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */,
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */,
); );
path = Helpers; path = Helpers;
sourceTree = "<group>"; sourceTree = "<group>";
@ -172,6 +181,9 @@
1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */, 1A2B3C4D00000006AABBCC01 /* SettingsExtendedTests.swift */,
201C2B482287866487EFAE66 /* SettingsPanelTests.swift */, 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */,
1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */, 1A2B3C4D00000004AABBCC01 /* TableOfContentsTests.swift */,
1A2B3C4D0000000CAABBCC01 /* ConfigurableWindowTests.swift */,
1A2B3C4D0000000EAABBCC01 /* ConcurrentParsingTests.swift */,
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
); );
path = ReaderUITests; path = ReaderUITests;
sourceTree = "<group>"; sourceTree = "<group>";
@ -324,6 +336,7 @@
files = ( files = (
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */, 63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */,
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */, 3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */,
1A2B3C4D00000011AABBCC01 /* DemoReaderState.swift in Sources */,
1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */, 1A2B3C4D00000001AABBCC01 /* BookmarkTests.swift in Sources */,
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */, 23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */, 1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
@ -334,6 +347,9 @@
1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */, 1A2B3C4D00000005AABBCC01 /* SettingsExtendedTests.swift in Sources */,
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */, FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */,
1A2B3C4D00000003AABBCC01 /* TableOfContentsTests.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; runOnlyForDeploymentPostprocessing = 0;
}; };

View File

@ -15,6 +15,9 @@ final class ViewController: UIViewController {
let resetsReaderState: Bool let resetsReaderState: Bool
let displaySequence: [RDReaderView.DisplayType] let displaySequence: [RDReaderView.DisplayType]
let stepDelay: TimeInterval let stepDelay: TimeInterval
let windowSize: Int?
let concurrency: Int?
let clearsCache: Bool
nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? { nonisolated private static func parseDisplayType(_ rawValue: String) -> RDReaderView.DisplayType? {
switch rawValue.lowercased() { switch rawValue.lowercased() {
@ -50,6 +53,9 @@ final class ViewController: UIViewController {
raw.split(separator: ",").compactMap { parseDisplayType(String($0)) } raw.split(separator: ",").compactMap { parseDisplayType(String($0)) }
} ?? [] } ?? []
let stepDelay = value(after: "--demo-step-delay").flatMap(TimeInterval.init) ?? 1.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( return LaunchAutomationPlan(
bookTitleQuery: bookTitleQuery, bookTitleQuery: bookTitleQuery,
@ -57,7 +63,10 @@ final class ViewController: UIViewController {
pageNumber: pageNumber, pageNumber: pageNumber,
resetsReaderState: resetsReaderState, resetsReaderState: resetsReaderState,
displaySequence: displaySequence, displaySequence: displaySequence,
stepDelay: stepDelay stepDelay: stepDelay,
windowSize: windowSize,
concurrency: concurrency,
clearsCache: clearsCache
) )
} }
} }
@ -242,11 +251,20 @@ final class ViewController: UIViewController {
if launchAutomationPlan.resetsReaderState { if launchAutomationPlan.resetsReaderState {
resetPersistedReaderState() resetPersistedReaderState()
} }
if launchAutomationPlan.clearsCache {
clearChapterSummaryCache()
}
var configuration = RDEPUBReaderConfiguration.default var configuration = RDEPUBReaderConfiguration.default
if let displayType = launchAutomationPlan.displayType { if let displayType = launchAutomationPlan.displayType {
configuration.displayType = 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) _ = 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 { extension ViewController: UITableViewDataSource {

View File

@ -36,6 +36,9 @@ struct DemoReaderState {
var buildableChapters: Int? { fields["buildableChapters"].flatMap(Int.init) } var buildableChapters: Int? { fields["buildableChapters"].flatMap(Int.init) }
var avoidWidows: Int? { fields["avoidWidows"].flatMap(Int.init) } var avoidWidows: Int? { fields["avoidWidows"].flatMap(Int.init) }
var avoidOrphans: Int? { fields["avoidOrphans"].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 { extension XCUIApplication {

View File

@ -5,18 +5,30 @@ extension XCUIApplication {
bookTitleQuery: String = "回归验证样本", bookTitleQuery: String = "回归验证样本",
displayType: String? = nil, displayType: String? = nil,
pageNumber: Int? = 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] var args = ["--demo-book-title", bookTitleQuery]
if resetsReaderState { if resetsReaderState {
args.append("--demo-reset-state") args.append("--demo-reset-state")
} }
if clearsCache {
args.append("--demo-clear-cache")
}
if let displayType { if let displayType {
args += ["--demo-display-type", displayType] args += ["--demo-display-type", displayType]
} }
if let pageNumber { if let pageNumber {
args += ["--demo-page", "\(pageNumber)"] args += ["--demo-page", "\(pageNumber)"]
} }
if let windowSize {
args += ["--demo-window-size", "\(windowSize)"]
}
if let concurrency {
args += ["--demo-concurrency", "\(concurrency)"]
}
launchArguments = args launchArguments = args
launch() launch()
} }

View File

@ -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", "组合配置后台解析应最终完成")
}
}

View File

@ -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")
}
}

View File

@ -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)))过低,可能存在瓶颈")
}
}

View File

@ -347,7 +347,10 @@ public final class RDURLReaderController: UIViewController {
"knownChapters=\(mapSnapshot.knownChapters)", "knownChapters=\(mapSnapshot.knownChapters)",
"buildableChapters=\(mapSnapshot.buildableChapters)", "buildableChapters=\(mapSnapshot.buildableChapters)",
"avoidWidows=\(layoutConfig?.avoidWidows == true ? 1 : 0)", "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: " ") ].joined(separator: " ")
demoStateLabel.text = state demoStateLabel.text = state
if let logPrefix { if let logPrefix {

View File

@ -23,7 +23,7 @@ final class RDEPUBChapterRuntimeStore {
/// spineIndex /// spineIndex
private(set) var currentSpineIndex: Int? private(set) var currentSpineIndex: Int?
/// spineIndex + prev + next /// spineIndex
private(set) var windowSpineIndices: [Int] = [] private(set) var windowSpineIndices: [Int] = []
// MARK: - vs // MARK: - vs
@ -75,13 +75,17 @@ final class RDEPUBChapterRuntimeStore {
// MARK: - // MARK: -
/// ±1 ///
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int) { func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
currentSpineIndex = spineIndex currentSpineIndex = spineIndex
var window = [spineIndex] let radius = max(0, windowRadius)
if spineIndex > 0 { window.append(spineIndex - 1) } let lowerBound = max(0, spineIndex - radius)
if spineIndex < totalSpineCount - 1 { window.append(spineIndex + 1) } let upperBound = min(totalSpineCount - 1, spineIndex + radius)
windowSpineIndices = window guard lowerBound <= upperBound else {
windowSpineIndices = [spineIndex]
return
}
windowSpineIndices = Array(lowerBound...upperBound)
} }
/// spineIndex /// spineIndex
@ -183,4 +187,4 @@ final class RDEPUBChapterRuntimeStore {
pageCountCache.removeAll() pageCountCache.removeAll()
imageCache.removeAllObjects() imageCache.removeAllObjects()
} }
} }

View File

@ -25,6 +25,11 @@ final class RDEPUBChapterSummaryDiskCache {
} }
} }
///
func flushPendingWrites() {
queue.sync { }
}
// MARK: - loadChapter // MARK: - loadChapter
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? { func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {

View File

@ -24,7 +24,11 @@ final class RDEPUBChapterWindowCoordinator {
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) { func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
let totalSpineCount = context.publication?.spine.count ?? 0 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 self.restoreChapterOffset = restoreChapterOffset
// //
@ -52,7 +56,11 @@ final class RDEPUBChapterWindowCoordinator {
// / linear=false spine // / linear=false spine
let nextIndex = initialSpineIndex + 1 let nextIndex = initialSpineIndex + 1
if nextIndex < totalSpineCount { 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.store.setNavigationTarget(spineIndex: nextIndex)
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount) self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
} else { } else {
@ -71,14 +79,13 @@ final class RDEPUBChapterWindowCoordinator {
print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT") print("[EPUB][WindowCoord] buildSnapshot: currentSpineIndex is nil, ABORT")
return return
} }
let prev = current > 0 ? store.chapterData(for: current - 1) : nil let chapters = store.windowSpineIndices.compactMap { spineIndex -> RDEPUBRuntimeChapter? in
let next = store.chapterData(for: current + 1) if spineIndex == chapter.spineIndex {
return chapter
let snapshot = RDEPUBChapterWindowSnapshot.from( }
currentChapter: chapter, return store.chapterData(for: spineIndex)
previousChapter: prev, }
nextChapter: next let snapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
)
print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)") print("[EPUB][WindowCoord] snapshot: chapters=\(snapshot.chapters.count) pages=\(snapshot.pageCount) anchorPage=\(snapshot.anchorPageOffset)")
currentSnapshot = snapshot currentSnapshot = snapshot
isApplyingSnapshot = true isApplyingSnapshot = true
@ -99,30 +106,17 @@ final class RDEPUBChapterWindowCoordinator {
} }
restoreChapterOffset = nil restoreChapterOffset = nil
// ±1 //
prefetchAdjacent(current: current) prefetchAdjacent(current: current)
} }
// MARK: - // MARK: -
private func prefetchAdjacent(current: Int) { private func prefetchAdjacent(current: Int) {
let totalSpineCount = context.publication?.spine.count ?? 0 for spineIndex in store.windowSpineIndices where spineIndex != current {
guard store.chapterData(for: spineIndex) == nil else { continue }
// prev store.addPrefetchTarget(spineIndex)
if current > 0 && store.chapterData(for: current - 1) == nil { loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
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
guard let self = self, case .success = result else { return } guard let self = self, case .success = result else { return }
self.refreshSnapshot() self.refreshSnapshot()
} }
@ -162,7 +156,11 @@ final class RDEPUBChapterWindowCoordinator {
isSwitchingChapter = true isSwitchingChapter = true
// //
store.setCurrentChapter(spineIndex: spineIndex, totalSpineCount: totalSpineCount) store.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
)
let evictable = store.evictableSpineIndices() let evictable = store.evictableSpineIndices()
for idx in evictable { for idx in evictable {
store.evict(spineIndex: idx) store.evict(spineIndex: idx)
@ -208,14 +206,8 @@ final class RDEPUBChapterWindowCoordinator {
return return
} }
let prev = current > 0 ? store.chapterData(for: current - 1) : nil let chapters = store.windowSpineIndices.compactMap { store.chapterData(for: $0) }
let next = store.chapterData(for: current + 1) let newSnapshot = RDEPUBChapterWindowSnapshot.from(chapters: chapters, anchorSpineIndex: current)
let newSnapshot = RDEPUBChapterWindowSnapshot.from(
currentChapter: currentChapter,
previousChapter: prev,
nextChapter: next
)
if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) { if snapshotContentChanged(old: currentSnapshot, new: newSnapshot) {
currentSnapshot = newSnapshot currentSnapshot = newSnapshot
@ -232,30 +224,16 @@ final class RDEPUBChapterWindowCoordinator {
let totalSpineCount = context.publication?.spine.count ?? 0 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() { for idx in store.evictableSpineIndices() {
store.evict(spineIndex: idx) store.evict(spineIndex: idx)
} }
// prev prefetchAdjacent(current: spineIndex)
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()
}
}
} }
// MARK: - // MARK: -

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
struct RDEPUBChapterWindowSnapshot { struct RDEPUBChapterWindowSnapshot {
/// prev, current, next ///
let chapters: [RDEPUBRuntimeChapter] let chapters: [RDEPUBRuntimeChapter]
/// RDReaderView /// RDReaderView
@ -22,39 +22,26 @@ struct RDEPUBChapterWindowSnapshot {
/// ///
static func from( static func from(
currentChapter: RDEPUBRuntimeChapter, chapters: [RDEPUBRuntimeChapter],
previousChapter: RDEPUBRuntimeChapter?, anchorSpineIndex: Int
nextChapter: RDEPUBRuntimeChapter?
) -> RDEPUBChapterWindowSnapshot { ) -> RDEPUBChapterWindowSnapshot {
var chapters: [RDEPUBRuntimeChapter] = [] let sortedChapters = chapters.sorted { $0.spineIndex < $1.spineIndex }
var anchorIndex = 0 let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
var pageOffset = 0 let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
if let prev = previousChapter {
chapters.append(prev)
anchorIndex = 1
pageOffset = prev.pages.count
}
chapters.append(currentChapter)
if let next = nextChapter {
chapters.append(next)
}
// //
var allPages: [RDEPUBTextPage] = [] var allPages: [RDEPUBTextPage] = []
for (chIdx, ch) in chapters.enumerated() { for (chIdx, ch) in sortedChapters.enumerated() {
for var page in ch.pages { for var page in ch.pages {
page.chapterIndex = chIdx page.chapterIndex = chIdx
allPages.append(page) allPages.append(page)
} }
} }
let windowStartSpineIndex = chapters.first?.spineIndex ?? currentChapter.spineIndex let windowStartSpineIndex = sortedChapters.first?.spineIndex ?? anchorSpineIndex
return RDEPUBChapterWindowSnapshot( return RDEPUBChapterWindowSnapshot(
chapters: chapters, chapters: sortedChapters,
flattenedPages: allPages, flattenedPages: allPages,
anchorChapterIndex: anchorIndex, anchorChapterIndex: anchorIndex,
anchorPageOffset: pageOffset, anchorPageOffset: pageOffset,
@ -83,4 +70,4 @@ struct RDEPUBChapterWindowSnapshot {
/// ///
var pageCount: Int { flattenedPages.count } var pageCount: Int { flattenedPages.count }
} }

View File

@ -52,6 +52,10 @@ final class RDEPUBReaderContext {
var searchState: RDEPUBSearchState? var searchState: RDEPUBSearchState?
/// ///
var lastTextPaginationPageSize: CGSize? var lastTextPaginationPageSize: CGSize?
/// OperationQueue
var lastMetadataParseWallClockMs: Int = 0
/// 使
var lastMetadataParseConcurrency: Int = 0
/// ///
var currentSelection: RDEPUBSelection? var currentSelection: RDEPUBSelection?

View File

@ -234,7 +234,8 @@ final class RDEPUBReaderPaginationCoordinator {
context.controller != nil else { return } context.controller != nil else { return }
runtime.chapterRuntimeStore.setCurrentChapter( runtime.chapterRuntimeStore.setCurrentChapter(
spineIndex: runtimeChapter.spineIndex, spineIndex: runtimeChapter.spineIndex,
totalSpineCount: publication.spine.count totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
) )
let partialMap = self.makePartialPageMap(from: quickWindowChapters) let partialMap = self.makePartialPageMap(from: quickWindowChapters)
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation) runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
@ -276,7 +277,8 @@ final class RDEPUBReaderPaginationCoordinator {
) throws -> [RDEPUBRuntimeChapter] { ) throws -> [RDEPUBRuntimeChapter] {
let windowSpineIndices = initialWindowSpineIndices( let windowSpineIndices = initialWindowSpineIndices(
around: anchorSpineIndex, around: anchorSpineIndex,
in: publication in: publication,
maxChapterCount: context.configuration.onDemandChapterWindowSize
) )
var chapters: [RDEPUBRuntimeChapter] = [] var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in windowSpineIndices { for spineIndex in windowSpineIndices {
@ -301,6 +303,7 @@ final class RDEPUBReaderPaginationCoordinator {
in publication: RDEPUBPublication, in publication: RDEPUBPublication,
maxChapterCount: Int = 3 maxChapterCount: Int = 3
) -> [Int] { ) -> [Int] {
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
let buildableIndices = allBuildableSpineIndices(in: publication) let buildableIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else { guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
return [anchorSpineIndex] return [anchorSpineIndex]
@ -310,12 +313,12 @@ final class RDEPUBReaderPaginationCoordinator {
var nextPosition = anchorPosition + 1 var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1 var previousPosition = anchorPosition - 1
while selected.count < maxChapterCount, while selected.count < normalizedMaxChapterCount,
nextPosition < buildableIndices.count || previousPosition >= 0 { nextPosition < buildableIndices.count || previousPosition >= 0 {
if nextPosition < buildableIndices.count { if nextPosition < buildableIndices.count {
selected.append(buildableIndices[nextPosition]) selected.append(buildableIndices[nextPosition])
nextPosition += 1 nextPosition += 1
if selected.count == maxChapterCount { if selected.count == normalizedMaxChapterCount {
break break
} }
} }
@ -384,10 +387,12 @@ final class RDEPUBReaderPaginationCoordinator {
let pageSize = context.currentTextPageSize() let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let style = context.currentTextRenderStyle() let style = context.currentTextRenderStyle()
let allBuildableIndices = allBuildableSpineIndices(in: publication) let allBuildableIndices = allBuildableSpineIndices(in: publication)
let summaryDiskCache = context.runtime?.summaryDiskCache 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 DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return } guard let self else { return }
@ -404,18 +409,21 @@ final class RDEPUBReaderPaginationCoordinator {
let restored = summaryDiskCache?.readAll(keys: catalog) let restored = summaryDiskCache?.readAll(keys: catalog)
RDEPUBBackgroundTrace.log( RDEPUBBackgroundTrace.log(
"MetadataParse", "MetadataParse",
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count)" "begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)"
) )
var mapBuilder = restored?.mapBuilder ?? RDEPUBBookPageMap.Builder() let cachedSummaries = restored?.summaries ?? [:]
var lastAppliedCount = 0 let cachedSpineIndices = Set(cachedSummaries.keys)
let cachedSpineIndices = Set((restored?.summaries ?? [:]).keys) let resultLock = NSLock()
var summariesBySpineIndex = cachedSummaries
var totalResolvedCount = cachedSpineIndices.count
var lastAppliedCount = cachedSpineIndices.count
if !cachedSpineIndices.isEmpty { if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log( RDEPUBBackgroundTrace.log(
"MetadataParse", "MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)" "resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
) )
let cachedMap = mapBuilder.build() let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil else { return } context.controller != nil else { return }
@ -423,24 +431,35 @@ final class RDEPUBReaderPaginationCoordinator {
} }
} }
for (offset, spineIndex) in allBuildableIndices.enumerated() { let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
guard context.controller != nil,
context.paginationToken == token else { self.waitForReadingInteractionToSettle(using: context)
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
return let wallClockStart = CFAbsoluteTimeGetCurrent()
} var totalRenderMs: Double = 0
if cachedSpineIndices.contains(spineIndex) { var totalWriteMs: Double = 0
continue var completedChapters = 0
} var failedChapters = 0
self.waitForReadingInteractionToSettle(using: context) let timingLock = NSLock()
do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(allBuildableIndices.count)") let queue = OperationQueue()
let lightweightEntry = try RDEPUBBackgroundTrace.measure( queue.name = "com.rdreader.metadata.parse"
"MetadataParse", queue.qualityOfService = .utility
"spine=\(spineIndex)" queue.maxConcurrentOperationCount = workerCount
) {
try autoreleasepool { () -> RDEPUBBookPageMapEntry? in for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
guard let result = try builder.buildChapter( 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, parser: parser,
publication: publication, publication: publication,
spineIndex: spineIndex, spineIndex: spineIndex,
@ -449,6 +468,7 @@ final class RDEPUBReaderPaginationCoordinator {
) else { ) else {
return nil return nil
} }
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter let chapter = result.chapter
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex) let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
@ -461,44 +481,76 @@ final class RDEPUBReaderPaginationCoordinator {
chapterContentHash: cacheKey.chapterContentHash, chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) } 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( timingLock.lock()
spineIndex: spineIndex, totalRenderMs += renderElapsed
href: chapter.href, totalWriteMs += writeElapsed
title: chapter.title, completedChapters += 1
pageCount: chapter.pages.count, timingLock.unlock()
absolutePageStart: 0,
fragmentOffsets: chapter.fragmentOffsets RDEPUBBackgroundTrace.log(
"MetadataParse",
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
) )
return summary
} }
}
if let lightweightEntry { guard let renderResult else { return }
mapBuilder.add(
spineIndex: lightweightEntry.spineIndex, var partialMap: RDEPUBBookPageMap?
href: lightweightEntry.href, resultLock.lock()
title: lightweightEntry.title, summariesBySpineIndex[spineIndex] = renderResult
pageCount: lightweightEntry.pageCount, totalResolvedCount += 1
fragmentOffsets: lightweightEntry.fragmentOffsets if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
) lastAppliedCount = totalResolvedCount
let builtCount = offset + 1 partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
if builtCount - lastAppliedCount >= 16 || builtCount == allBuildableIndices.count { }
lastAppliedCount = builtCount resultLock.unlock()
let partialMap = mapBuilder.build()
if let partialMap {
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil else { return } context.controller != nil else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap) 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( RDEPUBBackgroundTrace.log(
"MetadataParse", "MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)" "complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
@ -534,4 +586,22 @@ final class RDEPUBReaderPaginationCoordinator {
} }
return restored.mapBuilder.build() 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()
}
} }

View File

@ -324,12 +324,14 @@ final class RDEPUBReaderRuntime {
// //
readerView.reloadPageCountOnly() readerView.reloadPageCountOnly()
// //
if context.currentSelection == nil, bookPageMap.totalPages > 0 { let cv = readerView.collectionView
readerView.transitionToPage( let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating
pageNum: min(currentPage, max(bookPageMap.totalPages - 1, 0)), if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 {
animated: false let maxValidPage = max(bookPageMap.totalPages - 1, 0)
) if currentPage > maxValidPage {
readerView.transitionToPage(pageNum: maxValidPage, animated: false)
}
} }
if let currentLocation { if let currentLocation {
locationCoordinator.persist(location: currentLocation) locationCoordinator.persist(location: currentLocation)
@ -395,7 +397,8 @@ final class RDEPUBReaderRuntime {
chapterRuntimeStore.setCurrentChapter( chapterRuntimeStore.setCurrentChapter(
spineIndex: spineIndex, spineIndex: spineIndex,
totalSpineCount: publication.spine.count totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
) )
RDEPUBBackgroundTrace.log( RDEPUBBackgroundTrace.log(
"Runtime", "Runtime",
@ -418,8 +421,8 @@ final class RDEPUBReaderRuntime {
chapterRuntimeStore.evict(spineIndex: evictable) chapterRuntimeStore.evict(spineIndex: evictable)
} }
let adjacent = [spineIndex - 1, spineIndex + 1].filter { publication.spine.indices.contains($0) } for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex {
for adjacentSpineIndex in adjacent where chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil { guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue }
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex) chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
RDEPUBBackgroundTrace.log( RDEPUBBackgroundTrace.log(
"Runtime", "Runtime",

View File

@ -107,6 +107,12 @@ public struct RDEPUBReaderConfiguration: Equatable {
public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode public var fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode
/// 使 DTCoreText /// 使 DTCoreText
public var textRenderingEngine: RDEPUBTextRenderingEngine 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: // MARK:
@ -128,6 +134,8 @@ public struct RDEPUBReaderConfiguration: Equatable {
/// - fixedLayoutFit: /// - fixedLayoutFit:
/// - fixedLayoutSpreadMode: /// - fixedLayoutSpreadMode:
/// - textRenderingEngine: /// - textRenderingEngine:
/// - onDemandChapterWindowSize: 3...15
/// - metadataParsingConcurrency: CPU
public init( public init(
fontSize: CGFloat = 15, fontSize: CGFloat = 15,
lineHeightMultiple: CGFloat = 1.6, lineHeightMultiple: CGFloat = 1.6,
@ -146,7 +154,9 @@ public struct RDEPUBReaderConfiguration: Equatable {
darkImageBlendRatio: CGFloat = 0.15, darkImageBlendRatio: CGFloat = 0.15,
fixedLayoutFit: RDEPUBFixedLayoutFit = .page, fixedLayoutFit: RDEPUBFixedLayoutFit = .page,
fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic, fixedLayoutSpreadMode: RDEPUBFixedLayoutSpreadMode = .automatic,
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
onDemandChapterWindowSize: Int = 3,
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount
) { ) {
self.fontSize = fontSize self.fontSize = fontSize
self.lineHeightMultiple = lineHeightMultiple self.lineHeightMultiple = lineHeightMultiple
@ -166,12 +176,25 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.fixedLayoutFit = fixedLayoutFit self.fixedLayoutFit = fixedLayoutFit
self.fixedLayoutSpreadMode = fixedLayoutSpreadMode self.fixedLayoutSpreadMode = fixedLayoutSpreadMode
self.textRenderingEngine = textRenderingEngine self.textRenderingEngine = textRenderingEngine
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
} }
/// 使 /// 使
public static let `default` = RDEPUBReaderConfiguration() 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: - // MARK: -
extension RDEPUBReaderConfiguration { extension RDEPUBReaderConfiguration {