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
parent e976ceebd5
commit c64460988a
13 changed files with 2797 additions and 92 deletions

View File

@ -1,6 +1,6 @@
# ReadViewSDK 文档索引 # ReadViewSDK 文档索引
> 最后更新2026-06-12 > 最后更新2026-06-15
--- ---
@ -26,6 +26,7 @@
| 文档 | 说明 | | 文档 | 说明 |
|------|------| |------|------|
| [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划10 个任务、修改位置、实施步骤、风险点与验收标准 | | [当前阅读器问题修复开发清单.md](当前阅读器问题修复开发清单.md) | 基于当前代码实现整理的逐任务开发计划10 个任务、修改位置、实施步骤、风险点与验收标准 |
| [大书远距目录跳转与后台补全优化方案.md](大书远距目录跳转与后台补全优化方案.md) | 面向大书按需分页模式的完整优化方案:远距目录跳转、后台补全优先级、页图接管协议与分阶段实施路径 |
| [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s | | [大书后台解析优化实施清单_30秒目标.md](大书后台解析优化实施清单_30秒目标.md) | 《凡人修仙传》后台解析性能优化方案,目标从 70s 压缩到 30-50s |
## 项目信息 ## 项目信息

File diff suppressed because it is too large Load Diff

View File

@ -101,9 +101,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String, didActivateAttachmentText text: String,
sourceRect: CGRect sourceRect: CGRect,
sourcePoint: CGPoint
) { ) {
presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect) presentAttachmentTooltip(text: text, sourceView: contentView, sourceRect: sourceRect, sourcePoint: sourcePoint)
} }
func textContentView( func textContentView(
@ -339,7 +340,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
present(alert, animated: true) present(alert, animated: true)
} }
private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect) { private func presentAttachmentTooltip(text: String, sourceView: UIView, sourceRect: CGRect, sourcePoint: CGPoint) {
hideAttachmentTooltipIfNeeded() hideAttachmentTooltipIfNeeded()
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds) let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
@ -351,23 +352,40 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
let tooltip = RDEPUBAttachmentTooltipView() let tooltip = RDEPUBAttachmentTooltipView()
tooltip.alpha = 0 tooltip.alpha = 0
tooltip.configure(text: text, maxWidth: min(view.bounds.width - 48, 320)) let horizontalPadding = max(view.safeAreaInsets.left, view.safeAreaInsets.right) + 12
tooltip.configure(text: text, maxWidth: min(view.bounds.width - horizontalPadding * 2, 320))
let anchorRect = sourceView.convert(sourceRect, to: view) let anchorRect = sourceView.convert(sourceRect, to: view)
let horizontalPadding: CGFloat = 24 let rawAnchorPoint = sourceView.convert(sourcePoint, to: view)
let anchorPoint = CGPoint(
x: min(max(rawAnchorPoint.x, anchorRect.minX), anchorRect.maxX),
y: min(max(rawAnchorPoint.y, anchorRect.minY), anchorRect.maxY)
)
let verticalSpacing: CGFloat = 6 let verticalSpacing: CGFloat = 6
let tooltipSize = tooltip.frame.size let tooltipSize = tooltip.frame.size
let idealX = anchorRect.midX - tooltipSize.width / 2 let idealX = anchorPoint.x - tooltipSize.width / 2
let minX = horizontalPadding let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width) let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
let originX = min(max(idealX, minX), maxX) let originX = min(max(idealX, minX), maxX)
let originY = max(view.safeAreaInsets.top + 12, anchorRect.minY - tooltipSize.height - verticalSpacing) let topSafeY = view.safeAreaInsets.top + 12
let bottomSafeY = view.bounds.height - view.safeAreaInsets.bottom - 12
let availableSpaceAbove = anchorRect.minY - topSafeY
let availableSpaceBelow = bottomSafeY - anchorRect.maxY
let prefersAbove = availableSpaceAbove >= tooltipSize.height + verticalSpacing || availableSpaceAbove >= availableSpaceBelow
let tooltipPlacement: RDEPUBAttachmentTooltipView.ArrowPlacement = prefersAbove ? .bottom : .top
let originY: CGFloat
switch tooltipPlacement {
case .bottom:
originY = max(topSafeY, anchorRect.minY - tooltipSize.height - verticalSpacing)
case .top:
originY = min(bottomSafeY - tooltipSize.height, anchorRect.maxY + verticalSpacing)
}
let arrowTipX = min( let arrowTipX = min(
max(anchorRect.midX - originX, tooltip.minimumArrowX), max(anchorPoint.x - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX tooltipSize.width - tooltip.minimumArrowX
) )
tooltip.setArrowTipX(arrowTipX) tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
tooltip.frame.origin = CGPoint(x: originX, y: originY) tooltip.frame.origin = CGPoint(x: originX, y: originY)
overlay.addSubview(tooltip) overlay.addSubview(tooltip)
view.addSubview(overlay) view.addSubview(overlay)
@ -431,11 +449,17 @@ private final class RDEPUBAttachmentTooltipOverlayView: UIView {
} }
private final class RDEPUBAttachmentTooltipView: UIView { private final class RDEPUBAttachmentTooltipView: UIView {
enum ArrowPlacement {
case top
case bottom
}
private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20) private let contentInsets = UIEdgeInsets(top: 18, left: 20, bottom: 24, right: 20)
private let arrowSize = CGSize(width: 20, height: 10) private let arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18 private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28 private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat? private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = { private let textLabel: UILabel = {
let label = UILabel() let label = UILabel()
label.numberOfLines = 0 label.numberOfLines = 0
@ -465,17 +489,20 @@ private final class RDEPUBAttachmentTooltipView: UIView {
shapeLayer.path = bubblePath(in: bounds).cgPath shapeLayer.path = bubblePath(in: bounds).cgPath
shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor shapeLayer.fillColor = UIColor(white: 0.26, alpha: 0.96).cgColor
let topInset = contentInsets.top + (arrowPlacement == .top ? arrowSize.height : 0)
let bottomInset = contentInsets.bottom + (arrowPlacement == .bottom ? arrowSize.height : 0)
let labelFrame = bounds.inset(by: UIEdgeInsets( let labelFrame = bounds.inset(by: UIEdgeInsets(
top: contentInsets.top, top: topInset,
left: contentInsets.left, left: contentInsets.left,
bottom: contentInsets.bottom + arrowSize.height, bottom: bottomInset,
right: contentInsets.right right: contentInsets.right
)) ))
textLabel.frame = labelFrame textLabel.frame = labelFrame
} }
func setArrowTipX(_ value: CGFloat) { func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
arrowTipX = value arrowTipX = value
arrowPlacement = placement
setNeedsLayout() setNeedsLayout()
} }
@ -492,12 +519,23 @@ private final class RDEPUBAttachmentTooltipView: UIView {
} }
private func bubblePath(in rect: CGRect) -> UIBezierPath { private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect = CGRect( let bubbleRect: CGRect
x: rect.minX, switch arrowPlacement {
y: rect.minY, case .bottom:
width: rect.width, bubbleRect = CGRect(
height: rect.height - arrowSize.height x: rect.minX,
) y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
case .top:
bubbleRect = CGRect(
x: rect.minX,
y: rect.minY + arrowSize.height,
width: rect.width,
height: rect.height - arrowSize.height
)
}
let arrowMidX = min( let arrowMidX = min(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX), max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX bubbleRect.width - minimumArrowX
@ -505,42 +543,82 @@ private final class RDEPUBAttachmentTooltipView: UIView {
let arrowHalfWidth = arrowSize.width / 2 let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath() let path = UIBezierPath()
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY)) switch arrowPlacement {
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY)) case .bottom:
path.addArc( path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius), path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
radius: cornerRadius, path.addArc(
startAngle: -.pi / 2, withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
endAngle: 0, radius: cornerRadius,
clockwise: true startAngle: -.pi / 2,
) endAngle: 0,
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius)) clockwise: true
path.addArc( )
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius), path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
radius: cornerRadius, path.addArc(
startAngle: 0, withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
endAngle: .pi / 2, radius: cornerRadius,
clockwise: true startAngle: 0,
) endAngle: .pi / 2,
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY)) clockwise: true
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height)) )
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY)) path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY)) path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.maxY + arrowSize.height))
path.addArc( path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.maxY))
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius), path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
radius: cornerRadius, path.addArc(
startAngle: .pi / 2, withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
endAngle: .pi, radius: cornerRadius,
clockwise: true startAngle: .pi / 2,
) endAngle: .pi,
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius)) clockwise: true
path.addArc( )
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius), path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
radius: cornerRadius, path.addArc(
startAngle: .pi, withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
endAngle: -.pi / 2, radius: cornerRadius,
clockwise: true startAngle: .pi,
) endAngle: -.pi / 2,
clockwise: true
)
case .top:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX - arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: arrowMidX, y: bubbleRect.minY - arrowSize.height))
path.addLine(to: CGPoint(x: arrowMidX + arrowHalfWidth, y: bubbleRect.minY))
path.addLine(to: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: -.pi / 2,
endAngle: 0,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.maxX, y: bubbleRect.maxY - cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.maxX - cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: 0,
endAngle: .pi / 2,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.maxY - cornerRadius),
radius: cornerRadius,
startAngle: .pi / 2,
endAngle: .pi,
clockwise: true
)
path.addLine(to: CGPoint(x: bubbleRect.minX, y: bubbleRect.minY + cornerRadius))
path.addArc(
withCenter: CGPoint(x: bubbleRect.minX + cornerRadius, y: bubbleRect.minY + cornerRadius),
radius: cornerRadius,
startAngle: .pi,
endAngle: -.pi / 2,
clockwise: true
)
}
path.close() path.close()
return path return path
} }

