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:
parent
feb05eaf87
commit
d20196ee34
379
Doc/双层PageMap方案_估算加精确混合.md
Normal file
379
Doc/双层PageMap方案_估算加精确混合.md
Normal 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. 真机验证《凡人修仙传》的首屏总页数显示速度和精度
|
||||
@ -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 = "<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; };
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@ -108,6 +116,7 @@
|
||||
children = (
|
||||
00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */,
|
||||
74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */,
|
||||
1A2B3C4D00000012AABBCC01 /* DemoReaderState.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
@ -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 = "<group>";
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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", "组合配置后台解析应最终完成")
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
}
|
||||
@ -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)))过低,可能存在瓶颈")
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -25,6 +25,11 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待此前已排队的异步写入全部落盘。
|
||||
func flushPendingWrites() {
|
||||
queue.sync { }
|
||||
}
|
||||
|
||||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
|
||||
@ -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: - 内部辅助
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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?
|
||||
|
||||
|
||||
@ -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() {
|
||||
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 {
|
||||
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(
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user