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>
229 lines
8.5 KiB
Swift
229 lines
8.5 KiB
Swift
import Foundation
|
||
|
||
/// 页图接管决策
|
||
enum RDEPUBPageMapTakeoverDecision {
|
||
/// 保持当前窗口不变
|
||
case keepCurrentWindow
|
||
/// 扩窗:将候选段合并到当前窗口
|
||
case expandWindow(RDEPUBBackgroundCoverageSegment)
|
||
/// 分段替换:用候选段替换当前窗口的部分内容
|
||
case segmentReplace(RDEPUBBackgroundCoverageSegment)
|
||
/// 全量替换:用完整页图替换当前窗口
|
||
case fullReplace(RDEPUBBookPageMap)
|
||
}
|
||
|
||
/// 页图协调器:负责判断后台解析结果是否可以接管前台窗口
|
||
final class RDEPUBPageMapReconciliationCoordinator {
|
||
private unowned let context: RDEPUBReaderContext
|
||
|
||
init(context: RDEPUBReaderContext) {
|
||
self.context = context
|
||
}
|
||
|
||
/// 判断是否可以接管前台窗口
|
||
func evaluateTakeover(
|
||
candidatePageMap: RDEPUBBookPageMap?,
|
||
candidateSegment: RDEPUBBackgroundCoverageSegment?,
|
||
currentWindow: RDEPUBBookPageMap?,
|
||
jumpSession: RDEPUBJumpSession?
|
||
) -> RDEPUBPageMapTakeoverDecision {
|
||
// 如果没有当前窗口,允许接管
|
||
guard let currentWindow else {
|
||
if let candidatePageMap {
|
||
return .fullReplace(candidatePageMap)
|
||
}
|
||
return .keepCurrentWindow
|
||
}
|
||
|
||
// 获取当前阅读位置
|
||
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||
|
||
let lastBuildableSpineIndex = context.publication?.spine.indices
|
||
.reversed()
|
||
.first(where: { index in
|
||
guard let item = context.publication?.spine[index] else { return false }
|
||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||
}) ?? 0
|
||
|
||
// 检查 JumpSession 保护
|
||
if let jumpSession {
|
||
let protectedIndices = jumpSession.protectedSpineIndices
|
||
if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) {
|
||
// 在保护区内,检查候选是否覆盖保护区
|
||
if let candidateSegment {
|
||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||
let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) /
|
||
Double(protectedIndices.count)
|
||
if coverageRatio < 0.8 {
|
||
RDEPUBBackgroundTrace.log(
|
||
"Reconciliation",
|
||
"blocked: candidate doesn't cover protected area (coverage=\(coverageRatio))"
|
||
)
|
||
return .keepCurrentWindow
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查边界章节覆盖
|
||
if let currentSpineIndex {
|
||
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
|
||
|
||
if requiresAdjacentCoverage {
|
||
if let candidateSegment {
|
||
let hasPrev = candidateSegment.contains(spineIndex: currentSpineIndex - 1)
|
||
let hasNext = candidateSegment.contains(spineIndex: currentSpineIndex + 1)
|
||
if !hasPrev || !hasNext {
|
||
RDEPUBBackgroundTrace.log(
|
||
"Reconciliation",
|
||
"blocked: missing adjacent chapter coverage"
|
||
)
|
||
return .keepCurrentWindow
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// 检查渲染签名一致性
|
||
if let candidateSegment {
|
||
let currentRenderSignature = context.currentRenderSignature()
|
||
if candidateSegment.renderSignature != currentRenderSignature {
|
||
RDEPUBBackgroundTrace.log(
|
||
"Reconciliation",
|
||
"blocked: render signature mismatch"
|
||
)
|
||
return .keepCurrentWindow
|
||
}
|
||
}
|
||
|
||
// 评估接管类型
|
||
if let candidateSegment {
|
||
return evaluateSegmentTakeover(
|
||
candidateSegment: candidateSegment,
|
||
currentWindow: currentWindow,
|
||
currentSpineIndex: currentSpineIndex,
|
||
lastBuildableSpineIndex: lastBuildableSpineIndex
|
||
)
|
||
}
|
||
|
||
if let candidatePageMap {
|
||
return evaluateFullPageMapTakeover(
|
||
candidatePageMap: candidatePageMap,
|
||
currentWindow: currentWindow,
|
||
currentSpineIndex: currentSpineIndex,
|
||
lastBuildableSpineIndex: lastBuildableSpineIndex
|
||
)
|
||
}
|
||
|
||
return .keepCurrentWindow
|
||
}
|
||
|
||
/// 评估分段接管
|
||
private func evaluateSegmentTakeover(
|
||
candidateSegment: RDEPUBBackgroundCoverageSegment,
|
||
currentWindow: RDEPUBBookPageMap,
|
||
currentSpineIndex: Int?,
|
||
lastBuildableSpineIndex: Int
|
||
) -> RDEPUBPageMapTakeoverDecision {
|
||
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||
|
||
// 检查是否覆盖当前阅读位置
|
||
if let currentSpineIndex {
|
||
if !candidateIndices.contains(currentSpineIndex) {
|
||
return .keepCurrentWindow
|
||
}
|
||
}
|
||
|
||
// 检查是否覆盖相邻章节
|
||
if let currentSpineIndex {
|
||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||
currentSpineIndex == lastBuildableSpineIndex
|
||
if !hasPrev || !hasNext {
|
||
return .keepCurrentWindow
|
||
}
|
||
}
|
||
|
||
// 检查是否与当前窗口连续
|
||
let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) ||
|
||
currentIndices.contains(candidateSegment.upperSpineIndex + 1) ||
|
||
candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) ||
|
||
candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min)
|
||
|
||
if isContinuous {
|
||
// 连续,可以扩窗
|
||
return .expandWindow(candidateSegment)
|
||
} else {
|
||
// 不连续,检查是否覆盖当前窗口的大部分
|
||
let overlap = currentIndices.intersection(candidateIndices)
|
||
let overlapRatio = Double(overlap.count) / Double(currentIndices.count)
|
||
if overlapRatio > 0.5 {
|
||
// 覆盖大部分,可以替换
|
||
return .segmentReplace(candidateSegment)
|
||
}
|
||
}
|
||
|
||
return .keepCurrentWindow
|
||
}
|
||
|
||
/// 评估全量页图接管
|
||
private func evaluateFullPageMapTakeover(
|
||
candidatePageMap: RDEPUBBookPageMap,
|
||
currentWindow: RDEPUBBookPageMap,
|
||
currentSpineIndex: Int?,
|
||
lastBuildableSpineIndex: Int
|
||
) -> RDEPUBPageMapTakeoverDecision {
|
||
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
||
|
||
// 检查是否覆盖当前阅读位置
|
||
if let currentSpineIndex {
|
||
if !candidateIndices.contains(currentSpineIndex) {
|
||
return .keepCurrentWindow
|
||
}
|
||
}
|
||
|
||
// 检查是否覆盖相邻章节
|
||
if let currentSpineIndex {
|
||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||
currentSpineIndex == lastBuildableSpineIndex
|
||
if !hasPrev || !hasNext {
|
||
return .keepCurrentWindow
|
||
}
|
||
}
|
||
|
||
// 检查是否完整覆盖
|
||
let isComplete = candidateIndices.count >= currentWindow.entries.count
|
||
if isComplete {
|
||
return .fullReplace(candidatePageMap)
|
||
}
|
||
|
||
return .keepCurrentWindow
|
||
}
|
||
|
||
/// 生成保护区域的 spineIndex 集合
|
||
func protectedSpineIndices(
|
||
currentSpineIndex: Int?,
|
||
jumpSession: RDEPUBJumpSession?
|
||
) -> Set<Int> {
|
||
var indices: Set<Int> = []
|
||
|
||
if let currentSpineIndex {
|
||
indices.insert(currentSpineIndex)
|
||
// 添加相邻章节
|
||
if currentSpineIndex > 0 {
|
||
indices.insert(currentSpineIndex - 1)
|
||
}
|
||
indices.insert(currentSpineIndex + 1)
|
||
}
|
||
|
||
if let jumpSession {
|
||
indices.formUnion(jumpSession.protectedSpineIndices)
|
||
}
|
||
|
||
return indices
|
||
}
|
||
}
|