View File

@ -296,31 +296,41 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderDelegate {
reconcileTextPaginationSizeIfNeeded(for: pageNum) reconcileTextPaginationSizeIfNeeded(for: pageNum)
// map // map
let previousCurrentPage = readerView.currentPage
runtime.applyPendingFullPageMapIfNeeded() runtime.applyPendingFullPageMapIfNeeded()
let effectivePageNum = readerView.currentPage >= 0 ? readerView.currentPage : pageNum
if previousCurrentPage != readerView.currentPage, effectivePageNum != pageNum {
return
}
if readerContext.bookPageMap != nil { if readerContext.bookPageMap != nil {
_ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: pageNum + 1) _ = runtime.prepareOnDemandChapter(forAbsolutePageNumber: effectivePageNum + 1)
runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: pageNum + 1) runtime.extendPartialBookPageMapIfNeeded(currentPageNumber: effectivePageNum + 1)
} }
let totalPages = pageCountOfReaderView(readerView: readerView) let totalPages = pageCountOfReaderView(readerView: readerView)
if totalPages > 0, pageNum == totalPages - 1 { if totalPages > 0, effectivePageNum == totalPages - 1 {
delegate?.epubReaderDidReachEnd(self) delegate?.epubReaderDidReachEnd(self)
} }
if (textBook != nil || readerContext.bookPageMap != nil), if (textBook != nil || readerContext.bookPageMap != nil),
let location = resolvedTextLocation(forPageNumber: pageNum + 1) { let location = resolvedTextLocation(forPageNumber: effectivePageNum + 1) {
persist(location: location) persist(location: location)
synchronizeTextReadingState(pageNumber: pageNum + 1, location: location) synchronizeTextReadingState(pageNumber: effectivePageNum + 1, location: location)
// JumpSession
runtime.locationCoordinator.recordPageChangeIfNeeded()
return return
} }
if let location = fallbackLocation(for: pageNum) { if let location = fallbackLocation(for: effectivePageNum) {
persist(location: location) persist(location: location)
} }
if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving { if readingSession?.navigatorState == .jumping || readingSession?.navigatorState == .moving {
readingSession?.transition(to: .idle) readingSession?.transition(to: .idle)
} }
// JumpSession
runtime.locationCoordinator.recordPageChangeIfNeeded()
} }
/// ///

