- 新增 RDEPUBReaderSearchCoordinator 与 RDEPUBSelectionState 管理搜索和选中状态 - 新增 BookmarkChromeStateTests、NavigationBackwardTests、SelectionAnnotateTests 等 UI 测试 - 新增多个边界测试 epub 样本(损坏结构、空归档、缺失文件、流式外链验证) - 重构阅读器 chrome 状态管理,统一 tool bar 与 search bar 交互 - 优化大书分页缓存策略(RDEPUBChapterSummaryDiskCache、RDEPUBPageCountCache) - 移除废弃的 RDEPUBLocationConverter 和 RDEPUBPageBreakPolicy - 更新 epub-bridge.js 与 JS bridge 通信协议 - 全面更新现有 UI 测试以适配新的 helper 和状态管理
244 lines
10 KiB
Swift
244 lines
10 KiB
Swift
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
|
||
}
|
||
}
|
||
}
|