Files
ReadViewSDK/Sources/RDReaderView/EPUBTextRendering/RDEPUBTextBookCache.swift
T
2026-05-26 21:08:27 +08:00

229 lines
9.7 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
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 = 4
/// 串行队列,保证缓存读写的线程安全
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 {
print("[Cache] load MISS key=\(key)")
return nil
}
do {
let data = try Data(contentsOf: fileURL)
guard let archive = try NSKeyedUnarchiver.unarchivedObject(
ofClass: PaginationCacheArchive.self,
from: data
) else {
print("[Cache] load MISS key=\(key) (unarchive returned nil)")
return nil
}
var result: [String: RDEPUBTextChapterPaginationCache] = [:]
for chapter in archive.chapters {
result[chapter.href] = chapter.toCache()
}
print("[Cache] load HIT key=\(key) chapters=\(result.count)")
return result
} catch {
print("[Cache] load MISS key=\(key) error=\(error)")
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)
print("[Cache] save key=\(key) chapters=\(chapters.count)")
} catch {
print("[Cache] save FAILED key=\(key) error=\(error)")
}
}
}
// 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)
}
print("[Cache] invalidateAll")
}
}
}