View File

@ -0,0 +1,307 @@
import Foundation
///
struct RDEPUBBackgroundCoverageSegment {
/// spineIndex
let lowerSpineIndex: Int
/// spineIndex
let upperSpineIndex: Int
///
let pageMap: RDEPUBBookPageMap
/// spineIndex
let resolvedSpineIndices: Set<Int>
///
let generatedAt: CFAbsoluteTime
///
let renderSignature: String
///
let estimatedMemoryBytes: Int
/// spineIndex
func contains(spineIndex: Int) -> Bool {
spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
}
/// spineIndex
func distance(to spineIndex: Int) -> Int {
if contains(spineIndex: spineIndex) { return 0 }
return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex))
}
}
///
struct RDEPUBBackgroundCoverageStorePolicy {
///
let maxResidentSegments: Int
///
let maxChaptersPerSegment: Int
///
let memoryBudgetBytes: Int
///
static let `default` = RDEPUBBackgroundCoverageStorePolicy(
maxResidentSegments: 8,
maxChaptersPerSegment: 256,
memoryBudgetBytes: 8 * 1024 * 1024 // 8MB
)
}
///
final class RDEPUBBackgroundCoverageStore {
private unowned let context: RDEPUBReaderContext
///
private let policy: RDEPUBBackgroundCoverageStorePolicy
///
private var segments: [RDEPUBBackgroundCoverageSegment] = []
///
private var currentMemoryBytes: Int = 0
/// 访 LRU
private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
self.context = context
self.policy = policy
}
///
func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
//
evictIfNeeded(forNewSegment: segment)
//
var merged = false
for (index, existing) in segments.enumerated() {
if canMerge(existing, segment) {
if let mergedSegment = mergeSegments(existing, segment) {
segments[index] = mergedSegment
currentMemoryBytes = currentMemoryBytes - existing.estimatedMemoryBytes + mergedSegment.estimatedMemoryBytes
merged = true
RDEPUBBackgroundTrace.log(
"CoverageStore",
"merged segments: \(existing.lowerSpineIndex)-\(existing.upperSpineIndex) + \(segment.lowerSpineIndex)-\(segment.upperSpineIndex)"
)
break
}
}
}
if !merged {
segments.append(segment)
currentMemoryBytes += segment.estimatedMemoryBytes
}
// 访
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
RDEPUBBackgroundTrace.log(
"CoverageStore",
"added segment \(segment.lowerSpineIndex)-\(segment.upperSpineIndex) total=\(segments.count) memory=\(currentMemoryBytes)B"
)
}
/// spineIndex
func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { $0.contains(spineIndex: spineIndex) }
if let segment {
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
}
return segment
}
/// spineIndex
func findSegment(covering spineIndices: Set<Int>) -> RDEPUBBackgroundCoverageSegment? {
let segment = segments.first { segment in
spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
}
if let segment {
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
}
return segment
}
///
func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
segments
}
///
func clearAll() {
segments.removeAll()
currentMemoryBytes = 0
lastAccessTime.removeAll()
}
///
func clearColdSegments(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
) {
let keepIndices = activeWindowSpineIndices.union(protectedSpineIndices)
segments.removeAll { segment in
let isCold = !segment.resolvedSpineIndices.contains(where: { keepIndices.contains($0) })
if isCold {
currentMemoryBytes -= segment.estimatedMemoryBytes
lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
}
return isCold
}
}
///
func handleMemoryWarning(
activeWindowSpineIndices: Set<Int>,
protectedSpineIndices: Set<Int>
) {
RDEPUBBackgroundTrace.log(
"CoverageStore",
"memory warning: clearing cold segments, current=\(currentMemoryBytes)B"
)
//
clearColdSegments(
activeWindowSpineIndices: activeWindowSpineIndices,
protectedSpineIndices: protectedSpineIndices
)
//
if currentMemoryBytes > policy.memoryBudgetBytes {
//
let sorted = segments.sorted { lhs, rhs in
let lhsDistance = lhs.resolvedSpineIndices.map { idx in
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
}.min() ?? Int.max
let rhsDistance = rhs.resolvedSpineIndices.map { idx in
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
}.min() ?? Int.max
return lhsDistance > rhsDistance
}
for segment in sorted {
if currentMemoryBytes <= policy.memoryBudgetBytes { break }
currentMemoryBytes -= segment.estimatedMemoryBytes
lastAccessTime.removeValue(forKey: segment.lowerSpineIndex)
segments.removeAll { $0.lowerSpineIndex == segment.lowerSpineIndex }
}
}
RDEPUBBackgroundTrace.log(
"CoverageStore",
"after cleanup: segments=\(segments.count) memory=\(currentMemoryBytes)B"
)
}
///
private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) {
//
while segments.count >= policy.maxResidentSegments {
evictLeastRecentlyUsed()
}
//
while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes {
evictLeastRecentlyUsed()
}
}
/// 使
private func evictLeastRecentlyUsed() {
guard !segments.isEmpty else { return }
// 访
var oldestTime = CFAbsoluteTimeGetCurrent()
var oldestIndex = 0
for (index, segment) in segments.enumerated() {
let accessTime = lastAccessTime[segment.lowerSpineIndex] ?? 0
if accessTime < oldestTime {
oldestTime = accessTime
oldestIndex = index
}
}
let evicted = segments.remove(at: oldestIndex)
currentMemoryBytes -= evicted.estimatedMemoryBytes
lastAccessTime.removeValue(forKey: evicted.lowerSpineIndex)
RDEPUBBackgroundTrace.log(
"CoverageStore",
"evicted segment \(evicted.lowerSpineIndex)-\(evicted.upperSpineIndex)"
)
}
///
private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool {
//
guard lhs.renderSignature == rhs.renderSignature else { return false }
//
let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 &&
rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1
return overlap
}
///
private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? {
let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex)
let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex)
let newChapterCount = newUpper - newLower + 1
//
if newChapterCount > policy.maxChaptersPerSegment {
//
return nil
}
// resolvedSpineIndices
let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices)
//
let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs
let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs
let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap)
return RDEPUBBackgroundCoverageSegment(
lowerSpineIndex: newLower,
upperSpineIndex: newUpper,
pageMap: newPageMap,
resolvedSpineIndices: newResolved,
generatedAt: max(lhs.generatedAt, rhs.generatedAt),
renderSignature: lhs.renderSignature,
estimatedMemoryBytes: estimateMemoryBytes(pageMap: newPageMap, resolvedCount: newResolved.count)
)
}
///
private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
for entry in older.entries {
entriesBySpineIndex[entry.spineIndex] = entry
}
for entry in newer.entries {
entriesBySpineIndex[entry.spineIndex] = entry
}
for spineIndex in entriesBySpineIndex.keys.sorted() {
guard let entry = entriesBySpineIndex[spineIndex] else { continue }
builder.add(
spineIndex: entry.spineIndex,
href: entry.href,
title: entry.title,
pageCount: entry.pageCount,
fragmentOffsets: entry.fragmentOffsets
)
}
return builder.build()
}
///
private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
256 + pageMap.entries.count * 96 + resolvedCount * 16
}
}

