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

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

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-15 16:28:11 +08:00
co-authored by Claude Opus 4.7
parent e976ceebd5
commit c64460988a
13 changed files with 2797 additions and 92 deletions
@@ -101,9 +101,10 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
func textContentView(
_ contentView: RDEPUBTextContentView,
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(
@@ -339,7 +340,7 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
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()
let overlay = RDEPUBAttachmentTooltipOverlayView(frame: view.bounds)
@@ -351,23 +352,40 @@ extension RDEPUBReaderController: RDEPUBTextContentViewDelegate {
let tooltip = RDEPUBAttachmentTooltipView()
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 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 tooltipSize = tooltip.frame.size
let idealX = anchorRect.midX - tooltipSize.width / 2
let idealX = anchorPoint.x - tooltipSize.width / 2
let minX = horizontalPadding
let maxX = max(minX, view.bounds.width - horizontalPadding - tooltipSize.width)
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(
max(anchorRect.midX - originX, tooltip.minimumArrowX),
max(anchorPoint.x - originX, tooltip.minimumArrowX),
tooltipSize.width - tooltip.minimumArrowX
)
tooltip.setArrowTipX(arrowTipX)
tooltip.setArrowTipX(arrowTipX, placement: tooltipPlacement)
tooltip.frame.origin = CGPoint(x: originX, y: originY)
overlay.addSubview(tooltip)
view.addSubview(overlay)
@@ -431,11 +449,17 @@ private final class RDEPUBAttachmentTooltipOverlayView: 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 arrowSize = CGSize(width: 20, height: 10)
private let cornerRadius: CGFloat = 18
private(set) var minimumArrowX: CGFloat = 28
private var arrowTipX: CGFloat?
private var arrowPlacement: ArrowPlacement = .bottom
private let textLabel: UILabel = {
let label = UILabel()
label.numberOfLines = 0
@@ -465,17 +489,20 @@ private final class RDEPUBAttachmentTooltipView: UIView {
shapeLayer.path = bubblePath(in: bounds).cgPath
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(
top: contentInsets.top,
top: topInset,
left: contentInsets.left,
bottom: contentInsets.bottom + arrowSize.height,
bottom: bottomInset,
right: contentInsets.right
))
textLabel.frame = labelFrame
}
func setArrowTipX(_ value: CGFloat) {
func setArrowTipX(_ value: CGFloat, placement: ArrowPlacement) {
arrowTipX = value
arrowPlacement = placement
setNeedsLayout()
}
@@ -492,12 +519,23 @@ private final class RDEPUBAttachmentTooltipView: UIView {
}
private func bubblePath(in rect: CGRect) -> UIBezierPath {
let bubbleRect = CGRect(
x: rect.minX,
y: rect.minY,
width: rect.width,
height: rect.height - arrowSize.height
)
let bubbleRect: CGRect
switch arrowPlacement {
case .bottom:
bubbleRect = CGRect(
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(
max(arrowTipX ?? bubbleRect.midX, minimumArrowX),
bubbleRect.width - minimumArrowX
@@ -505,42 +543,82 @@ private final class RDEPUBAttachmentTooltipView: UIView {
let arrowHalfWidth = arrowSize.width / 2
let path = UIBezierPath()
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, 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: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
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: 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
)
switch arrowPlacement {
case .bottom:
path.move(to: CGPoint(x: bubbleRect.minX + cornerRadius, 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: arrowMidX + arrowHalfWidth, y: bubbleRect.maxY))
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: 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
)
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()
return path
}