import Foundation /// 远距跳转会话:保护前台阅读窗口不被后台页图覆盖。 /// /// 当用户执行远距跳转(目录、书签、搜索)后创建,在保护期内: /// - 不允许任何不覆盖保护区的后台页图接管前台 /// - 后台补全围绕当前阅读区段优先 struct RDEPUBJumpSession { /// 跳转目标的 spineIndex let anchorSpineIndex: Int /// 创建时间 let createdAt: CFAbsoluteTime /// 受保护的 spineIndex 集合 let protectedSpineIndices: Set /// 序列号,用于区分多次跳转 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 = [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) -> 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 } }