View File

@ -0,0 +1,193 @@
import Foundation
///
///
///
struct RDEPUBBackgroundPriorityPolicy {
///
let hotRadius: Int
///
let warmRadius: Int
///
let maxWarmJumpAnchors: Int
///
let coldLaneShare: Double
///
static let `default` = RDEPUBBackgroundPriorityPolicy(
hotRadius: 24,
warmRadius: 96,
maxWarmJumpAnchors: 2,
coldLaneShare: 0.15
)
///
static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy {
let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48)
let warmRadius = min(max(hotRadius * 3, 32), 192)
return RDEPUBBackgroundPriorityPolicy(
hotRadius: hotRadius,
warmRadius: warmRadius,
maxWarmJumpAnchors: 2,
coldLaneShare: 0.15
)
}
}
///
enum RDEPUBPriorityBand: Int, Comparable {
case hot = 0
case warmPrimary = 1
case warmSecondary = 2
case cold = 3
static func < (lhs: RDEPUBPriorityBand, rhs: RDEPUBPriorityBand) -> Bool {
lhs.rawValue < rhs.rawValue
}
}
///
struct RDEPUBWarmJumpAnchor {
let spineIndex: Int
let timestamp: CFAbsoluteTime
let sequenceNumber: Int
}
///
struct RDEPUBMetadataParseWorkItem {
let spineIndex: Int
let generation: Int
let priorityBand: RDEPUBPriorityBand
///
var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) {
(priorityBand.rawValue, 0, 0, spineIndex)
}
}
///
final class RDEPUBBackgroundPriorityManager {
private unowned let context: RDEPUBReaderContext
///
private(set) var policy: RDEPUBBackgroundPriorityPolicy
///
private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
/// generation
private(set) var currentGeneration: Int = 0
///
private var coldCursor: Int = 0
init(context: RDEPUBReaderContext) {
self.context = context
self.policy = .default
}
///
func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
policy = newPolicy
}
///
func addWarmAnchor(spineIndex: Int) {
let anchor = RDEPUBWarmJumpAnchor(
spineIndex: spineIndex,
timestamp: CFAbsoluteTimeGetCurrent(),
sequenceNumber: currentGeneration
)
warmAnchors.insert(anchor, at: 0)
// N
if warmAnchors.count > policy.maxWarmJumpAnchors {
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
}
currentGeneration += 1
coldCursor = 0 //
RDEPUBBackgroundTrace.log(
"PriorityManager",
"addWarmAnchor spine=\(spineIndex) generation=\(currentGeneration) anchors=\(warmAnchors.count)"
)
}
/// spineIndex
func makeMetadataPriorityOrder(
allBuildableIndices: [Int],
currentSpineIndex: Int?,
cachedSpineIndices: Set<Int>
) -> [Int] {
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
guard !uncachedIndices.isEmpty else { return [] }
// spineIndex
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
let band = classifySpineIndex(
spineIndex: spineIndex,
currentSpineIndex: currentSpineIndex
)
return (spineIndex, band)
}
//
let sorted = items.sorted { lhs, rhs in
//
if lhs.band != rhs.band {
return lhs.band < rhs.band
}
//
let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max
let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max
if lhsDistanceToCurrent != rhsDistanceToCurrent {
return lhsDistanceToCurrent < rhsDistanceToCurrent
}
// spineIndex
return lhs.spineIndex < rhs.spineIndex
}
return sorted.map { $0.spineIndex }
}
/// spineIndex
private func classifySpineIndex(
spineIndex: Int,
currentSpineIndex: Int?
) -> RDEPUBPriorityBand {
//
if let current = currentSpineIndex {
let distance = abs(spineIndex - current)
if distance <= policy.hotRadius {
return .hot
}
}
//
for (index, anchor) in warmAnchors.enumerated() {
let distance = abs(spineIndex - anchor.spineIndex)
if distance <= policy.warmRadius {
return index == 0 ? .warmPrimary : .warmSecondary
}
}
//
return .cold
}
///
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
warmAnchors
}
///
func reset() {
warmAnchors.removeAll()
currentGeneration = 0
coldCursor = 0
}
}

View File

