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:
+12
-8
@@ -23,7 +23,7 @@ final class RDEPUBChapterRuntimeStore {
|
||||
/// 当前章 spineIndex
|
||||
private(set) var currentSpineIndex: Int?
|
||||
|
||||
/// 当前窗口内的 spineIndex 集合(当前 + prev + next)
|
||||
/// 当前窗口内的 spineIndex 集合(以当前章为中心,按配置半径展开)
|
||||
private(set) var windowSpineIndices: [Int] = []
|
||||
|
||||
// MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占)
|
||||
@@ -75,13 +75,17 @@ final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
// MARK: - 窗口管理
|
||||
|
||||
/// 设定当前章,自动计算 ±1 窗口
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int) {
|
||||
/// 设定当前章,自动计算按半径展开的窗口
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
|
||||
currentSpineIndex = spineIndex
|
||||
var window = [spineIndex]
|
||||
if spineIndex > 0 { window.append(spineIndex - 1) }
|
||||
if spineIndex < totalSpineCount - 1 { window.append(spineIndex + 1) }
|
||||
windowSpineIndices = window
|
||||
let radius = max(0, windowRadius)
|
||||
let lowerBound = max(0, spineIndex - radius)
|
||||
let upperBound = min(totalSpineCount - 1, spineIndex + radius)
|
||||
guard lowerBound <= upperBound else {
|
||||
windowSpineIndices = [spineIndex]
|
||||
return
|
||||
}
|
||||
windowSpineIndices = Array(lowerBound...upperBound)
|
||||
}
|
||||
|
||||
/// 返回窗口外、应该淘汰的 spineIndex
|
||||
@@ -183,4 +187,4 @@ final class RDEPUBChapterRuntimeStore {
|
||||
pageCountCache.removeAll()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -25,6 +25,11 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待此前已排队的异步写入全部落盘。
|
||||
func flushPendingWrites() {
|
||||
queue.sync { }
|
||||
}
|
||||
|
||||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
|
||||
+35
-57
@@ -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: - 内部辅助
|
||||
|
||||
+10
-23
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterWindowSnapshot {
|
||||
/// 窗口中的章节(有序:prev, current, next)
|
||||
/// 窗口中的章节(有序)
|
||||
let chapters: [RDEPUBRuntimeChapter]
|
||||
|
||||
/// 展平后的连续页数组(供 RDReaderView 消费)
|
||||
@@ -22,39 +22,26 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
|
||||
/// 从章节窗口构建快照
|
||||
static func from(
|
||||
currentChapter: RDEPUBRuntimeChapter,
|
||||
previousChapter: RDEPUBRuntimeChapter?,
|
||||
nextChapter: RDEPUBRuntimeChapter?
|
||||
chapters: [RDEPUBRuntimeChapter],
|
||||
anchorSpineIndex: Int
|
||||
) -> RDEPUBChapterWindowSnapshot {
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
var anchorIndex = 0
|
||||
var pageOffset = 0
|
||||
|
||||
if let prev = previousChapter {
|
||||
chapters.append(prev)
|
||||
anchorIndex = 1
|
||||
pageOffset = prev.pages.count
|
||||
}
|
||||
|
||||
chapters.append(currentChapter)
|
||||
|
||||
if let next = nextChapter {
|
||||
chapters.append(next)
|
||||
}
|
||||
let sortedChapters = chapters.sorted { $0.spineIndex < $1.spineIndex }
|
||||
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
|
||||
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
|
||||
|
||||
// 展平页数组
|
||||
var allPages: [RDEPUBTextPage] = []
|
||||
for (chIdx, ch) in chapters.enumerated() {
|
||||
for (chIdx, ch) in sortedChapters.enumerated() {
|
||||
for var page in ch.pages {
|
||||
page.chapterIndex = chIdx
|
||||
allPages.append(page)
|
||||
}
|
||||
}
|
||||
|
||||
let windowStartSpineIndex = chapters.first?.spineIndex ?? currentChapter.spineIndex
|
||||
let windowStartSpineIndex = sortedChapters.first?.spineIndex ?? anchorSpineIndex
|
||||
|
||||
return RDEPUBChapterWindowSnapshot(
|
||||
chapters: chapters,
|
||||
chapters: sortedChapters,
|
||||
flattenedPages: allPages,
|
||||
anchorChapterIndex: anchorIndex,
|
||||
anchorPageOffset: pageOffset,
|
||||
@@ -83,4 +70,4 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
|
||||
/// 总页数(窗口内)
|
||||
var pageCount: Int { flattenedPages.count }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user