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:
@@ -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 {
|
||||
|
||||
+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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
+122
-52
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user