@ -0,0 +1,233 @@
import Foundation
///
///
///
/// -
/// -
struct RDEPUBJumpSession {
/// spineIndex
let anchorSpineIndex: Int
///
let createdAt: CFAbsoluteTime
/// spineIndex
let protectedSpineIndices: Set<Int>
///
let sequenceNumber: Int
///
let expiresAt: CFAbsoluteTime
///
let reason: Reason
///
enum Reason {
case tableOfContentsJump
case bookmarkJump
case searchJump
}
///
enum EndReason {
///
case coverageComplete
///
case navigatedAway
///
case timeout
///
case superseded
}
}
/// JumpSession
public struct RDEPUBJumpSessionPolicy: Equatable {
///
public let exitPageThreshold: Int
///
public let timeout: TimeInterval
///
public let idleGracePeriod: TimeInterval
///
public let protectedNeighborRadius: Int
///
public static let `default` = RDEPUBJumpSessionPolicy(
exitPageThreshold: 6,
timeout: 20,
idleGracePeriod: 1.5,
protectedNeighborRadius: 1
)
public init(
exitPageThreshold: Int = 6,
timeout: TimeInterval = 20,
idleGracePeriod: TimeInterval = 1.5,
protectedNeighborRadius: Int = 1
) {
self.exitPageThreshold = exitPageThreshold
self.timeout = timeout
self.idleGracePeriod = idleGracePeriod
self.protectedNeighborRadius = protectedNeighborRadius
}
}
/// JumpSession
final class RDEPUBJumpSessionManager {
private unowned let context: RDEPUBReaderContext
/// JumpSession
private(set) var activeSession: RDEPUBJumpSession?
///
private var nextSequenceNumber: Int = 0
///
private var consecutivePageCount: Int = 0
///
private var lastPageDirection: PageDirection?
///
private var lastActivityTime: CFAbsoluteTime = 0
///
enum PageDirection {
case forward
case backward
}
init(context: RDEPUBReaderContext) {
self.context = context
}
/// JumpSession
@discardableResult
func createSession(
anchorSpineIndex: Int,
reason: RDEPUBJumpSession.Reason,
totalSpineCount: Int
) -> RDEPUBJumpSession {
let policy = context.configuration.jumpSessionPolicy
let now = CFAbsoluteTimeGetCurrent()
//
var protectedIndices: Set<Int> = [anchorSpineIndex]
for offset in 1...policy.protectedNeighborRadius {
let lower = anchorSpineIndex - offset
let upper = anchorSpineIndex + offset
if lower >= 0 {
protectedIndices.insert(lower)
}
if upper < totalSpineCount {
protectedIndices.insert(upper)
}
}
nextSequenceNumber += 1
let session = RDEPUBJumpSession(
anchorSpineIndex: anchorSpineIndex,
createdAt: now,
protectedSpineIndices: protectedIndices,
sequenceNumber: nextSequenceNumber,
expiresAt: now + policy.timeout,
reason: reason
)
activeSession = session
consecutivePageCount = 0
lastPageDirection = nil
lastActivityTime = now
RDEPUBBackgroundTrace.log(
"JumpSession",
"created anchor=\(anchorSpineIndex) protected=\(protectedIndices.count) seq=\(nextSequenceNumber)"
)
return session
}
///
func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
guard activeSession != nil else { return }
let direction: PageDirection = toSpineIndex >= fromSpineIndex ? .forward : .backward
lastActivityTime = CFAbsoluteTimeGetCurrent()
if direction == lastPageDirection {
consecutivePageCount += 1
} else {
consecutivePageCount = 1
lastPageDirection = direction
}
}
///
func shouldAllowPageMapTakeover(candidateSpineIndices: Set<Int>) -> Bool {
guard let session = activeSession else {
return true // Session
}
//
let protectedIndices = session.protectedSpineIndices
let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
Double(protectedIndices.count)
// 80%
return coverageRatio >= 0.8
}
/// Session
func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
guard let session = activeSession else { return nil }
let now = CFAbsoluteTimeGetCurrent()
let policy = context.configuration.jumpSessionPolicy
// 1.
if now >= session.expiresAt {
if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
RDEPUBBackgroundTrace.log(
"JumpSession",
"end: timeout seq=\(session.sequenceNumber)"
)
return .timeout
}
}
// 2.
if !session.protectedSpineIndices.contains(currentSpineIndex) {
if consecutivePageCount >= policy.exitPageThreshold {
RDEPUBBackgroundTrace.log(
"JumpSession",
"end: navigated-away seq=\(session.sequenceNumber) pages=\(consecutivePageCount)"
)
return .navigatedAway
}
} else {
//
consecutivePageCount = 0
}
return nil
}
/// Session
func endSession(_ reason: RDEPUBJumpSession.EndReason) {
guard let session = activeSession else { return }
RDEPUBBackgroundTrace.log(
"JumpSession",
"ended seq=\(session.sequenceNumber) reason=\(reason)"
)
activeSession = nil
consecutivePageCount = 0
lastPageDirection = nil
}
/// Session
func clearSession() {
activeSession = nil
consecutivePageCount = 0
lastPageDirection = nil
nextSequenceNumber = 0
}
}

View File

@ -0,0 +1,228 @@
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
}
}

View File

