Phase 1: Context 拆分 - 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices - RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services Phase 2: Runtime 拆分 - 新增 RDEPUBPresentationRuntime 处理分页状态管理 - 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排 - RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade Phase 0.5: 性能优化 - prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false) - extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载 - RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争 - CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded) - RDEPUBTextPageRenderView 引入静态位图缓存 - RDEPUBTextContentView 新增 loadingSpinner 占位页 Phase 3: 状态机 - 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验) - 新增 RDEPUBPaginationState 记录分页来源 Review 修复 - makeSummary 重复方法合并 - ensureNavigationTargetAvailable 同步路径加注释标记 UI 测试 - 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景) - 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
948 lines
42 KiB
Swift
948 lines
42 KiB
Swift
import Foundation
|
|
|
|
final class RDEPUBReaderPaginationCoordinator {
|
|
|
|
private final class MetadataParseState {
|
|
|
|
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
|
|
|
|
var totalResolvedCount: Int
|
|
|
|
var lastAppliedCount: Int
|
|
|
|
init(
|
|
summariesBySpineIndex: [Int: RDEPUBChapterSummary],
|
|
totalResolvedCount: Int,
|
|
lastAppliedCount: Int
|
|
) {
|
|
self.summariesBySpineIndex = summariesBySpineIndex
|
|
self.totalResolvedCount = totalResolvedCount
|
|
self.lastAppliedCount = lastAppliedCount
|
|
}
|
|
}
|
|
|
|
private final class MetadataParseCancellationController {
|
|
|
|
let token: UUID
|
|
|
|
private let lock = NSLock()
|
|
|
|
private weak var queue: OperationQueue?
|
|
|
|
private var cancelled = false
|
|
|
|
init(token: UUID) {
|
|
self.token = token
|
|
}
|
|
|
|
func attach(queue: OperationQueue) {
|
|
let shouldCancelImmediately: Bool
|
|
lock.lock()
|
|
self.queue = queue
|
|
shouldCancelImmediately = cancelled
|
|
lock.unlock()
|
|
|
|
if shouldCancelImmediately {
|
|
queue.cancelAllOperations()
|
|
}
|
|
}
|
|
|
|
func cancel() {
|
|
let queueToCancel: OperationQueue?
|
|
lock.lock()
|
|
cancelled = true
|
|
queueToCancel = queue
|
|
lock.unlock()
|
|
queueToCancel?.cancelAllOperations()
|
|
}
|
|
|
|
var isCancelled: Bool {
|
|
lock.lock()
|
|
let value = cancelled
|
|
lock.unlock()
|
|
return value
|
|
}
|
|
}
|
|
|
|
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
|
|
|
static var pageMapRefreshInterval: Int = 32
|
|
|
|
private unowned let context: RDEPUBReaderContext
|
|
|
|
private let metadataParseControlLock = NSLock()
|
|
|
|
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
|
|
|
|
init(context: RDEPUBReaderContext) {
|
|
self.context = context
|
|
}
|
|
|
|
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
|
guard let controller = context.controller,
|
|
let parser = context.parser,
|
|
let publication = context.publication,
|
|
let readingSession = context.readingSession else {
|
|
return
|
|
}
|
|
|
|
controller.isRepaginating = true
|
|
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .repaginating)
|
|
controller.errorLabel.isHidden = true
|
|
controller.showLoading()
|
|
let token = UUID()
|
|
context.paginationToken = token
|
|
#if DEBUG
|
|
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
|
|
#endif
|
|
|
|
if publication.readingProfile == .textReflowable {
|
|
#if DEBUG
|
|
print("[EPUB][Pagination] path=text-reflowable-on-demand")
|
|
#endif
|
|
paginateTextPublication(
|
|
parser: parser,
|
|
publication: publication,
|
|
readingSession: readingSession,
|
|
restoreLocation: restoreLocation,
|
|
token: token
|
|
)
|
|
return
|
|
}
|
|
|
|
if publication.layout == .fixed {
|
|
#if DEBUG
|
|
print("[EPUB][Pagination] path=fixed-layout")
|
|
#endif
|
|
let snapshot = readingSession.makePaginationSnapshot(
|
|
pageCounts: Array(repeating: 1, count: publication.spine.count),
|
|
preferences: controller.currentPreferences(),
|
|
layoutContext: controller.currentLayoutContext()
|
|
)
|
|
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
|
return
|
|
}
|
|
|
|
let paginator = context.makePaginator()
|
|
#if DEBUG
|
|
print("[EPUB][Pagination] path=web-paginator")
|
|
#endif
|
|
context.paginator = paginator
|
|
paginator.calculate(
|
|
parser: parser,
|
|
hostingView: controller.ensurePaginationHostView(),
|
|
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
|
|
) { [weak controller] pageCounts in
|
|
guard let controller, self.context.paginationToken == token else { return }
|
|
let snapshot = readingSession.makePaginationSnapshot(
|
|
pageCounts: pageCounts,
|
|
preferences: controller.currentPreferences(),
|
|
layoutContext: controller.currentLayoutContext()
|
|
)
|
|
self.context.paginator = nil
|
|
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
|
|
}
|
|
}
|
|
|
|
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
|
guard let controller = context.controller else { return }
|
|
context.textBook = textBook
|
|
context.bookPageMap = nil
|
|
context.pendingFullPageMap = nil
|
|
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
|
context.replaceActiveSnapshot(snapshot)
|
|
|
|
guard !textBook.pages.isEmpty else {
|
|
context.handle(error: RDEPUBParserError.emptySpine)
|
|
return
|
|
}
|
|
|
|
finishPagination(restoreLocation: restoreLocation)
|
|
}
|
|
|
|
func applyPaginationSnapshot(
|
|
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
|
restoreLocation: RDEPUBLocation?
|
|
) {
|
|
guard context.controller != nil else { return }
|
|
context.textBook = nil
|
|
context.bookPageMap = nil
|
|
context.pendingFullPageMap = nil
|
|
context.replaceActiveSnapshot(snapshot)
|
|
|
|
guard !snapshot.pages.isEmpty else {
|
|
context.handle(error: RDEPUBParserError.emptySpine)
|
|
return
|
|
}
|
|
|
|
finishPagination(restoreLocation: restoreLocation)
|
|
}
|
|
|
|
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
|
guard let controller = context.controller,
|
|
let readerView = context.readerView else { return }
|
|
controller.isRepaginating = false
|
|
context.runtime?.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
|
controller.hideLoading()
|
|
readerView.reloadData()
|
|
if let targetLocation = restoreLocation {
|
|
controller.restoreReadingLocation(targetLocation)
|
|
context.readingSession?.transition(to: .idle)
|
|
} else {
|
|
readerView.transitionToPage(pageNum: 0)
|
|
context.readingSession?.transition(to: .idle)
|
|
}
|
|
|
|
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
|
}
|
|
|
|
func repaginatePreservingCurrentLocation() {
|
|
guard context.publication != nil else { return }
|
|
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
|
?? context.currentVisibleLocation()
|
|
?? context.persistenceLocation()
|
|
paginatePublication(restoreLocation: restoreLocation)
|
|
}
|
|
|
|
func refreshVisibleContentPreservingLocation() {
|
|
guard let readerView = context.readerView else { return }
|
|
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
|
readerView.reloadData()
|
|
if let restoreLocation {
|
|
_ = context.restoreReadingLocation(restoreLocation)
|
|
}
|
|
}
|
|
|
|
func rebuildExternalTextBook() {
|
|
guard let controller = context.controller,
|
|
let textFileURL = controller.textFileURL else { return }
|
|
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
|
let pageSize = controller.currentTextPageSize()
|
|
let style = controller.currentTextRenderStyle()
|
|
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
|
|
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
|
|
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
|
|
}
|
|
}
|
|
|
|
private func paginateTextPublication(
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication,
|
|
readingSession: RDEPUBReadingSession,
|
|
restoreLocation: RDEPUBLocation?,
|
|
token: UUID
|
|
) {
|
|
guard let controller = context.controller else { return }
|
|
let context = self.context
|
|
|
|
let pageSize = controller.currentTextPageSize()
|
|
context.lastTextPaginationPageSize = pageSize
|
|
|
|
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
|
guard controller != nil else { return }
|
|
guard context.controller != nil else { return }
|
|
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
|
publication: publication,
|
|
readingSession: readingSession,
|
|
restoreLocation: restoreLocation
|
|
)
|
|
guard prioritizedCandidates.first != nil else {
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil else { return }
|
|
context.handle(error: RDEPUBParserError.emptySpine)
|
|
}
|
|
return
|
|
}
|
|
|
|
do {
|
|
guard context.controller != nil,
|
|
let runtime = context.runtime else { return }
|
|
RDEPUBBackgroundTrace.log(
|
|
"QuickOpen",
|
|
"begin token=\(token.uuidString) candidates=\(prioritizedCandidates.count) restoreSpine=\(readingSession.initialSpineIndex(for: restoreLocation))"
|
|
)
|
|
let runtimeChapter = try RDEPUBBackgroundTrace.measure(
|
|
"QuickOpen",
|
|
"loadFirstRenderableRuntimeChapter"
|
|
) {
|
|
try self.loadFirstRenderableRuntimeChapter(
|
|
prioritizedSpineIndices: prioritizedCandidates,
|
|
runtime: runtime
|
|
)
|
|
}
|
|
RDEPUBBackgroundTrace.log(
|
|
"QuickOpen",
|
|
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
|
)
|
|
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil else { return }
|
|
runtime.chapterRuntimeStore.setCurrentChapter(
|
|
spineIndex: runtimeChapter.spineIndex,
|
|
totalSpineCount: publication.spine.count,
|
|
windowRadius: context.configuration.chapterWindowRadius
|
|
)
|
|
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
|
|
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
|
runtime.prefetchForwardChaptersAfterInitialOpen(
|
|
anchorSpineIndex: runtimeChapter.spineIndex,
|
|
totalSpineCount: publication.spine.count
|
|
)
|
|
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
|
}
|
|
} catch {
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil else { return }
|
|
context.handle(error: error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadFirstRenderableRuntimeChapter(
|
|
prioritizedSpineIndices: [Int],
|
|
runtime: RDEPUBReaderRuntime
|
|
) throws -> RDEPUBRuntimeChapter {
|
|
var lastError: Error?
|
|
for spineIndex in prioritizedSpineIndices {
|
|
do {
|
|
return try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
|
spineIndex: spineIndex,
|
|
store: runtime.chapterRuntimeStore
|
|
)
|
|
} catch {
|
|
lastError = error
|
|
RDEPUBBackgroundTrace.log("QuickOpen", "skip spine=\(spineIndex) reason=\(error)")
|
|
}
|
|
}
|
|
throw lastError ?? RDEPUBParserError.emptySpine
|
|
}
|
|
|
|
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
|
var builder = RDEPUBBookPageMap.Builder()
|
|
for chapter in chapters {
|
|
builder.add(
|
|
spineIndex: chapter.spineIndex,
|
|
href: chapter.href,
|
|
title: chapter.title,
|
|
pageCount: chapter.pages.count,
|
|
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
|
)
|
|
}
|
|
return builder.build()
|
|
}
|
|
|
|
private func prioritizedBuildableSpineIndices(
|
|
publication: RDEPUBPublication,
|
|
readingSession: RDEPUBReadingSession,
|
|
restoreLocation: RDEPUBLocation?
|
|
) -> [Int] {
|
|
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
|
|
return publication.spine.indices
|
|
.filter { isBuildableTextSpine(at: $0, in: publication) }
|
|
.sorted { lhs, rhs in
|
|
abs(lhs - preferred) < abs(rhs - preferred)
|
|
}
|
|
}
|
|
|
|
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
|
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
|
|
}
|
|
|
|
private func waitForReadingInteractionToSettle(
|
|
using context: RDEPUBReaderContext,
|
|
cancellationController: MetadataParseCancellationController? = nil
|
|
) {
|
|
while context.controller != nil,
|
|
cancellationController?.isCancelled != true,
|
|
context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
|
|
Thread.sleep(forTimeInterval: 0.08)
|
|
}
|
|
}
|
|
|
|
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
|
|
guard publication.spine.indices.contains(index) else { return false }
|
|
let item = publication.spine[index]
|
|
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
|
}
|
|
|
|
private static let maxRetryCount = 3
|
|
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
|
|
|
|
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
|
|
let context = self.context
|
|
guard let parser = context.parser,
|
|
let publication = context.publication else { return }
|
|
let cancellationController = beginMetadataParseCancellationController(for: token)
|
|
|
|
let pageSize = context.currentTextPageSize()
|
|
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
|
let style = context.currentTextRenderStyle()
|
|
let renderSignature = context.currentRenderSignature()
|
|
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
|
let summaryDiskCache = context.runtime?.summaryDiskCache
|
|
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
|
let cpuCount = ProcessInfo.processInfo.activeProcessorCount
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "config concurrency=\(workerCount) cpuCores=\(cpuCount)")
|
|
|
|
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
guard let self else { return }
|
|
defer { self.finishMetadataParseCancellationController(cancellationController) }
|
|
guard context.controller != nil,
|
|
!cancellationController.isCancelled,
|
|
context.paginationToken == token else { return }
|
|
|
|
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
|
|
)
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil,
|
|
!cancellationController.isCancelled else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
|
|
}
|
|
return
|
|
}
|
|
|
|
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
|
var contentHashBySpineIndex: [Int: String] = [:]
|
|
for spineIndex in allBuildableIndices {
|
|
guard !cancellationController.isCancelled,
|
|
context.paginationToken == token,
|
|
context.controller != nil else {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "abort during content hash prewarm")
|
|
return
|
|
}
|
|
guard let href = publication.spine.indices.contains(spineIndex)
|
|
? publication.spine[spineIndex].href : nil,
|
|
let html = parser.htmlString(forRelativePath: href) else {
|
|
contentHashBySpineIndex[spineIndex] = ""
|
|
continue
|
|
}
|
|
contentHashBySpineIndex[spineIndex] = html.sha256Hex
|
|
}
|
|
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
|
|
|
|
let catalog = allBuildableIndices.map { spineIndex in
|
|
let item = publication.spine[spineIndex]
|
|
return (
|
|
key: context.chapterCacheKey(
|
|
forSpineIndex: spineIndex,
|
|
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
|
|
renderSignature: renderSignature
|
|
),
|
|
spineIndex: spineIndex,
|
|
href: item.href,
|
|
title: item.title
|
|
)
|
|
}
|
|
let restored = summaryDiskCache?.readAll(keys: catalog)
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"begin token=\(token.uuidString) buildableChapters=\(allBuildableIndices.count) concurrency=\(workerCount)"
|
|
)
|
|
let cachedSummaries = restored?.summaries ?? [:]
|
|
let cachedSpineIndices = Set(cachedSummaries.keys)
|
|
let resultLock = NSLock()
|
|
let parseState = MetadataParseState(
|
|
summariesBySpineIndex: cachedSummaries,
|
|
totalResolvedCount: cachedSpineIndices.count,
|
|
lastAppliedCount: cachedSpineIndices.count
|
|
)
|
|
|
|
if !cachedSpineIndices.isEmpty {
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"resumeFromCache cachedChapters=\(cachedSpineIndices.count) total=\(allBuildableIndices.count)"
|
|
)
|
|
let cachedMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(cachedMap)
|
|
}
|
|
}
|
|
|
|
let prioritizedSpineIndices: [Int]
|
|
if let priorityManager = context.runtime?.backgroundPriorityManager {
|
|
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
|
.flatMap { context.normalizedSpineIndex(for: $0) }
|
|
prioritizedSpineIndices = priorityManager.makeMetadataPriorityOrder(
|
|
allBuildableIndices: allBuildableIndices,
|
|
currentSpineIndex: currentSpineIndex,
|
|
cachedSpineIndices: cachedSpineIndices
|
|
)
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"prioritized hot=\(prioritizedSpineIndices.prefix(10).count) total=\(prioritizedSpineIndices.count)"
|
|
)
|
|
} else {
|
|
prioritizedSpineIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
|
}
|
|
|
|
let uncachedSpineIndices = prioritizedSpineIndices
|
|
|
|
self.waitForReadingInteractionToSettle(
|
|
using: context,
|
|
cancellationController: cancellationController
|
|
)
|
|
guard !cancellationController.isCancelled,
|
|
context.controller != nil,
|
|
context.paginationToken == token else {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "abort before queue start")
|
|
return
|
|
}
|
|
|
|
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
|
var totalRenderMs: Double = 0
|
|
var totalWriteMs: Double = 0
|
|
var totalMergeMs: Double = 0
|
|
var completedChapters = 0
|
|
var failedChapters = 0
|
|
let timingLock = NSLock()
|
|
|
|
let queue = OperationQueue()
|
|
queue.name = "com.rdreader.metadata.parse"
|
|
queue.qualityOfService = .utility
|
|
queue.maxConcurrentOperationCount = workerCount
|
|
cancellationController.attach(queue: queue)
|
|
|
|
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
|
|
|
|
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
|
let operation = BlockOperation()
|
|
operation.addExecutionBlock { [weak operation] in
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return
|
|
}
|
|
do {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "正在解析 spine=\(spineIndex) \(offset + 1)/\(uncachedSpineIndices.count)")
|
|
|
|
let renderResult: RDEPUBChapterSummary? = try autoreleasepool { () -> RDEPUBChapterSummary? in
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return nil
|
|
}
|
|
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
|
let renderStart = CFAbsoluteTimeGetCurrent()
|
|
guard let result = try chapterBuilder.buildChapter(
|
|
parser: parser,
|
|
publication: publication,
|
|
spineIndex: spineIndex,
|
|
pageSize: pageSize,
|
|
style: style
|
|
) else {
|
|
return nil
|
|
}
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "drop rendered chapter due to cancellation spine=\(spineIndex)")
|
|
return nil
|
|
}
|
|
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
|
|
|
let chapter = result.chapter
|
|
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
|
|
let cacheKey = context.chapterCacheKey(
|
|
forSpineIndex: spineIndex,
|
|
precomputedContentHash: precomputedHash,
|
|
renderSignature: renderSignature
|
|
)
|
|
let summary = RDEPUBChapterSummary(
|
|
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
|
pageCount: chapter.pages.count,
|
|
fragmentOffsets: chapter.fragmentOffsets,
|
|
cfiMap: chapter.cfiMap,
|
|
renderSignature: cacheKey.renderSignature,
|
|
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
|
chapterContentHash: cacheKey.chapterContentHash,
|
|
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
|
)
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "skip disk write due to cancellation spine=\(spineIndex)")
|
|
return nil
|
|
}
|
|
let writeStart = CFAbsoluteTimeGetCurrent()
|
|
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
|
let writeElapsed = (CFAbsoluteTimeGetCurrent() - writeStart) * 1000
|
|
|
|
timingLock.lock()
|
|
totalRenderMs += renderElapsed
|
|
totalWriteMs += writeElapsed
|
|
completedChapters += 1
|
|
timingLock.unlock()
|
|
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"spine=\(spineIndex) renderMs=\(Int(renderElapsed)) writeMs=\(Int(writeElapsed))"
|
|
)
|
|
return summary
|
|
}
|
|
|
|
guard let renderResult else { return }
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled,
|
|
operation?.isCancelled != true else {
|
|
return
|
|
}
|
|
|
|
var snapshot: [Int: RDEPUBChapterSummary]?
|
|
resultLock.lock()
|
|
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
|
parseState.totalResolvedCount += 1
|
|
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
|
|| parseState.totalResolvedCount == allBuildableIndices.count {
|
|
parseState.lastAppliedCount = parseState.totalResolvedCount
|
|
snapshot = parseState.summariesBySpineIndex
|
|
}
|
|
resultLock.unlock()
|
|
|
|
if let snapshot {
|
|
let mergeStart = CFAbsoluteTimeGetCurrent()
|
|
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
|
|
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
|
timingLock.lock()
|
|
totalMergeMs += mergeElapsed
|
|
timingLock.unlock()
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil,
|
|
!cancellationController.isCancelled else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
|
}
|
|
}
|
|
} catch {
|
|
guard !cancellationController.isCancelled,
|
|
context.paginationToken == token,
|
|
context.controller != nil,
|
|
operation?.isCancelled != true else {
|
|
return
|
|
}
|
|
timingLock.lock()
|
|
failedChapters += 1
|
|
timingLock.unlock()
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
|
|
|
self.scheduleRetry(
|
|
spineIndex: spineIndex,
|
|
retryCount: 0,
|
|
token: token,
|
|
context: context,
|
|
parser: parser,
|
|
publication: publication,
|
|
pageSize: pageSize,
|
|
layoutConfig: layoutConfig,
|
|
style: style,
|
|
renderSignature: renderSignature,
|
|
summaryDiskCache: summaryDiskCache,
|
|
contentHashBySpineIndex: contentHashBySpineIndex,
|
|
resultLock: resultLock,
|
|
parseState: parseState,
|
|
allBuildableIndices: allBuildableIndices,
|
|
catalog: catalog,
|
|
refreshInterval: refreshInterval,
|
|
cancellationController: cancellationController
|
|
)
|
|
}
|
|
}
|
|
queue.addOperation(operation)
|
|
}
|
|
queue.waitUntilAllOperationsAreFinished()
|
|
if !cancellationController.isCancelled,
|
|
context.paginationToken == token,
|
|
context.controller != nil {
|
|
summaryDiskCache?.flushPendingWrites()
|
|
}
|
|
|
|
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
|
|
timingLock.lock()
|
|
let renderTotal = Int(totalRenderMs)
|
|
let writeTotal = Int(totalWriteMs)
|
|
let mergeTotal = Int(totalMergeMs)
|
|
let rendered = completedChapters
|
|
let failed = failedChapters
|
|
timingLock.unlock()
|
|
let avgRenderMs = rendered > 0 ? renderTotal / rendered : 0
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
|
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
|
|
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
|
)
|
|
context.lastMetadataParseWallClockMs = wallClockMs
|
|
context.lastMetadataParseConcurrency = workerCount
|
|
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled else {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "abort: reader dismissed or token changed")
|
|
return
|
|
}
|
|
|
|
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
|
let pageMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
|
|
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
|
)
|
|
|
|
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
|
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
|
let lowerSpine = resolvedSpineIndices.min() ?? 0
|
|
let upperSpine = resolvedSpineIndices.max() ?? 0
|
|
let estimatedBytes = 256 + pageMap.entries.count * 96 + resolvedSpineIndices.count * 16
|
|
|
|
let segment = RDEPUBBackgroundCoverageSegment(
|
|
lowerSpineIndex: lowerSpine,
|
|
upperSpineIndex: upperSpine,
|
|
pageMap: pageMap,
|
|
resolvedSpineIndices: resolvedSpineIndices,
|
|
generatedAt: CFAbsoluteTimeGetCurrent(),
|
|
renderSignature: renderSignature,
|
|
estimatedMemoryBytes: estimatedBytes
|
|
)
|
|
coverageStore.addSegment(segment)
|
|
}
|
|
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(pageMap)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func scheduleRetry(
|
|
spineIndex: Int,
|
|
retryCount: Int,
|
|
token: UUID,
|
|
context: RDEPUBReaderContext,
|
|
parser: RDEPUBParser,
|
|
publication: RDEPUBPublication,
|
|
pageSize: CGSize,
|
|
layoutConfig: RDEPUBTextLayoutConfig,
|
|
style: RDEPUBTextRenderStyle,
|
|
renderSignature: String,
|
|
summaryDiskCache: RDEPUBChapterSummaryDiskCache?,
|
|
contentHashBySpineIndex: [Int: String],
|
|
resultLock: NSLock,
|
|
parseState: MetadataParseState,
|
|
allBuildableIndices: [Int],
|
|
catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
|
|
refreshInterval: Int,
|
|
cancellationController: MetadataParseCancellationController
|
|
) {
|
|
guard retryCount < Self.maxRetryCount else {
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"spine=\(spineIndex) max retries reached, marking as deferredFailure"
|
|
)
|
|
return
|
|
}
|
|
|
|
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"scheduling retry for spine=\(spineIndex) attempt=\(retryCount + 1) delay=\(delay)s"
|
|
)
|
|
|
|
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
|
|
guard let self else { return }
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled else {
|
|
return
|
|
}
|
|
|
|
do {
|
|
let chapterBuilder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
|
guard let result = try chapterBuilder.buildChapter(
|
|
parser: parser,
|
|
publication: publication,
|
|
spineIndex: spineIndex,
|
|
pageSize: pageSize,
|
|
style: style
|
|
) else {
|
|
return
|
|
}
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled else {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "drop retry result due to cancellation spine=\(spineIndex)")
|
|
return
|
|
}
|
|
|
|
let chapter = result.chapter
|
|
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
|
|
let cacheKey = context.chapterCacheKey(
|
|
forSpineIndex: spineIndex,
|
|
precomputedContentHash: precomputedHash,
|
|
renderSignature: renderSignature
|
|
)
|
|
let summary = RDEPUBChapterSummary(
|
|
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
|
pageCount: chapter.pages.count,
|
|
fragmentOffsets: chapter.fragmentOffsets,
|
|
cfiMap: chapter.cfiMap,
|
|
renderSignature: cacheKey.renderSignature,
|
|
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
|
chapterContentHash: cacheKey.chapterContentHash,
|
|
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
|
)
|
|
guard context.controller != nil,
|
|
context.paginationToken == token,
|
|
!cancellationController.isCancelled else {
|
|
RDEPUBBackgroundTrace.log("MetadataParse", "skip retry disk write due to cancellation spine=\(spineIndex)")
|
|
return
|
|
}
|
|
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
|
|
|
resultLock.lock()
|
|
parseState.summariesBySpineIndex[spineIndex] = summary
|
|
parseState.totalResolvedCount += 1
|
|
let shouldRefresh =
|
|
parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|
|
|| parseState.totalResolvedCount == allBuildableIndices.count
|
|
if shouldRefresh {
|
|
parseState.lastAppliedCount = parseState.totalResolvedCount
|
|
}
|
|
resultLock.unlock()
|
|
|
|
if shouldRefresh {
|
|
let partialMap = self.buildPageMap(from: catalog, summaries: parseState.summariesBySpineIndex)
|
|
DispatchQueue.main.async {
|
|
guard context.paginationToken == token,
|
|
context.controller != nil,
|
|
!cancellationController.isCancelled else { return }
|
|
context.runtime?.refreshBookPageMapInPlace(partialMap)
|
|
}
|
|
}
|
|
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"retry succeeded for spine=\(spineIndex) attempt=\(retryCount + 1)"
|
|
)
|
|
} catch {
|
|
RDEPUBBackgroundTrace.log(
|
|
"MetadataParse",
|
|
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
|
|
)
|
|
|
|
self.scheduleRetry(
|
|
spineIndex: spineIndex,
|
|
retryCount: retryCount + 1,
|
|
token: token,
|
|
context: context,
|
|
parser: parser,
|
|
publication: publication,
|
|
pageSize: pageSize,
|
|
layoutConfig: layoutConfig,
|
|
style: style,
|
|
renderSignature: renderSignature,
|
|
summaryDiskCache: summaryDiskCache,
|
|
contentHashBySpineIndex: contentHashBySpineIndex,
|
|
resultLock: resultLock,
|
|
parseState: parseState,
|
|
allBuildableIndices: allBuildableIndices,
|
|
catalog: catalog,
|
|
refreshInterval: refreshInterval,
|
|
cancellationController: cancellationController
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
func cancelActiveMetadataParseWork() {
|
|
metadataParseControlLock.lock()
|
|
let controller = activeMetadataParseCancellationController
|
|
activeMetadataParseCancellationController = nil
|
|
metadataParseControlLock.unlock()
|
|
controller?.cancel()
|
|
}
|
|
|
|
private func beginMetadataParseCancellationController(for token: UUID) -> MetadataParseCancellationController {
|
|
let controller = MetadataParseCancellationController(token: token)
|
|
metadataParseControlLock.lock()
|
|
let previous = activeMetadataParseCancellationController
|
|
activeMetadataParseCancellationController = controller
|
|
metadataParseControlLock.unlock()
|
|
previous?.cancel()
|
|
return controller
|
|
}
|
|
|
|
private func finishMetadataParseCancellationController(_ controller: MetadataParseCancellationController) {
|
|
metadataParseControlLock.lock()
|
|
if activeMetadataParseCancellationController === controller {
|
|
activeMetadataParseCancellationController = nil
|
|
}
|
|
metadataParseControlLock.unlock()
|
|
}
|
|
|
|
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
|
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
|
|
let parser = context.parser else {
|
|
return nil
|
|
}
|
|
let renderSignature = context.currentRenderSignature()
|
|
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
|
let item = publication.spine[spineIndex]
|
|
let href = item.href
|
|
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
|
return (
|
|
key: context.chapterCacheKey(
|
|
forSpineIndex: spineIndex,
|
|
precomputedContentHash: contentHash,
|
|
renderSignature: renderSignature
|
|
),
|
|
spineIndex: spineIndex,
|
|
href: href,
|
|
title: item.title
|
|
)
|
|
}
|
|
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {
|
|
return nil
|
|
}
|
|
let restored = summaryDiskCache.readAll(keys: catalog)
|
|
guard restored.summaries.count == catalog.count else {
|
|
return nil
|
|
}
|
|
return restored.mapBuilder.build()
|
|
}
|
|
|
|
private func buildPageMap(
|
|
from catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)],
|
|
summaries: [Int: RDEPUBChapterSummary]
|
|
) -> RDEPUBBookPageMap {
|
|
var builder = RDEPUBBookPageMap.Builder()
|
|
for item in catalog {
|
|
guard let summary = summaries[item.spineIndex] else { continue }
|
|
builder.add(
|
|
spineIndex: item.spineIndex,
|
|
href: item.href,
|
|
title: item.title,
|
|
pageCount: summary.pageCount,
|
|
fragmentOffsets: summary.fragmentOffsets
|
|
)
|
|
}
|
|
return builder.build()
|
|
}
|
|
}
|