ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBJumpSession.swift
shenlei c64460988a 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>
2026-06-15 16:28:11 +08:00

234 lines
7.0 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}
}