@ -10,6 +10,9 @@ import Foundation
final class RDEPUBReaderLocationCoordinator { final class RDEPUBReaderLocationCoordinator {
private unowned let context: RDEPUBReaderContext private unowned let context: RDEPUBReaderContext
/// spineIndex
private var lastPageChangeSpineIndex: Int?
init(context: RDEPUBReaderContext) { init(context: RDEPUBReaderContext) {
self.context = context self.context = context
} }
@ -23,6 +26,9 @@ final class RDEPUBReaderLocationCoordinator {
) -> Bool { ) -> Bool {
guard let controller = context.controller, guard let controller = context.controller,
let readerView = context.readerView else { return false } let readerView = context.readerView else { return false }
if context.bookPageMap != nil {
_ = context.runtime?.ensureOnDemandNavigationTargetAvailable(for: location)
}
guard let targetPageNumber = controller.pageNumber(for: location) else { guard let targetPageNumber = controller.pageNumber(for: location) else {
readerView.transitionToPage(pageNum: 0) readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle) context.readingSession?.transition(to: .idle)
@ -50,6 +56,10 @@ final class RDEPUBReaderLocationCoordinator {
context.readingSession?.transition(to: .jumping) context.readingSession?.transition(to: .jumping)
} }
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated) readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
// JumpSession
recordPageChangeIfNeeded()
return true return true
} }
@ -95,4 +105,40 @@ final class RDEPUBReaderLocationCoordinator {
controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem) controller.delegate?.epubReader(controller, didUpdateCurrentTableOfContentsItem: controller.currentTableOfContentsItem)
controller.updateReaderChrome() controller.updateReaderChrome()
} }
/// JumpSession
func recordPageChangeIfNeeded() {
guard let runtime = context.runtime,
let bookPageMap = context.bookPageMap,
let readerView = context.readerView else { return }
let currentPageNumber = readerView.currentPage + 1
guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
return
}
if let lastSpineIndex = lastPageChangeSpineIndex,
lastSpineIndex != currentSpineIndex {
runtime.jumpSessionManager.recordPageChange(
fromSpineIndex: lastSpineIndex,
toSpineIndex: currentSpineIndex
)
}
lastPageChangeSpineIndex = currentSpineIndex
// JumpSession
let isIdle = context.secondsSinceLastUserNavigation() > 2.0
if let endReason = runtime.jumpSessionManager.checkSessionEnd(
currentSpineIndex: currentSpineIndex,
isIdle: isIdle
) {
runtime.jumpSessionManager.endSession(endReason)
}
}
///
func resetPageChangeState() {
lastPageChangeSpineIndex = nil
}
} }

View File

