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>
234 lines
7.0 KiB
Swift
234 lines
7.0 KiB
Swift
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
|
||
}
|
||
}
|