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