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:
parent
e976ceebd5
commit
c64460988a
@ -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 |
|
||||||
|
|
||||||
## 项目信息
|
## 项目信息
|
||||||
|
|||||||
1017
Doc/大书远距目录跳转与后台补全优化方案.md
Normal file
1017
Doc/大书远距目录跳转与后台补全优化方案.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 屏幕方向即将变化回调,捕获待恢复的展示位置
|
/// 屏幕方向即将变化回调,捕获待恢复的展示位置
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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]
|
||||||
|
|
||||||
/// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets),
|
/// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets),
|
||||||
/// 写入磁盘摘要缓存,不累积 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 {
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 检查是否应该结束 JumpSession(coverage-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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user