feat: 实现大书远距目录跳转与后台补全优化方案

Phase 1: 稳定性优先
- 新增 RDEPUBJumpSession 保护机制,防止远距跳转后翻页串章
- 升级页图接管条件,增加 JumpSession 保护区检查
- 窗口扩展改为基于当前权威窗口方向

Phase 2: 补全优先级重排
- 新增 RDEPUBBackgroundPriorityPolicy 策略配置
- 实现 hot/warm/cold zone 优先级排序
- 添加失败重试机制(指数退避,最多3次)

Phase 3: 分段覆盖与最终收敛
- 新增 RDEPUBBackgroundCoverageStore 分段存储
- 新增 RDEPUBPageMapReconciliationCoordinator 页图接管仲裁
- 实现 LRU 淘汰和内存警告处理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-15 16:28:11 +08:00
co-authored by Claude Opus 4.7
parent e976ceebd5
commit c64460988a
13 changed files with 2797 additions and 92 deletions
@@ -9,11 +9,69 @@ import Foundation
/// -
/// -
final class RDEPUBReaderPaginationCoordinator {
private final class MetadataParseState {
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
var totalResolvedCount: Int
var lastAppliedCount: Int
init(
summariesBySpineIndex: [Int: RDEPUBChapterSummary],
totalResolvedCount: Int,
lastAppliedCount: Int
) {
self.summariesBySpineIndex = summariesBySpineIndex
self.totalResolvedCount = totalResolvedCount
self.lastAppliedCount = lastAppliedCount
}
}
private final class MetadataParseCancellationController {
let token: UUID
private let lock = NSLock()
private weak var queue: OperationQueue?
private var cancelled = false
init(token: UUID) {
self.token = token
}
func attach(queue: OperationQueue) {
let shouldCancelImmediately: Bool
lock.lock()
self.queue = queue
shouldCancelImmediately = cancelled
lock.unlock()
if shouldCancelImmediately {
queue.cancelAllOperations()
}
}
func cancel() {
let queueToCancel: OperationQueue?
lock.lock()
cancelled = true
queueToCancel = queue
lock.unlock()
queueToCancel?.cancelAllOperations()
}
var isCancelled: Bool {
lock.lock()
let value = cancelled
lock.unlock()
return value
}
}
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap
static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext
private let metadataParseControlLock = NSLock()
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
init(context: RDEPUBReaderContext) {
self.context = context
@@ -375,8 +433,12 @@ final class RDEPUBReaderPaginationCoordinator {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
private func waitForReadingInteractionToSettle(using context: RDEPUBReaderContext) {
private func waitForReadingInteractionToSettle(
using context: RDEPUBReaderContext,
cancellationController: MetadataParseCancellationController? = nil
) {
while context.controller != nil,
cancellationController?.isCancelled != true,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
@@ -390,12 +452,17 @@ final class RDEPUBReaderPaginationCoordinator {
// MARK: - Phase 0
///
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
/// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context
guard let parser = context.parser,
let publication = context.publication else { return }
let cancellationController = beginMetadataParseCancellationController(for: token)
let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
@@ -409,12 +476,21 @@ final class RDEPUBReaderPaginationCoordinator {
DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return }
guard context.controller != nil else { return }
defer { self.finishMetadataParseCancellationController(cancellationController) }
guard context.controller != nil,
!cancellationController.isCancelled,
context.paginationToken == token else { return }
// contentHash + SHA-256
let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices {
guard !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort during content hash prewarm")
return
}
guard let href = publication.spine.indices.contains(spineIndex)
? publication.spine[spineIndex].href : nil,
let html = parser.htmlString(forRelativePath: href) else {
@@ -447,16 +523,18 @@ final class RDEPUBReaderPaginationCoordinator {
let cachedSummaries = restored?.summaries ?? [:]
let cachedSpineIndices = Set(cachedSummaries.keys)
let resultLock = NSLock()
var summariesBySpineIndex = cachedSummaries
var totalResolvedCount = cachedSpineIndices.count
var lastAppliedCount = cachedSpineIndices.count
let parseState = MetadataParseState(
summariesBySpineIndex: cachedSummaries,
totalResolvedCount: cachedSpineIndices.count,
lastAppliedCount: cachedSpineIndices.count
)
if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
)
let cachedMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
let cachedMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
@@ -464,9 +542,36 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
let uncachedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
// 使 spineIndex
let prioritizedSpineIndices: [Int]
if let priorityManager = context.runtime?.backgroundPriorityManager {
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
allBuildableIndices: allBuildableIndices,
currentSpineIndex: currentSpineIndex,
cachedSpineIndices: cachedSpineIndices
)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)"
)
} else {
prioritizedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
}
self.waitForReadingInteractionToSettle(using: context)
let uncachedSpineIndices = prioritizedSpineIndices
self.waitForReadingInteractionToSettle(
using: context,
cancellationController: cancellationController
)
guard !cancellationController.isCancelled,
context.controller != nil,
context.paginationToken == token else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start")
return
}
let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0
@@ -480,19 +585,29 @@ final class RDEPUBReaderPaginationCoordinator {
queue.name = "com.rdreader.metadata.parse"
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = workerCount
cancellationController.attach(queue: queue)
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
queue.addOperation {
let operation = BlockOperation()
operation.addExecutionBlock { [weak operation] in
guard context.controller != nil,
context.paginationToken == token else {
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return nil
}
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStart = CFAbsoluteTimeGetCurrent()
guard let result = try chapterBuilder.buildChapter(
@@ -504,6 +619,13 @@ final class RDEPUBReaderPaginationCoordinator {
) else {
return nil
}
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)")
return nil
}
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter
@@ -522,6 +644,13 @@ final class RDEPUBReaderPaginationCoordinator {
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)")
return nil
}
let writeStart = CFAbsoluteTimeGetCurrent()
summaryDiskCache?.write(summary: summary, for: cacheKey)
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
@@ -540,15 +669,22 @@ final class RDEPUBReaderPaginationCoordinator {
}
guard let renderResult else { return }
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
// pageMap
var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count {
lastAppliedCount = totalResolvedCount
snapshot = summariesBySpineIndex
parseState.summariesBySpineIndex[spineIndex] = renderResult
parseState.totalResolvedCount += 1
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == allBuildableIndices.count {
parseState.lastAppliedCount = parseState.totalResolvedCount
snapshot = parseState.summariesBySpineIndex
}
resultLock.unlock()
@@ -561,20 +697,54 @@ final class RDEPUBReaderPaginationCoordinator {
timingLock.unlock()
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
} catch {
guard !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil,
operation?.isCancelled != true else {
return
}
timingLock.lock()
failedChapters += 1
timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
// 退
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: 0,
token: token,
context: context,
parser: parser,
publication: publication,
pageSize: pageSize,
layoutConfig: layoutConfig,
style: style,
renderSignature: renderSignature,
summaryDiskCache: summaryDiskCache,
contentHashBySpineIndex: contentHashBySpineIndex,
resultLock: resultLock,
parseState: parseState,
allBuildableIndices: allBuildableIndices,
catalog: catalog,
refreshInterval: refreshInterval,
cancellationController: cancellationController
)
}
}
queue.addOperation(operation)
}
queue.waitUntilAllOperationsAreFinished()
summaryDiskCache?.flushPendingWrites()
if !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil {
summaryDiskCache?.flushPendingWrites()
}
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
timingLock.lock()
@@ -595,19 +765,39 @@ final class RDEPUBReaderPaginationCoordinator {
context.lastMetadataParseConcurrency = workerCount
guard context.controller != nil,
context.paginationToken == token else {
context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
return
}
let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
let pageMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
)
// BackgroundCoverageStore
if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
let lowerSpine = resolvedSpineIndices.min() ?? 0
let upperSpine = resolvedSpineIndices.max() ?? 0
let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
let segment = RDEPUBBackgroundCoverageSegment(
lowerSpineIndex: lowerSpine,
upperSpineIndex: upperSpine,
pageMap: pageMap,
resolvedSpineIndices: resolvedSpineIndices,
generatedAt: CFAbsoluteTimeGetCurrent(),
renderSignature: renderSignature,
estimatedMemoryBytes: estimatedBytes
)
coverageStore.addSegment(segment)
}
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil else { return }
@@ -616,6 +806,172 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
///
private func scheduleRetry(
spineIndex: Int,
retryCount: Int,
token: UUID,
context: RDEPUBReaderContext,
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
layoutConfig: RDEPUBTextLayoutConfig,
style: RDEPUBTextRenderStyle,
renderSignature: String,
summaryDiskCache: RDEPUBChapterSummaryDiskCache?,
contentHashBySpineIndex: [Int: String],
resultLock: NSLock,
parseState: MetadataParseState,
allBuildableIndices: [Int],
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
refreshInterval: Int,
cancellationController: MetadataParseCancellationController
) {
guard retryCount < Self.maxRetryCount else {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"spine=\(spineIndex) max retries reached, marking as deferredFailure"
)
return
}
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
RDEPUBBackgroundTrace.log(
"MetadataParse",
"scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s"
)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self else { return }
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
return
}
do {
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
guard let result = try chapterBuilder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
) else {
return
}
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)")
return
}
let chapter = result.chapter
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
let cacheKey = context.chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedHash,
renderSignature: renderSignature
)
let summary = RDEPUBChapterSummary(
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
pageCount: chapter.pages.count,
fragmentOffsets: chapter.fragmentOffsets,
renderSignature: cacheKey.renderSignature,
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) }
)
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)")
return
}
summaryDiskCache?.write(summary: summary, for: cacheKey)
resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = summary
parseState.totalResolvedCount += 1
let shouldRefresh =
parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == allBuildableIndices.count
if shouldRefresh {
parseState.lastAppliedCount = parseState.totalResolvedCount
}
resultLock.unlock()
if shouldRefresh {
let partialMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
DispatchQueue.main.async {
guard context.paginationToken == token,
context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap)
}
}
RDEPUBBackgroundTrace.log(
"MetadataParse",
"retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)"
)
} catch {
RDEPUBBackgroundTrace.log(
"MetadataParse",
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
)
//
self.scheduleRetry(
spineIndex: spineIndex,
retryCount: retryCount + 1,
token: token,
context: context,
parser: parser,
publication: publication,
pageSize: pageSize,
layoutConfig: layoutConfig,
style: style,
renderSignature: renderSignature,
summaryDiskCache: summaryDiskCache,
contentHashBySpineIndex: contentHashBySpineIndex,
resultLock: resultLock,
parseState: parseState,
allBuildableIndices: allBuildableIndices,
catalog: catalog,
refreshInterval: refreshInterval,
cancellationController: cancellationController
)
}
}
}
func cancelActiveMetadataParseWork() {
metadataParseControlLock.lock()
let controller = activeMetadataParseCancellationController
activeMetadataParseCancellationController = nil
metadataParseControlLock.unlock()
controller?.cancel()
}
private func beginMetadataParseCancellationController(for token: UUID) -> MetadataParseCancellationController {
let controller = MetadataParseCancellationController(token: token)
metadataParseControlLock.lock()
let previous = activeMetadataParseCancellationController
activeMetadataParseCancellationController = controller
metadataParseControlLock.unlock()
previous?.cancel()
return controller
}
private func finishMetadataParseCancellationController(_ controller: MetadataParseCancellationController) {
metadataParseControlLock.lock()
if activeMetadataParseCancellationController === controller {
activeMetadataParseCancellationController = nil
}
metadataParseControlLock.unlock()
}
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
let parser = context.parser else {