import Foundation import CryptoKit // MARK: - 分页缓存数据模型(WXRead 模式:只缓存页范围,不缓存富文本) /// 单个章节的分页元数据缓存,用于磁盘持久化。 /// /// 对标 WXRead 的 WRChapterPageCount 缓存策略:只存储每页的 NSRange + 分页原因, /// 不缓存完整的 NSAttributedString。缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤。 public struct RDEPUBTextChapterPaginationCache: Equatable { /// 章节文件相对路径 public var href: String /// 每页在章节富文本中的字符范围 public var pageRanges: [NSRange] /// 每页的分页原因(语义边界、帧限制等) public var breakReasons: [RDEPUBTextPageBreakReason] public var semanticHints: [RDEPUBTextSemanticHint] public init( href: String, pageRanges: [NSRange], breakReasons: [RDEPUBTextPageBreakReason], semanticHints: [RDEPUBTextSemanticHint] ) { self.href = href self.pageRanges = pageRanges self.breakReasons = breakReasons self.semanticHints = semanticHints } } // MARK: - NSCoding 归档层(只使用字符串和整数类型) /// 整本书的分页缓存归档,用于 NSKeyedArchiver 序列化。 /// 包含所有章节的分页归档数据。 final class PaginationCacheArchive: NSObject, NSSecureCoding { static var supportsSecureCoding: Bool { true } let chapters: [ChapterPaginationArchive] init(chapters: [ChapterPaginationArchive]) { self.chapters = chapters } func encode(with coder: NSCoder) { coder.encode(chapters, forKey: "chapters") } required init?(coder: NSCoder) { guard let chapters = coder.decodeObject(of: [NSArray.self, ChapterPaginationArchive.self], forKey: "chapters") as? [ChapterPaginationArchive] else { return nil } self.chapters = chapters } } /// 单个章节的分页归档,将 NSRange 拆分为 location/length 数组以便 NSSecureCoding 编码。 final class ChapterPaginationArchive: NSObject, NSSecureCoding { static var supportsSecureCoding: Bool { true } let href: String /// 页范围的起始位置数组(与 rangeLengths 一一对应) let rangeLocations: [NSNumber] /// 页范围的长度数组 let rangeLengths: [NSNumber] /// 分页原因的原始字符串数组 let breakReasons: [String] /// 语义提示的原始字符串数组 let semanticHints: [String] /// 从缓存模型构建归档对象 init(from cache: RDEPUBTextChapterPaginationCache) { self.href = cache.href self.rangeLocations = cache.pageRanges.map { NSNumber(value: $0.location) } self.rangeLengths = cache.pageRanges.map { NSNumber(value: $0.length) } self.breakReasons = cache.breakReasons.map(\.rawValue) self.semanticHints = cache.semanticHints.map(\.rawValue) } func encode(with coder: NSCoder) { coder.encode(href, forKey: "href") coder.encode(rangeLocations, forKey: "rangeLocations") coder.encode(rangeLengths, forKey: "rangeLengths") coder.encode(breakReasons, forKey: "breakReasons") coder.encode(semanticHints, forKey: "semanticHints") } required init?(coder: NSCoder) { guard let href = coder.decodeObject(of: NSString.self, forKey: "href") as String?, let rangeLocations = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLocations") as? [NSNumber], let rangeLengths = coder.decodeObject(of: [NSArray.self, NSNumber.self], forKey: "rangeLengths") as? [NSNumber], let breakReasons = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "breakReasons") as? [String], let semanticHints = coder.decodeObject(of: [NSArray.self, NSString.self], forKey: "semanticHints") as? [String] else { return nil } self.href = href self.rangeLocations = rangeLocations self.rangeLengths = rangeLengths self.breakReasons = breakReasons self.semanticHints = semanticHints } /// 将归档数据转换回缓存模型 func toCache() -> RDEPUBTextChapterPaginationCache { let pageRanges = zip(rangeLocations, rangeLengths).map { loc, len in NSRange(location: loc.intValue, length: len.intValue) } return RDEPUBTextChapterPaginationCache( href: href, pageRanges: pageRanges, breakReasons: breakReasons.compactMap(RDEPUBTextPageBreakReason.init(rawValue:)), semanticHints: semanticHints.compactMap(RDEPUBTextSemanticHint.init(rawValue:)) ) } } // MARK: - 分页缓存管理器 /// 磁盘持久化的分页缓存层,对标 WXRead 的 WRChapterPageCount 缓存模式。 /// /// 缓存策略: /// - 缓存键 = SHA256(书籍ID + 字号 + 行距 + 内边距 + 页面尺寸 + schema版本) /// - 缓存值 = 每章的页 NSRange 列表 + 分页原因(NSKeyedArchiver 序列化) /// - 富文本不缓存:缓存命中时重新渲染 HTML,但跳过 CoreText 分页步骤 /// - 线程安全:所有读写操作通过 serial DispatchQueue 串行执行 public final class RDEPUBTextBookCache { /// 缓存模式版本号,变更时旧缓存自动失效 // 分页算法调整后需要提升版本,避免继续复用旧页范围缓存。 public var schemaVersion: Int = 6 /// 串行队列,保证缓存读写的线程安全 private let queue = DispatchQueue(label: "com.rdreader.textbookcache", qos: .utility) /// 缓存文件目录 private let cacheDirectory: URL /// 初始化缓存管理器,自动创建缓存目录 /// - Parameter subdirectory: Caches 目录下的子目录名 public init(subdirectory: String = "RDEPUBTextBookCache") { let baseURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first? .appendingPathComponent(subdirectory, isDirectory: true) ?? FileManager.default.temporaryDirectory.appendingPathComponent(subdirectory, isDirectory: true) self.cacheDirectory = baseURL try? FileManager.default.createDirectory(at: cacheDirectory, withIntermediateDirectories: true) } // MARK: - 缓存键生成 // 对标 WRChapterPageCount.currentCacheKeyWithBookId: // 编码 bookID + fontSize + lineHeightMultiple + contentInsets + pageSize /// 生成缓存文件名(SHA256 哈希 + ".cache" 后缀)。 /// /// 任意布局参数变更都会导致缓存键不同,从而自动失效。 public func cacheKey( bookID: String, fontSize: CGFloat, lineHeightMultiple: CGFloat, contentInsets: UIEdgeInsets, pageSize: CGSize, layoutConfigSignature: String = RDEPUBTextLayoutConfig.default.cacheSignature ) -> String { let raw = "\(bookID)_\(fontSize)_\(lineHeightMultiple)_\(contentInsets.top)_\(contentInsets.left)_\(contentInsets.bottom)_\(contentInsets.right)_\(pageSize.width)_\(pageSize.height)_\(layoutConfigSignature)_v\(schemaVersion)" let digest = SHA256.hash(data: Data(raw.utf8)) let hex = digest.map { String(format: "%02x", $0) }.joined() return hex + ".cache" } // MARK: - 加载/保存(只缓存分页元数据) /// 从磁盘加载缓存的分页元数据,返回以章节 href 为键的字典。 /// 缓存未命中或反序列化失败时返回 nil。 public func load(key: String) -> [String: RDEPUBTextChapterPaginationCache]? { queue.sync { let fileURL = cacheDirectory.appendingPathComponent(key) guard FileManager.default.fileExists(atPath: fileURL.path) else { #if DEBUG print("[Cache] load MISS key=\(key)") #endif return nil } do { let data = try Data(contentsOf: fileURL) guard let archive = try NSKeyedUnarchiver.unarchivedObject( ofClass: PaginationCacheArchive.self, from: data ) else { #if DEBUG print("[Cache] load MISS key=\(key) (unarchive returned nil)") #endif return nil } var result: [String: RDEPUBTextChapterPaginationCache] = [:] for chapter in archive.chapters { result[chapter.href] = chapter.toCache() } #if DEBUG print("[Cache] load HIT key=\(key) chapters=\(result.count)") #endif return result } catch { #if DEBUG print("[Cache] load MISS key=\(key) error=\(error)") #endif return nil } } } /// 将分页元数据保存到磁盘(原子写入,防止损坏) public func save(_ chapters: [RDEPUBTextChapterPaginationCache], key: String) { queue.sync { let fileURL = cacheDirectory.appendingPathComponent(key) do { let archives = chapters.map { ChapterPaginationArchive(from: $0) } let bookArchive = PaginationCacheArchive(chapters: archives) let data = try NSKeyedArchiver.archivedData(withRootObject: bookArchive, requiringSecureCoding: true) try data.write(to: fileURL, options: .atomic) #if DEBUG print("[Cache] save key=\(key) chapters=\(chapters.count)") #endif } catch { #if DEBUG print("[Cache] save FAILED key=\(key) error=\(error)") #endif } } } // MARK: - 缓存失效 /// 清除所有缓存文件 public func invalidateAll() { queue.sync { let fileManager = FileManager.default guard let contents = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return } for file in contents { try? fileManager.removeItem(at: file) } #if DEBUG print("[Cache] invalidateAll") #endif } } }