1. Configurable chapter window size (onDemandChapterWindowSize: 3-15) - Parameterized window radius in RDEPUBChapterRuntimeStore - Updated RDEPUBChapterWindowCoordinator to use configurable radius - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next - Even numbers round up to odd (4→5), min 3, max 15 2. Configurable metadata parsing concurrency (metadataParsingConcurrency) - Default equals CPU core count - Parallel execution via OperationQueue in paginateMetadataOnly - Each worker creates independent builder instance - NSLock protects result aggregation 3. Per-chapter and total wall-clock timing instrumentation - Separated render vs I/O timing per chapter - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs - Timing stored in RDEPUBReaderContext for test access 4. UI automation test infrastructure - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args - DemoReaderState exposes windowSize, parseMs, parseConcurrency - ConfigurableWindowTests: 5 test cases for window size 3/5/15 - ConcurrentParsingTests: 4 test cases for concurrency 2/4 - MetadataParseBenchmarkTests: serial vs parallel benchmark 5. Bug fixes - Fixed page snap-back during background parsing (isUserInteracting check) - Reduced BookPageMap refresh frequency from 16 to 32 chapters - Moved waitForReadingInteractionToSettle outside operation loop 6. Design doc: dual-layer PageMap (estimated + precise mixed)
160 lines
6.2 KiB
Swift
160 lines
6.2 KiB
Swift
import Foundation
|
||
|
||
final class RDEPUBChapterSummaryDiskCache {
|
||
private let cacheDirectory: URL
|
||
private let fileManager = FileManager.default
|
||
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
||
|
||
init(cacheDirectory: URL) {
|
||
self.cacheDirectory = cacheDirectory
|
||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||
}
|
||
|
||
// MARK: - 写入(异步)
|
||
|
||
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||
queue.async {
|
||
self.writeImmediately(summary: summary, for: key)
|
||
}
|
||
}
|
||
|
||
/// 同步写入:用于后台整书元数据构建完成前,确保摘要文件已经真实落盘。
|
||
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||
queue.sync {
|
||
self.writeImmediately(summary: summary, for: key)
|
||
}
|
||
}
|
||
|
||
/// 等待此前已排队的异步写入全部落盘。
|
||
func flushPendingWrites() {
|
||
queue.sync { }
|
||
}
|
||
|
||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||
|
||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||
let fileURL = self.fileURL(for: key)
|
||
guard let data = try? Data(contentsOf: fileURL) else { return nil }
|
||
return try? JSONDecoder().decode(RDEPUBChapterSummary.self, from: data)
|
||
}
|
||
|
||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||
|
||
/// 批量读取指定缓存键列表的摘要,返回 spineIndex → summary 映射。
|
||
/// 同步方法,应在后台线程调用。
|
||
/// 由调用方负责构建完整的缓存键列表(含正确的 contentHash)。
|
||
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
|
||
summaries: [Int: RDEPUBChapterSummary],
|
||
mapBuilder: RDEPUBBookPageMap.Builder
|
||
) {
|
||
var summaries: [Int: RDEPUBChapterSummary] = [:]
|
||
var mapBuilder = RDEPUBBookPageMap.Builder()
|
||
|
||
for item in keys {
|
||
if let summary = read(for: item.key) {
|
||
summaries[item.spineIndex] = summary
|
||
mapBuilder.add(
|
||
spineIndex: item.spineIndex,
|
||
href: item.href,
|
||
title: item.title,
|
||
pageCount: summary.pageCount,
|
||
fragmentOffsets: summary.fragmentOffsets
|
||
)
|
||
}
|
||
}
|
||
return (summaries, mapBuilder)
|
||
}
|
||
|
||
/// 检查指定缓存键列表是否全部有对应的磁盘摘要。
|
||
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||
for key in keys {
|
||
if read(for: key) == nil {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
/// 语义化别名:用于判断当前 renderSignature 下是否具备完整章节摘要集合。
|
||
func containsCompleteSet(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||
isCacheComplete(keys: keys)
|
||
}
|
||
|
||
/// 清空所有缓存文件
|
||
func removeAll() {
|
||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: nil) else { return }
|
||
for fileURL in files where fileURL.pathExtension == "json" {
|
||
try? fileManager.removeItem(at: fileURL)
|
||
}
|
||
}
|
||
|
||
// MARK: - key -> 文件路径
|
||
|
||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||
let digest = rawKey.sha256Hex
|
||
return cacheDirectory.appendingPathComponent("\(digest).json")
|
||
}
|
||
|
||
private func writeImmediately(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||
let fileURL = self.fileURL(for: key)
|
||
let data = try? JSONEncoder().encode(summary)
|
||
try? data?.write(to: fileURL)
|
||
}
|
||
}
|
||
|
||
struct RDEPUBChapterSummary: Codable {
|
||
let pageRanges: [RangeData]
|
||
let pageCount: Int
|
||
let fragmentOffsets: [String: Int]
|
||
let renderSignature: String
|
||
let schemaVersion: Int
|
||
let chapterContentHash: String
|
||
let pageMetadataList: [PageMetadataSummary]
|
||
|
||
static let currentSchemaVersion = 6
|
||
|
||
struct RangeData: Codable {
|
||
let location: Int
|
||
let length: Int
|
||
var nsRange: NSRange { NSRange(location: location, length: length) }
|
||
}
|
||
|
||
struct PageMetadataSummary: Codable {
|
||
let breakReason: String
|
||
let attachmentRanges: [RangeData]
|
||
let attachmentKinds: [String]
|
||
let blockKinds: [String]
|
||
let semanticHints: [String]
|
||
let attachmentPlacements: [String]
|
||
let trailingFragmentID: String?
|
||
|
||
func toPageMetadata() -> RDEPUBTextPageMetadata {
|
||
RDEPUBTextPageMetadata(
|
||
breakReason: RDEPUBTextPageBreakReason(rawValue: breakReason) ?? .frameLimit,
|
||
blockRange: nil,
|
||
attachmentRanges: attachmentRanges.map { $0.nsRange },
|
||
attachmentKinds: attachmentKinds.compactMap { RDEPUBTextAttachmentKind(rawValue: $0) },
|
||
blockKinds: blockKinds.compactMap { RDEPUBTextBlockKind(rawValue: $0) },
|
||
semanticHints: semanticHints.compactMap { RDEPUBTextSemanticHint(rawValue: $0) },
|
||
attachmentPlacements: attachmentPlacements.compactMap { RDEPUBTextAttachmentPlacement(rawValue: $0) },
|
||
trailingFragmentID: trailingFragmentID,
|
||
diagnostics: []
|
||
)
|
||
}
|
||
|
||
static func from(_ metadata: RDEPUBTextPageMetadata) -> PageMetadataSummary {
|
||
PageMetadataSummary(
|
||
breakReason: metadata.breakReason.rawValue,
|
||
attachmentRanges: metadata.attachmentRanges.map { .init(location: $0.location, length: $0.length) },
|
||
attachmentKinds: metadata.attachmentKinds.map { $0.rawValue },
|
||
blockKinds: metadata.blockKinds.map { $0.rawValue },
|
||
semanticHints: metadata.semanticHints.map { $0.rawValue },
|
||
attachmentPlacements: metadata.attachmentPlacements.map { $0.rawValue },
|
||
trailingFragmentID: metadata.trailingFragmentID
|
||
)
|
||
}
|
||
}
|
||
}
|