feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip) - 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示 - 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配 - 新增 RDEPUBSelectionLoupeView 选区放大镜 - 新增 MetadataParseWorker/CancellationController 元数据解析取消机制 - 重构 PresentationRuntime/PaginationCoordinator 精简职责 - 优化 ChapterLoader/WarmupOrchestrator 异步章节加载 - CFI 模块微调与 NoteModels 更新 - 清理冗余文档,更新架构/UML/业务逻辑文档 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+176
-41
@@ -2,10 +2,21 @@ import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
private typealias LoadCompletion = (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
|
||||
private struct PendingLoad {
|
||||
var priority: LoadPriority
|
||||
var completions: [LoadCompletion]
|
||||
}
|
||||
|
||||
private weak var context: RDEPUBReaderContext?
|
||||
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
private let pendingLoadsLock = NSLock()
|
||||
|
||||
private var pendingLoads: [Int: PendingLoad] = [:]
|
||||
|
||||
var onDeferredCFIMapReady: ((Int) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -25,31 +36,70 @@ final class RDEPUBChapterLoader {
|
||||
case prefetch
|
||||
}
|
||||
|
||||
private enum LoadRegistrationResult {
|
||||
case created
|
||||
case joined(existingPriority: LoadPriority, effectivePriority: LoadPriority)
|
||||
}
|
||||
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "request spine=\(spineIndex) priority=\(priority)")
|
||||
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
||||
store: store
|
||||
)
|
||||
if let context {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
store.markBuilding(true)
|
||||
let completionOnMain: LoadCompletion = { result in
|
||||
DispatchQueue.main.async {
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
let registration = registerPendingLoad(
|
||||
spineIndex: spineIndex,
|
||||
priority: priority,
|
||||
completion: completionOnMain
|
||||
)
|
||||
|
||||
switch registration {
|
||||
case .joined(let existingPriority, let effectivePriority):
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoader",
|
||||
"dedupe spine=\(spineIndex) existingPriority=\(existingPriority) requestedPriority=\(priority) effectivePriority=\(effectivePriority)"
|
||||
)
|
||||
return
|
||||
case .created:
|
||||
break
|
||||
}
|
||||
|
||||
store.markBuilding(true)
|
||||
_ = store.beginPendingChapterLoad(for: spineIndex)
|
||||
|
||||
store.chapterLoadQueue.async { [self] in
|
||||
guard let context = self.context else {
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(RDEPUBChapterLoadError.missingParser))
|
||||
return
|
||||
}
|
||||
let queuePriority = self.pendingPriority(for: spineIndex) ?? priority
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(queuePriority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
@@ -71,12 +121,13 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
let chapter = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
"buildChapter spine=\(spineIndex) priority=\(queuePriority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
) {
|
||||
try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: availablePageRanges,
|
||||
diskSummary: diskSummary
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
||||
@@ -96,42 +147,38 @@ final class RDEPUBChapterLoader {
|
||||
store: store
|
||||
)
|
||||
|
||||
switch priority {
|
||||
let effectivePriority = self.pendingPriority(for: spineIndex) ?? queuePriority
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
switch effectivePriority {
|
||||
case .navigation:
|
||||
|
||||
let nextTarget = store.consumeNavigationTarget()
|
||||
if let target = nextTarget, target != spineIndex {
|
||||
|
||||
store.markBuilding(false)
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: { _ in })
|
||||
return
|
||||
}
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .preview:
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
|
||||
case .prefetch:
|
||||
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(chapter))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .success(chapter))
|
||||
}
|
||||
} catch {
|
||||
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
store.endPendingChapterLoad(for: spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
completion(.failure(error))
|
||||
}
|
||||
self.resolvePendingLoad(spineIndex: spineIndex, result: .failure(error))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,11 +187,15 @@ final class RDEPUBChapterLoader {
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let context else {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
if let cached = store?.chapterData(for: spineIndex) {
|
||||
if let store {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex, context: context),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
@@ -155,6 +206,26 @@ final class RDEPUBChapterLoader {
|
||||
throw RDEPUBChapterLoadError.missingParser
|
||||
}
|
||||
|
||||
if store.hasPendingChapterLoad(for: spineIndex) {
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let registration = registerPendingLoad(
|
||||
spineIndex: spineIndex,
|
||||
priority: .navigation
|
||||
) { pendingResult in
|
||||
result = pendingResult
|
||||
semaphore.signal()
|
||||
}
|
||||
if case .joined(let existingPriority, let effectivePriority) = registration {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoader",
|
||||
"sync join spine=\(spineIndex) existingPriority=\(existingPriority) effectivePriority=\(effectivePriority)"
|
||||
)
|
||||
semaphore.wait()
|
||||
return try result!.get()
|
||||
}
|
||||
}
|
||||
|
||||
store.assertNotOnChapterLoadQueue()
|
||||
|
||||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||||
@@ -167,7 +238,7 @@ final class RDEPUBChapterLoader {
|
||||
"sync buildChapter spine=\(spineIndex)"
|
||||
) {
|
||||
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
@@ -178,7 +249,8 @@ final class RDEPUBChapterLoader {
|
||||
let chapter = try self.buildChapter(
|
||||
spineIndex: spineIndex,
|
||||
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
|
||||
diskSummary: diskSummary
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
store.insertChapter(chapter)
|
||||
let pageCount = RDEPUBRuntimePageCount(
|
||||
@@ -206,10 +278,48 @@ final class RDEPUBChapterLoader {
|
||||
return try result!.get()
|
||||
}
|
||||
|
||||
private func registerPendingLoad(
|
||||
spineIndex: Int,
|
||||
priority: LoadPriority,
|
||||
completion: @escaping LoadCompletion
|
||||
) -> LoadRegistrationResult {
|
||||
pendingLoadsLock.lock()
|
||||
defer { pendingLoadsLock.unlock() }
|
||||
|
||||
if var pending = pendingLoads[spineIndex] {
|
||||
let existingPriority = pending.priority
|
||||
pending.priority = LoadPriority.higherPriority(existingPriority, priority)
|
||||
pending.completions.append(completion)
|
||||
pendingLoads[spineIndex] = pending
|
||||
return .joined(existingPriority: existingPriority, effectivePriority: pending.priority)
|
||||
}
|
||||
|
||||
pendingLoads[spineIndex] = PendingLoad(priority: priority, completions: [completion])
|
||||
return .created
|
||||
}
|
||||
|
||||
private func pendingPriority(for spineIndex: Int) -> LoadPriority? {
|
||||
pendingLoadsLock.lock()
|
||||
let priority = pendingLoads[spineIndex]?.priority
|
||||
pendingLoadsLock.unlock()
|
||||
return priority
|
||||
}
|
||||
|
||||
private func resolvePendingLoad(spineIndex: Int, result: Result<RDEPUBRuntimeChapter, Error>) {
|
||||
pendingLoadsLock.lock()
|
||||
let completions = pendingLoads.removeValue(forKey: spineIndex)?.completions ?? []
|
||||
pendingLoadsLock.unlock()
|
||||
|
||||
guard !completions.isEmpty else { return }
|
||||
|
||||
completions.forEach { $0(result) }
|
||||
}
|
||||
|
||||
private func buildChapter(
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
diskSummary: RDEPUBChapterSummary? = nil
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else {
|
||||
@@ -231,7 +341,8 @@ final class RDEPUBChapterLoader {
|
||||
pageSize: pageSize,
|
||||
style: style,
|
||||
layoutConfig: layoutConfig,
|
||||
diskSummary: diskSummary
|
||||
diskSummary: diskSummary,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
@@ -251,7 +362,8 @@ final class RDEPUBChapterLoader {
|
||||
from: result.chapter,
|
||||
spineIndex: spineIndex,
|
||||
pageSize: pageSize,
|
||||
layoutConfig: layoutConfig
|
||||
layoutConfig: layoutConfig,
|
||||
context: context
|
||||
)
|
||||
}
|
||||
|
||||
@@ -263,7 +375,8 @@ final class RDEPUBChapterLoader {
|
||||
pageSize: CGSize,
|
||||
style: RDEPUBTextRenderStyle,
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
diskSummary: RDEPUBChapterSummary? = nil
|
||||
diskSummary: RDEPUBChapterSummary? = nil,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
@@ -465,7 +578,8 @@ final class RDEPUBChapterLoader {
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
pageSize: CGSize,
|
||||
layoutConfig: RDEPUBTextLayoutConfig
|
||||
layoutConfig: RDEPUBTextLayoutConfig,
|
||||
context: RDEPUBReaderContext
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: chapter.attributedContent,
|
||||
@@ -482,7 +596,7 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex)
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex, context: context)
|
||||
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
@@ -498,7 +612,7 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
private func makeCacheKey(spineIndex: Int, context: RDEPUBReaderContext) -> RDEPUBChapterCacheKey {
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||
|
||||
@@ -513,7 +627,7 @@ final class RDEPUBChapterLoader {
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash = contentHashForSpineIndex(spineIndex)
|
||||
let contentHash = contentHashForSpineIndex(spineIndex, context: context)
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
bookID: context.currentBookIdentifier ?? "",
|
||||
@@ -540,7 +654,7 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
defer { store.endBuildingCFIMap(for: spineIndex) }
|
||||
guard let rawHTML = self.context.parser?.htmlString(forRelativePath: href),
|
||||
guard let rawHTML = self.context?.parser?.htmlString(forRelativePath: href),
|
||||
let chapterText else {
|
||||
return
|
||||
}
|
||||
@@ -586,12 +700,12 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int, context: RDEPUBReaderContext) -> String {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return "" }
|
||||
let href = publication.spine[spineIndex].href
|
||||
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
|
||||
return html.sha256Hex
|
||||
return html.rd_sha256Hex
|
||||
}
|
||||
|
||||
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||||
@@ -638,13 +752,34 @@ final class RDEPUBChapterLoader {
|
||||
markers: markers,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: "",
|
||||
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).sha256Hex,
|
||||
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).rd_sha256Hex,
|
||||
fragmentPathMap: domPaths
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private extension RDEPUBChapterLoader.LoadPriority {
|
||||
|
||||
static func higherPriority(_ lhs: Self, _ rhs: Self) -> Self {
|
||||
if lhs.rank >= rhs.rank {
|
||||
return lhs
|
||||
}
|
||||
return rhs
|
||||
}
|
||||
|
||||
var rank: Int {
|
||||
switch self {
|
||||
case .prefetch:
|
||||
return 0
|
||||
case .preview:
|
||||
return 1
|
||||
case .navigation:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBChapterLoadError: LocalizedError {
|
||||
|
||||
case missingParser
|
||||
|
||||
+26
@@ -32,6 +32,10 @@ final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
private let cfiMapLock = NSLock()
|
||||
|
||||
private var pendingChapterLoadSpineIndices: Set<Int> = []
|
||||
|
||||
private let pendingChapterLoadLock = NSLock()
|
||||
|
||||
init() {
|
||||
|
||||
imageCache.countLimit = 50
|
||||
@@ -146,6 +150,25 @@ final class RDEPUBChapterRuntimeStore {
|
||||
buildingLock.unlock()
|
||||
}
|
||||
|
||||
func beginPendingChapterLoad(for spineIndex: Int) -> Bool {
|
||||
pendingChapterLoadLock.lock()
|
||||
defer { pendingChapterLoadLock.unlock() }
|
||||
return pendingChapterLoadSpineIndices.insert(spineIndex).inserted
|
||||
}
|
||||
|
||||
func endPendingChapterLoad(for spineIndex: Int) {
|
||||
pendingChapterLoadLock.lock()
|
||||
pendingChapterLoadSpineIndices.remove(spineIndex)
|
||||
pendingChapterLoadLock.unlock()
|
||||
}
|
||||
|
||||
func hasPendingChapterLoad(for spineIndex: Int) -> Bool {
|
||||
pendingChapterLoadLock.lock()
|
||||
let hasPendingLoad = pendingChapterLoadSpineIndices.contains(spineIndex)
|
||||
pendingChapterLoadLock.unlock()
|
||||
return hasPendingLoad
|
||||
}
|
||||
|
||||
func beginBuildingCFIMap(for spineIndex: Int) -> Bool {
|
||||
cfiMapLock.lock()
|
||||
defer { cfiMapLock.unlock() }
|
||||
@@ -166,5 +189,8 @@ final class RDEPUBChapterRuntimeStore {
|
||||
cfiMapLock.lock()
|
||||
buildingCFIMapSpineIndices.removeAll()
|
||||
cfiMapLock.unlock()
|
||||
pendingChapterLoadLock.lock()
|
||||
pendingChapterLoadSpineIndices.removeAll()
|
||||
pendingChapterLoadLock.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -119,7 +119,7 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
||||
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
||||
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
|
||||
let digest = rawKey.sha256Hex
|
||||
let digest = rawKey.rd_sha256Hex
|
||||
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
|
||||
}
|
||||
|
||||
@@ -150,7 +150,7 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
|
||||
private static func cacheNamespacePrefix(for rawValue: String) -> String {
|
||||
rawValue.sha256Hex.prefix(12).lowercased()
|
||||
rawValue.rd_sha256Hex.prefix(12).lowercased()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+139
-13
@@ -24,6 +24,14 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
private var isExtendingPartialBookPageMap = false
|
||||
|
||||
private let prepareRequestStateLock = NSLock()
|
||||
|
||||
private var pendingPreparePageNumbers: Set<Int> = []
|
||||
|
||||
private var recentPrepareTimestamps: [Int: CFAbsoluteTime] = [:]
|
||||
|
||||
private let prepareRequestDebounceInterval: CFTimeInterval = 0.15
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
@@ -58,6 +66,16 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
|
||||
return false
|
||||
}
|
||||
let chapterReady = store.chapterData(for: spineIndex) != nil
|
||||
|
||||
if let debouncedResult = debouncedPrepareResult(
|
||||
pageNumber: pageNumber,
|
||||
spineIndex: spineIndex,
|
||||
chapterReady: chapterReady,
|
||||
allowSynchronousLoad: allowSynchronousLoad
|
||||
) {
|
||||
return debouncedResult
|
||||
}
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
@@ -70,7 +88,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||||
)
|
||||
|
||||
if store.chapterData(for: spineIndex) == nil {
|
||||
if !chapterReady {
|
||||
guard allowSynchronousLoad else {
|
||||
scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: spineIndex,
|
||||
@@ -85,11 +103,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
store: store
|
||||
)
|
||||
} catch {
|
||||
clearPendingPreparePageNumber(pageNumber)
|
||||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
markPrepareResolved(pageNumber)
|
||||
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
|
||||
@@ -189,6 +209,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
guard shouldSchedulePrefetch(for: spineIndex) else { continue }
|
||||
|
||||
store.addPrefetchTarget(spineIndex)
|
||||
RDEPUBBackgroundTrace.log("Runtime", "initial open prefetch forward spine=\(spineIndex)")
|
||||
@@ -226,9 +247,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
return true
|
||||
}
|
||||
|
||||
if let pendingMap = context.pendingFullPageMap,
|
||||
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
presentationRuntime.applyPendingFullPageMapIfNeeded()
|
||||
if context.pendingPageMapUpdates.contains(where: { update in
|
||||
update.pageMap.entry(forSpineIndex: targetSpineIndex) != nil
|
||||
}) {
|
||||
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
@@ -284,6 +306,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
asynchronouslyPreparingSpineIndices.removeAll()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
prepareRequestStateLock.lock()
|
||||
pendingPreparePageNumbers.removeAll()
|
||||
recentPrepareTimestamps.removeAll()
|
||||
prepareRequestStateLock.unlock()
|
||||
}
|
||||
|
||||
private func applyAsyncPartialBookPageMapExtension(
|
||||
@@ -337,7 +363,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
presentationRuntime.applyExtendedPartialPageMap(
|
||||
presentationRuntime.queueExtendedPartialPageMap(
|
||||
newMap,
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
@@ -349,7 +375,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
triggerPageNumber: Int,
|
||||
completion: ((Bool) -> Void)?
|
||||
) {
|
||||
guard beginAsynchronousChapterPreparation(for: spineIndex) else { return }
|
||||
guard beginAsynchronousChapterPreparation(for: spineIndex) else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async deduped spine=\(spineIndex) page=\(triggerPageNumber)"
|
||||
)
|
||||
return
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async spine=\(spineIndex) page=\(triggerPageNumber)"
|
||||
@@ -363,11 +395,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
self.endAsynchronousChapterPreparation(for: spineIndex)
|
||||
switch result {
|
||||
case .success:
|
||||
self.markPrepareResolved(triggerPageNumber)
|
||||
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
|
||||
case .failure(let error):
|
||||
self.clearPendingPreparePageNumber(triggerPageNumber)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async FAILED: spine=\(spineIndex) error=\(error)"
|
||||
@@ -389,7 +423,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
}
|
||||
|
||||
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||||
guard store.chapterData(for: adjacentSpineIndex) == nil else { continue }
|
||||
guard shouldSchedulePrefetch(for: adjacentSpineIndex) else { continue }
|
||||
store.addPrefetchTarget(adjacentSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
@@ -428,7 +462,7 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
|
||||
for targetSpineIndex in targets {
|
||||
guard store.chapterData(for: targetSpineIndex) == nil else { continue }
|
||||
guard shouldSchedulePrefetch(for: targetSpineIndex) else { continue }
|
||||
store.addPrefetchTarget(targetSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
@@ -441,7 +475,9 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
}
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible(
|
||||
minimumTrailingPages: Int = 2
|
||||
) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
@@ -449,8 +485,13 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
return
|
||||
}
|
||||
|
||||
let currentPageNumber = max(readerView.currentPage + 1, 1)
|
||||
let trailingPages = currentMap.totalPages - currentPageNumber
|
||||
guard trailingPages <= minimumTrailingPages else { return }
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
var projectedTotalPages = currentMap.totalPages
|
||||
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
|
||||
guard let chapter = store.chapterData(for: spineIndex) else { break }
|
||||
appendedEntries.append(
|
||||
@@ -463,6 +504,10 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
projectedTotalPages += chapter.pages.count
|
||||
if projectedTotalPages - currentPageNumber > minimumTrailingPages {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
@@ -497,12 +542,68 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
"appendLoadedForwardChapters currentPage=\(currentPageNumber) trailingBefore=\(trailingPages) trailingAfter=\(newMap.totalPages - currentPageNumber) chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: newMap))
|
||||
readerView.reloadPageCountOnly()
|
||||
presentationRuntime.queueForwardAppendedPageMap(newMap)
|
||||
}
|
||||
|
||||
private func shouldSchedulePrefetch(for spineIndex: Int) -> Bool {
|
||||
guard store.chapterData(for: spineIndex) == nil else { return false }
|
||||
guard !store.hasPrefetchTarget(spineIndex) else { return false }
|
||||
guard !store.hasPendingChapterLoad(for: spineIndex) else { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private func debouncedPrepareResult(
|
||||
pageNumber: Int,
|
||||
spineIndex: Int,
|
||||
chapterReady: Bool,
|
||||
allowSynchronousLoad: Bool
|
||||
) -> Bool? {
|
||||
prepareRequestStateLock.lock()
|
||||
defer { prepareRequestStateLock.unlock() }
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
recentPrepareTimestamps = recentPrepareTimestamps.filter { now - $0.value <= prepareRequestDebounceInterval }
|
||||
|
||||
if !allowSynchronousLoad && !chapterReady {
|
||||
let inserted = pendingPreparePageNumbers.insert(pageNumber).inserted
|
||||
if !inserted {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page deduped page=\(pageNumber) spine=\(spineIndex) chapterReady=false"
|
||||
)
|
||||
return false
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
pendingPreparePageNumbers.remove(pageNumber)
|
||||
if let lastTimestamp = recentPrepareTimestamps[pageNumber],
|
||||
now - lastTimestamp <= prepareRequestDebounceInterval {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page debounced page=\(pageNumber) spine=\(spineIndex) chapterReady=\(chapterReady)"
|
||||
)
|
||||
return chapterReady
|
||||
}
|
||||
|
||||
recentPrepareTimestamps[pageNumber] = now
|
||||
return nil
|
||||
}
|
||||
|
||||
private func markPrepareResolved(_ pageNumber: Int) {
|
||||
prepareRequestStateLock.lock()
|
||||
pendingPreparePageNumbers.remove(pageNumber)
|
||||
recentPrepareTimestamps[pageNumber] = CFAbsoluteTimeGetCurrent()
|
||||
prepareRequestStateLock.unlock()
|
||||
}
|
||||
|
||||
private func clearPendingPreparePageNumber(_ pageNumber: Int) {
|
||||
prepareRequestStateLock.lock()
|
||||
pendingPreparePageNumbers.remove(pageNumber)
|
||||
prepareRequestStateLock.unlock()
|
||||
}
|
||||
|
||||
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
|
||||
@@ -510,16 +611,41 @@ final class RDEPUBChapterWarmupOrchestrator {
|
||||
let bookPageMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
if readerView.isPageCurlTransitioning {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"defer refreshVisibleContent spine=\(spineIndex) triggerPage=\(triggerPageNumber) reason=pageCurlTransition"
|
||||
)
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||
self?.refreshVisibleContentIfNeeded(
|
||||
afterPreparing: spineIndex,
|
||||
triggerPageNumber: triggerPageNumber
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
let visiblePageNumber = readerView.currentPage + 1
|
||||
if visiblePageNumber == triggerPageNumber {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"refreshVisibleContent matchedTrigger spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
|
||||
)
|
||||
refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
guard visiblePageNumber > 0,
|
||||
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
|
||||
visibleSpineIndex == spineIndex else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"skip refreshVisibleContent spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
|
||||
)
|
||||
return
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"LoadingPage",
|
||||
"refreshVisibleContent matchedVisibleSpine spine=\(spineIndex) triggerPage=\(triggerPageNumber) visiblePage=\(visiblePageNumber)"
|
||||
)
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
import CryptoKit
|
||||
|
||||
extension String {
|
||||
|
||||
var sha256Hex: String {
|
||||
|
||||
let digest = SHA256.hash(data: Data(self.utf8))
|
||||
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user