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
@@ -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",