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
@@ -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()
}
}
}
@@ -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,
@@ -83,4 +70,4 @@ struct RDEPUBChapterWindowSnapshot {
///
var pageCount: Int { flattenedPages.count }
}
}