@ -9,11 +9,69 @@ import Foundation
/// - /// -
/// - /// -
final class RDEPUBReaderPaginationCoordinator { 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 private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
/// N pageMap /// N pageMap
static var pageMapRefreshInterval: Int = 32 static var pageMapRefreshInterval: Int = 32
private unowned let context: RDEPUBReaderContext private unowned let context: RDEPUBReaderContext
private let metadataParseControlLock = NSLock()
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
init(context: RDEPUBReaderContext) { init(context: RDEPUBReaderContext) {
self.context = context self.context = context
@ -375,8 +433,12 @@ final class RDEPUBReaderPaginationCoordinator {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) } 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, while context.controller != nil,
cancellationController?.isCancelled != true,
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown { context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08) Thread.sleep(forTimeInterval: 0.08)
} }
@ -390,12 +452,17 @@ final class RDEPUBReaderPaginationCoordinator {
// MARK: - Phase 0 // MARK: - Phase 0
///
private static let maxRetryCount = 3
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
/// pageCountpageRangesfragmentOffsets /// pageCountpageRangesfragmentOffsets
/// RDEPUBTextBook /// RDEPUBTextBook
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) { func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
let context = self.context let context = self.context
guard let parser = context.parser, guard let parser = context.parser,
let publication = context.publication else { return } let publication = context.publication else { return }
let cancellationController = beginMetadataParseCancellationController(for: token)
let pageSize = context.currentTextPageSize() let pageSize = context.currentTextPageSize()
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize) let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
@ -409,12 +476,21 @@ final class RDEPUBReaderPaginationCoordinator {
DispatchQueue.global(qos: .utility).async { [weak self] in DispatchQueue.global(qos: .utility).async { [weak self] in
guard let self else { return } 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 // contentHash + SHA-256
let prewarmStart = CFAbsoluteTimeGetCurrent() let prewarmStart = CFAbsoluteTimeGetCurrent()
var contentHashBySpineIndex: [Int: String] = [:] var contentHashBySpineIndex: [Int: String] = [:]
for spineIndex in allBuildableIndices { 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) guard let href = publication.spine.indices.contains(spineIndex)
? publication.spine[spineIndex].href : nil, ? publication.spine[spineIndex].href : nil,
let html = parser.htmlString(forRelativePath: href) else { let html = parser.htmlString(forRelativePath: href) else {
@ -447,16 +523,18 @@ final class RDEPUBReaderPaginationCoordinator {
let cachedSummaries = restored?.summaries ?? [:] let cachedSummaries = restored?.summaries ?? [:]
let cachedSpineIndices = Set(cachedSummaries.keys) let cachedSpineIndices = Set(cachedSummaries.keys)
let resultLock = NSLock() let resultLock = NSLock()
var summariesBySpineIndex = cachedSummaries let parseState = MetadataParseState(
var totalResolvedCount = cachedSpineIndices.count summariesBySpineIndex: cachedSummaries,
var lastAppliedCount = cachedSpineIndices.count totalResolvedCount: cachedSpineIndices.count,
lastAppliedCount: cachedSpineIndices.count
)
if !cachedSpineIndices.isEmpty { if !cachedSpineIndices.isEmpty {
RDEPUBBackgroundTrace.log( RDEPUBBackgroundTrace.log(
"MetadataParse", "MetadataParse",
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)" "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 { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil else { return } 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() let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0 var totalRenderMs: Double = 0
@ -480,19 +585,29 @@ final class RDEPUBReaderPaginationCoordinator {
queue.name = "com.rdreader.metadata.parse" queue.name = "com.rdreader.metadata.parse"
queue.qualityOfService = .utility queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = workerCount queue.maxConcurrentOperationCount = workerCount
cancellationController.attach(queue: queue)
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
for (offset, spineIndex) in uncachedSpineIndices.enumerated() { for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
queue.addOperation { let operation = BlockOperation()
operation.addExecutionBlock { [weak operation] in
guard context.controller != nil, guard context.controller != nil,
context.paginationToken == token else { context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return return
} }
do { do {
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)") RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in 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 chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStart = CFAbsoluteTimeGetCurrent() let renderStart = CFAbsoluteTimeGetCurrent()
guard let result = try chapterBuilder.buildChapter( guard let result = try chapterBuilder.buildChapter(
@ -504,6 +619,13 @@ final class RDEPUBReaderPaginationCoordinator {
) else { ) else {
return nil 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 renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
let chapter = result.chapter let chapter = result.chapter
@ -522,6 +644,13 @@ final class RDEPUBReaderPaginationCoordinator {
chapterContentHash: cacheKey.chapterContentHash, chapterContentHash: cacheKey.chapterContentHash,
pageMetadataList: chapter.pages.map { .from($0.metadata) } 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() let writeStart = CFAbsoluteTimeGetCurrent()
summaryDiskCache?.write(summary: summary, for: cacheKey) summaryDiskCache?.write(summary: summary, for: cacheKey)
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000 let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
@ -540,15 +669,22 @@ final class RDEPUBReaderPaginationCoordinator {
} }
guard let renderResult else { return } guard let renderResult else { return }
guard context.controller != nil,
context.paginationToken == token,
!cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
// pageMap // pageMap
var snapshot: [Int: RDEPUBChapterSummary]? var snapshot: [Int: RDEPUBChapterSummary]?
resultLock.lock() resultLock.lock()
summariesBySpineIndex[spineIndex] = renderResult parseState.summariesBySpineIndex[spineIndex] = renderResult
totalResolvedCount += 1 parseState.totalResolvedCount += 1
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count { if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
lastAppliedCount = totalResolvedCount || parseState.totalResolvedCount == allBuildableIndices.count {
snapshot = summariesBySpineIndex parseState.lastAppliedCount = parseState.totalResolvedCount
snapshot = parseState.summariesBySpineIndex
} }
resultLock.unlock() resultLock.unlock()
@ -561,20 +697,54 @@ final class RDEPUBReaderPaginationCoordinator {
timingLock.unlock() timingLock.unlock()
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil else { return } context.controller != nil,
!cancellationController.isCancelled else { return }
context.runtime?.refreshBookPageMapInPlace(partialMap) context.runtime?.refreshBookPageMapInPlace(partialMap)
} }
} }
} catch { } catch {
guard !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil,
operation?.isCancelled != true else {
return
}
timingLock.lock() timingLock.lock()
failedChapters += 1 failedChapters += 1
timingLock.unlock() timingLock.unlock()
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)") 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() queue.waitUntilAllOperationsAreFinished()
summaryDiskCache?.flushPendingWrites() if !cancellationController.isCancelled,
context.paginationToken == token,
context.controller != nil {
summaryDiskCache?.flushPendingWrites()
}
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000) let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
timingLock.lock() timingLock.lock()
@ -595,19 +765,39 @@ final class RDEPUBReaderPaginationCoordinator {
context.lastMetadataParseConcurrency = workerCount context.lastMetadataParseConcurrency = workerCount
guard context.controller != nil, guard context.controller != nil,
context.paginationToken == token else { context.paginationToken == token,
!cancellationController.isCancelled else {
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed") RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
return return
} }
let finalMergeStart = CFAbsoluteTimeGetCurrent() 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) let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log( RDEPUBBackgroundTrace.log(
"MetadataParse", "MetadataParse",
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)" "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 { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil else { return } 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? { private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
guard let summaryDiskCache = context.runtime?.summaryDiskCache, guard let summaryDiskCache = context.runtime?.summaryDiskCache,
let parser = context.parser else { let parser = context.parser else {

View File

@ -22,6 +22,10 @@ final class RDEPUBReaderRuntime {
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context) lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context) lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context) lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
init(context: RDEPUBReaderContext) { init(context: RDEPUBReaderContext) {
self.context = context self.context = context
@ -326,15 +330,44 @@ final class RDEPUBReaderRuntime {
let readerView = context.readerView, let readerView = context.readerView,
let controller = context.controller else { return } let controller = context.controller else { return }
context.pendingFullPageMap = nil // 使
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
// map switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
return
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
// candidatePageMap
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
break
}
}
///
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDReaderView,
controller: RDEPUBReaderController
) {
let currentLocation = locationCoordinator.currentVisibleLocation() let currentLocation = locationCoordinator.currentVisibleLocation()
context.pendingFullPageMap = nil
// map // map
context.textBook = nil context.textBook = nil
context.bookPageMap = pendingMap context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: pendingMap)) context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
// map // map
if let currentLocation { if let currentLocation {
@ -347,6 +380,18 @@ final class RDEPUBReaderRuntime {
} else { } else {
readerView.reloadPageCountOnly() readerView.reloadPageCountOnly()
} }
// JumpSessioncoverage-complete
if let currentLocation,
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
let activeSession = jumpSessionManager.activeSession {
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
let protectedIndices = activeSession.protectedSpineIndices
let isFullyCovered = protectedIndices.isSubset(of: candidateIndices)
if isFullyCovered {
jumpSessionManager.endSession(.coverageComplete)
}
}
} }
/// ///
@ -401,6 +446,95 @@ final class RDEPUBReaderRuntime {
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature) viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
} }
@discardableResult
func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
guard context.bookPageMap != nil,
let publication = context.publication,
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
return false
}
//
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
} else {
false
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
if let pendingMap = context.pendingFullPageMap,
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
applyPendingFullPageMapIfNeeded()
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
}
let buildableSpineIndices = publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
guard let anchorPosition = buildableSpineIndices.firstIndex(of: targetSpineIndex) else {
return false
}
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
context.configuration.onDemandChapterWindowSize
)
let chapters = loadPartialWindowChapters(
around: anchorPosition,
in: buildableSpineIndices,
targetSpineIndex: targetSpineIndex,
windowSize: normalizedWindowSize
)
guard !chapters.isEmpty else {
return false
}
chapterRuntimeStore.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = makePartialPageMap(from: chapters)
context.bookPageMap = partialMap
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
context.readerView?.reloadData()
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
//
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
}
@discardableResult @discardableResult
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool { func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> Bool {
guard let bookPageMap = context.bookPageMap, guard let bookPageMap = context.bookPageMap,
@ -468,22 +602,40 @@ final class RDEPUBReaderRuntime {
guard currentMap.totalChapters < buildableSpineIndices.count else { guard currentMap.totalChapters < buildableSpineIndices.count else {
return return
} }
guard currentMap.totalPages - currentPageNumber <= minimumTrailingPages else {
//
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
let isNearStart = currentPageNumber <= minimumTrailingPages
//
var spineIndicesToAppend: [Int] = []
if isNearEnd {
//
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
} else if isNearStart {
//
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
} else {
//
return return
} }
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1 guard !spineIndicesToAppend.isEmpty else {
let nextSpineIndices = buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount)
guard !nextSpineIndices.isEmpty else {
return return
} }
RDEPUBBackgroundTrace.log( RDEPUBBackgroundTrace.log(
"Runtime", "Runtime",
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(Array(nextSpineIndices))" "extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")"
) )
var appendedEntries: [RDEPUBBookPageMapEntry] = [] var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in nextSpineIndices { for spineIndex in spineIndicesToAppend {
do { do {
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration( let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex, spineIndex: spineIndex,
@ -545,9 +697,80 @@ final class RDEPUBReaderRuntime {
} }
func clearOnDemandPageModeState() { func clearOnDemandPageModeState() {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllForSettingsChange() chapterRuntimeStore.invalidateAllForSettingsChange()
context.bookPageMap = nil context.bookPageMap = nil
context.pendingFullPageMap = nil context.pendingFullPageMap = nil
jumpSessionManager.clearSession()
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()
}
///
func handleMemoryWarning() {
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let activeWindowIndices: Set<Int> = if let currentSpineIndex {
[currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1]
} else {
[]
}
let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? []
backgroundCoverageStore.handleMemoryWarning(
activeWindowSpineIndices: activeWindowIndices,
protectedSpineIndices: protectedIndices
)
}
private func loadPartialWindowChapters(
around anchorPosition: Int,
in buildableSpineIndices: [Int],
targetSpineIndex: Int,
windowSize: Int
) -> [RDEPUBRuntimeChapter] {
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
let startIndex = max(0, upperBound - max(windowSize, 1))
let window = Array(buildableSpineIndices[startIndex..<upperBound])
var chapters: [RDEPUBRuntimeChapter] = []
for spineIndex in window {
do {
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
chapters.append(chapter)
} catch {
if spineIndex == targetSpineIndex {
RDEPUBBackgroundTrace.log(
"Runtime",
"ensureNavigationTarget FAILED target spine=\(spineIndex) error=\(error)"
)
return []
}
RDEPUBBackgroundTrace.log(
"Runtime",
"ensureNavigationTarget skip adjacent spine=\(spineIndex) error=\(error)"
)
}
}
return chapters
}
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
var builder = RDEPUBBookPageMap.Builder()
for chapter in chapters {
builder.add(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
}
return builder.build()
} }
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot { private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {

View File

@ -114,6 +114,11 @@ public struct RDEPUBReaderConfiguration: Equatable {
/// writeTotalMs I/O cpuCount * 1.25~1.5 I/O /// writeTotalMs I/O cpuCount * 1.25~1.5 I/O
public var metadataParsingConcurrency: Int public var metadataParsingConcurrency: Int
// MARK: JumpSession
/// JumpSession
public var jumpSessionPolicy: RDEPUBJumpSessionPolicy
// MARK: // MARK:
/// URL scheme https /// URL scheme https
@ -172,6 +177,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText, textRenderingEngine: RDEPUBTextRenderingEngine = .dtCoreText,
onDemandChapterWindowSize: Int = 3, onDemandChapterWindowSize: Int = 3,
metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount, metadataParsingConcurrency: Int = ProcessInfo.processInfo.activeProcessorCount,
jumpSessionPolicy: RDEPUBJumpSessionPolicy = .default,
allowedExternalURLSchemes: Set<String> = ["https"], allowedExternalURLSchemes: Set<String> = ["https"],
requiresExternalLinkConfirmation: Bool = true, requiresExternalLinkConfirmation: Bool = true,
allowsInspectableWebViews: Bool = false, allowsInspectableWebViews: Bool = false,
@ -197,6 +203,7 @@ public struct RDEPUBReaderConfiguration: Equatable {
self.textRenderingEngine = textRenderingEngine self.textRenderingEngine = textRenderingEngine
self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize) self.onDemandChapterWindowSize = Self.normalizedChapterWindowSize(onDemandChapterWindowSize)
self.metadataParsingConcurrency = max(1, metadataParsingConcurrency) self.metadataParsingConcurrency = max(1, metadataParsingConcurrency)
self.jumpSessionPolicy = jumpSessionPolicy
self.allowedExternalURLSchemes = allowedExternalURLSchemes self.allowedExternalURLSchemes = allowedExternalURLSchemes
self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation self.requiresExternalLinkConfirmation = requiresExternalLinkConfirmation
self.allowsInspectableWebViews = allowsInspectableWebViews self.allowsInspectableWebViews = allowsInspectableWebViews

View File

@ -17,7 +17,8 @@ protocol RDEPUBTextContentViewDelegate: AnyObject {
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
didActivateAttachmentText text: String, didActivateAttachmentText text: String,
sourceRect: CGRect sourceRect: CGRect,
sourcePoint: CGPoint
) )
func textContentView( func textContentView(
_ contentView: RDEPUBTextContentView, _ contentView: RDEPUBTextContentView,
@ -552,7 +553,12 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate {
} }
if let attachmentText = attachmentText(at: point), if let attachmentText = attachmentText(at: point),
let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) { let sourceRect = attachmentSourceRect(at: point, fallbackPoint: point) {
delegate?.textContentView(self, didActivateAttachmentText: attachmentText, sourceRect: sourceRect) delegate?.textContentView(
self,
didActivateAttachmentText: attachmentText,
sourceRect: sourceRect,
sourcePoint: convert(point, from: overlayView)
)
return return
} }
guard let highlight = highlight(at: point), guard let highlight = highlight(at: point),