优化 EPUB 翻页与后台分页刷新

This commit is contained in:
shen 2026-07-04 19:21:46 +08:00 committed by shenlei
parent 71f4feb12f
commit 9e91207011
7 changed files with 274 additions and 32 deletions

View File

@ -137,7 +137,7 @@ public final class RDEPUBTextBookCache {
let fileURL = cacheDirectory.appendingPathComponent(key) let fileURL = cacheDirectory.appendingPathComponent(key)
guard FileManager.default.fileExists(atPath: fileURL.path) else { guard FileManager.default.fileExists(atPath: fileURL.path) else {
#if DEBUG #if DEBUG
print("[Cache] load MISS key=\(key)") // print("[Cache] load MISS key=\(key)")
#endif #endif
return nil return nil
} }
@ -148,7 +148,7 @@ public final class RDEPUBTextBookCache {
from: data from: data
) else { ) else {
#if DEBUG #if DEBUG
print("[Cache] load MISS key=\(key) (unarchive returned nil)") // print("[Cache] load MISS key=\(key) (unarchive returned nil)")
#endif #endif
return nil return nil
} }
@ -157,12 +157,12 @@ public final class RDEPUBTextBookCache {
result[chapter.href] = chapter.toCache() result[chapter.href] = chapter.toCache()
} }
#if DEBUG #if DEBUG
print("[Cache] load HIT key=\(key) chapters=\(result.count)") // print("[Cache] load HIT key=\(key) chapters=\(result.count)")
#endif #endif
return result return result
} catch { } catch {
#if DEBUG #if DEBUG
print("[Cache] load MISS key=\(key) error=\(error)") // print("[Cache] load MISS key=\(key) error=\(error)")
#endif #endif
return nil return nil
} }

View File

@ -56,6 +56,8 @@ final class RDEPUBMetadataParseWorker {
private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)] private let catalog: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]
private let progressLogStride = 4
init( init(
context: RDEPUBReaderContext, context: RDEPUBReaderContext,
cancellationController: RDEPUBMetadataParseCancellationController, cancellationController: RDEPUBMetadataParseCancellationController,
@ -116,19 +118,38 @@ final class RDEPUBMetadataParseWorker {
func start(token: UUID, restoreLocation: RDEPUBLocation?) { func start(token: UUID, restoreLocation: RDEPUBLocation?) {
let cancellationController = self.cancellationController let cancellationController = self.cancellationController
DispatchQueue.global(qos: .utility).async { [weak self] in DispatchQueue.global(qos: .utility).async { [self] in
guard let self else { return } RDEPUBBackgroundTrace.log(
"Metadata",
"workerDispatched token=\(token.uuidString)"
)
let context = self.context let context = self.context
guard let context, guard let context,
context.controller != nil, context.controller != nil,
!cancellationController.isCancelled, !cancellationController.isCancelled,
context.paginationToken == token else { return } context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedBeforeStart reason=contextUnavailableOrTokenMismatch"
)
return
}
defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) } defer { context.runtime?.paginationCoordinator.finishMetadataParseCancellationController(cancellationController) }
guard context.controller != nil, guard context.controller != nil,
!cancellationController.isCancelled, !cancellationController.isCancelled,
context.paginationToken == token else { return } context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedAfterStart reason=contextUnavailableOrTokenMismatch"
)
return
}
if let restoredPageMap = self.restoreBookPageMapIfPossible() { if let restoredPageMap = self.restoreBookPageMapIfPossible() {
RDEPUBBackgroundTrace.log(
"Metadata",
"restoreBookPageMapIfPossible hit totalChapters=\(restoredPageMap.totalChapters) totalPages=\(restoredPageMap.totalPages)"
)
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil, context.controller != nil,
@ -175,13 +196,26 @@ final class RDEPUBMetadataParseWorker {
let uncachedSpineIndices = prioritizedSpineIndices let uncachedSpineIndices = prioritizedSpineIndices
RDEPUBBackgroundTrace.log(
"Metadata",
"waitingForInteractionCooldown elapsedSinceNavigation=\(String(format: "%.2f", context.secondsSinceLastUserNavigation())) uncached=\(uncachedSpineIndices.count)"
)
self.waitForReadingInteractionToSettle(cancellationController: cancellationController) self.waitForReadingInteractionToSettle(cancellationController: cancellationController)
guard !cancellationController.isCancelled, guard !cancellationController.isCancelled,
context.controller != nil, context.controller != nil,
context.paginationToken == token else { context.paginationToken == token else {
RDEPUBBackgroundTrace.log(
"Metadata",
"workerAbortedDuringCooldown"
)
return return
} }
RDEPUBBackgroundTrace.log(
"Metadata",
"start totalBuildable=\(self.allBuildableIndices.count) cached=\(cachedSpineIndices.count) uncached=\(uncachedSpineIndices.count) concurrency=\(self.workerCount) refreshInterval=\(self.pageMapRefreshInterval)"
)
let wallClockStart = CFAbsoluteTimeGetCurrent() let wallClockStart = CFAbsoluteTimeGetCurrent()
var totalRenderMs: Double = 0 var totalRenderMs: Double = 0
var totalWriteMs: Double = 0 var totalWriteMs: Double = 0
@ -283,6 +317,10 @@ final class RDEPUBMetadataParseWorker {
resultLock.lock() resultLock.lock()
parseState.summariesBySpineIndex[spineIndex] = renderResult parseState.summariesBySpineIndex[spineIndex] = renderResult
parseState.totalResolvedCount += 1 parseState.totalResolvedCount += 1
let resolvedCount = parseState.totalResolvedCount
let shouldLogProgress = resolvedCount == self.allBuildableIndices.count
|| resolvedCount == cachedSpineIndices.count + 1
|| resolvedCount % self.progressLogStride == 0
if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval if parseState.totalResolvedCount - parseState.lastAppliedCount >= refreshInterval
|| parseState.totalResolvedCount == self.allBuildableIndices.count { || parseState.totalResolvedCount == self.allBuildableIndices.count {
parseState.lastAppliedCount = parseState.totalResolvedCount parseState.lastAppliedCount = parseState.totalResolvedCount
@ -290,6 +328,17 @@ final class RDEPUBMetadataParseWorker {
} }
resultLock.unlock() resultLock.unlock()
if shouldLogProgress {
let progressPercent = Self.progressPercent(
resolved: resolvedCount,
total: self.allBuildableIndices.count
)
RDEPUBBackgroundTrace.log(
"Metadata",
"chapterReady spine=\(spineIndex) resolved=\(resolvedCount)/\(self.allBuildableIndices.count) progress=\(progressPercent)% pageCount=\(renderResult.pageCount)"
)
}
if let snapshot { if let snapshot {
let mergeStart = CFAbsoluteTimeGetCurrent() let mergeStart = CFAbsoluteTimeGetCurrent()
let partialMap = self.buildPageMap(summaries: snapshot) let partialMap = self.buildPageMap(summaries: snapshot)
@ -297,6 +346,10 @@ final class RDEPUBMetadataParseWorker {
timingLock.lock() timingLock.lock()
totalMergeMs += mergeElapsed totalMergeMs += mergeElapsed
timingLock.unlock() timingLock.unlock()
RDEPUBBackgroundTrace.log(
"Metadata",
"partialMap resolvedChapters=\(snapshot.count) totalPages=\(partialMap.totalPages) mergeMs=\(Int(mergeElapsed))"
)
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil, context.controller != nil,
@ -314,6 +367,10 @@ final class RDEPUBMetadataParseWorker {
timingLock.lock() timingLock.lock()
failedChapters += 1 failedChapters += 1
timingLock.unlock() timingLock.unlock()
RDEPUBBackgroundTrace.log(
"Metadata",
"chapterFailed spine=\(spineIndex) retryScheduled=true error=\(String(describing: error))"
)
self.scheduleRetry( self.scheduleRetry(
spineIndex: spineIndex, spineIndex: spineIndex,
@ -354,6 +411,10 @@ final class RDEPUBMetadataParseWorker {
let finalMergeStart = CFAbsoluteTimeGetCurrent() let finalMergeStart = CFAbsoluteTimeGetCurrent()
let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) let pageMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000) let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
RDEPUBBackgroundTrace.log(
"Metadata",
"finish resolved=\(parseState.summariesBySpineIndex.count)/\(self.allBuildableIndices.count) totalPages=\(pageMap.totalPages) elapsedMs=\(wallClockMs) renderMs=\(renderTotal) writeMs=\(writeTotal) mergeMs=\(mergeTotal + finalMergeMs) failed=\(failed)"
)
if let coverageStore = context.runtime?.backgroundCoverageStore { if let coverageStore = context.runtime?.backgroundCoverageStore {
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys) let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
@ -407,10 +468,18 @@ final class RDEPUBMetadataParseWorker {
cancellationController: RDEPUBMetadataParseCancellationController cancellationController: RDEPUBMetadataParseCancellationController
) { ) {
guard retryCount < Self.maxRetryCount else { guard retryCount < Self.maxRetryCount else {
RDEPUBBackgroundTrace.log(
"Metadata",
"retryAborted spine=\(spineIndex) retryCount=\(retryCount)"
)
return return
} }
let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)] let delay = Self.retryDelays[min(retryCount, Self.retryDelays.count - 1)]
RDEPUBBackgroundTrace.log(
"Metadata",
"retryScheduled spine=\(spineIndex) retryCount=\(retryCount + 1) delayMs=\(Int(delay * 1000))"
)
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + delay) { [weak self] in
guard let self, let context = self.context else { return } guard let self, let context = self.context else { return }
@ -474,6 +543,10 @@ final class RDEPUBMetadataParseWorker {
if shouldRefresh { if shouldRefresh {
let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex) let partialMap = self.buildPageMap(summaries: parseState.summariesBySpineIndex)
RDEPUBBackgroundTrace.log(
"Metadata",
"retryPartialMap resolvedChapters=\(parseState.summariesBySpineIndex.count) totalPages=\(partialMap.totalPages)"
)
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == self.token, guard context.paginationToken == self.token,
context.controller != nil, context.controller != nil,
@ -495,6 +568,11 @@ final class RDEPUBMetadataParseWorker {
} }
} }
private static func progressPercent(resolved: Int, total: Int) -> Int {
guard total > 0 else { return 0 }
return Int((Double(resolved) / Double(total) * 100.0).rounded())
}
private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? { private func restoreBookPageMapIfPossible() -> RDEPUBBookPageMap? {
guard let summaryDiskCache else { return nil } guard let summaryDiskCache else { return nil }
guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else { guard summaryDiskCache.isCacheComplete(keys: catalog.map(\.key)) else {

View File

@ -21,6 +21,8 @@ final class RDEPUBPresentationRuntime {
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
private var pendingCommitRetryWorkItem: DispatchWorkItem?
init( init(
context: RDEPUBReaderContext, context: RDEPUBReaderContext,
locationCoordinator: RDEPUBReaderLocationCoordinator, locationCoordinator: RDEPUBReaderLocationCoordinator,
@ -62,11 +64,22 @@ final class RDEPUBPresentationRuntime {
guard let readerView = context.readerView, guard let readerView = context.readerView,
let controller = context.controller else { return } let controller = context.controller else { return }
guard !controller.isRepaginating else { return } guard !context.pendingPageMapUpdates.isEmpty else {
guard !readerView.isPageCurlTransitioning else { cancelPendingCommitRetry()
return return
} }
guard !controller.isRepaginating else {
schedulePendingCommitRetry()
return
}
guard !readerView.isPageCurlTransitioning else {
schedulePendingCommitRetry()
return
}
cancelPendingCommitRetry()
let rankedUpdates = rankedPendingPageMapUpdates() let rankedUpdates = rankedPendingPageMapUpdates()
for (index, update) in rankedUpdates { for (index, update) in rankedUpdates {
if commitPendingPageMapUpdate( if commitPendingPageMapUpdate(
@ -75,9 +88,16 @@ final class RDEPUBPresentationRuntime {
readerView: readerView, readerView: readerView,
controller: controller controller: controller
) { ) {
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
return return
} }
} }
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
} }
func queueExtendedPartialPageMap( func queueExtendedPartialPageMap(
@ -139,19 +159,40 @@ final class RDEPUBPresentationRuntime {
controller: RDEPUBReaderController controller: RDEPUBReaderController
) { ) {
let currentLocation = locationCoordinator.currentVisibleLocation() let currentLocation = locationCoordinator.currentVisibleLocation()
let livePageBeforeApply = readerView.currentPage + 1
context.textBook = nil context.textBook = nil
applyPageMapToLiveModel(newPageMap) applyPageMapToLiveModel(newPageMap)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
)
if let currentLocation { if let currentLocation {
if rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) == false { let resolvedTargetPage = controller.pageNumber(for: currentLocation)
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1) let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
let newPage = max(0, newPageNumber - 1) resolvedTargetPage,
rebindVisiblePage( livePageBeforeApply: livePageBeforeApply
to: newPage, )
readerView: readerView RDEPUBBackgroundTrace.log(
) "Reconciliation",
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) resolvedTargetPage=\(resolvedTargetPage ?? -1) trustResolved=\(shouldTrustResolvedLocation) href=\(currentLocation.href)"
)
if shouldTrustResolvedLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return
} }
let fallbackPage = max(livePageBeforeApply, 1)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement preserveLivePage fallbackPage=\(fallbackPage) currentReaderPage=\(readerView.currentPage + 1)"
)
rebindVisiblePage(
to: fallbackPage - 1,
readerView: readerView
)
} else { } else {
readerView.reloadPageCountOnly() readerView.reloadPageCountOnly()
} }
@ -223,17 +264,23 @@ final class RDEPUBPresentationRuntime {
return false return false
} }
case .extendPartial(_, let currentLocation): case .extendPartial(let capturedPageNumber, let currentLocation):
removePendingPageMapUpdate(at: index) removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap) applyPageMapToLiveModel(update.pageMap)
if let currentLocation, let livePageNumber = max(readerView.currentPage, 0) + 1
let shouldTrustCapturedLocation = livePageNumber == max(capturedPageNumber, 1)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"extendPartial commit capturedPage=\(capturedPageNumber) livePage=\(livePageNumber) trustCaptured=\(shouldTrustCapturedLocation) totalPages=\(update.pageMap.totalPages) totalChapters=\(update.pageMap.totalChapters)"
)
if shouldTrustCapturedLocation,
let currentLocation,
rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) { rebindVisibleLocation(currentLocation, readerView: readerView, controller: controller) {
return true return true
} }
// Fallback: prefer the live readerView.currentPage over the stale captured // Prefer the live readerView page when the user has moved since the
// currentPageNumber, which may be outdated by the time this commit runs // extension request was created; otherwise a stale captured location can
// (especially in pageCurl mode where the user may have turned several pages // snap pageCurl back to the previous page after the turn completes.
// since the extension was initiated).
let livePageIndex = max(readerView.currentPage, 0) let livePageIndex = max(readerView.currentPage, 0)
rebindVisiblePage( rebindVisiblePage(
to: livePageIndex, to: livePageIndex,
@ -250,6 +297,19 @@ final class RDEPUBPresentationRuntime {
} }
private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) { private func rebindVisiblePage(to pageIndex: Int, readerView: RDReaderView) {
if pageIndex == readerView.currentPage {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage pageUnchanged currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType)"
)
readerView.reloadPageCountOnly()
return
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage targetPage=\(pageIndex + 1) currentPage=\(readerView.currentPage + 1) displayType=\(readerView.currentDisplayType) isPageCurlTransitioning=\(readerView.isPageCurlTransitioning)"
)
if readerView.currentDisplayType == .pageCurl { if readerView.currentDisplayType == .pageCurl {
if readerView.isPageCurlTransitioning { if readerView.isPageCurlTransitioning {
// Defer the transition until the current page-curl animation completes, // Defer the transition until the current page-curl animation completes,
@ -257,6 +317,10 @@ final class RDEPUBPresentationRuntime {
DispatchQueue.main.async { [weak readerView] in DispatchQueue.main.async { [weak readerView] in
guard let readerView, !readerView.isPageCurlTransitioning else { return } guard let readerView, !readerView.isPageCurlTransitioning else { return }
let livePageIndex = max(readerView.currentPage, 0) let livePageIndex = max(readerView.currentPage, 0)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
)
readerView.transitionToPage(pageNum: livePageIndex, animated: false) readerView.transitionToPage(pageNum: livePageIndex, animated: false)
} }
} else { } else {
@ -276,6 +340,10 @@ final class RDEPUBPresentationRuntime {
controller: RDEPUBReaderController controller: RDEPUBReaderController
) -> Bool { ) -> Bool {
guard let targetPageNumber = controller.pageNumber(for: location) else { guard let targetPageNumber = controller.pageNumber(for: location) else {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
)
return false return false
} }
@ -284,9 +352,18 @@ final class RDEPUBPresentationRuntime {
forAbsolutePageNumber: targetPageNumber, forAbsolutePageNumber: targetPageNumber,
allowSynchronousLoad: false allowSynchronousLoad: false
) == false { ) == false {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
)
return false return false
} }
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
)
rebindVisiblePage( rebindVisiblePage(
to: max(targetPageNumber - 1, 0), to: max(targetPageNumber - 1, 0),
readerView: readerView readerView: readerView
@ -353,4 +430,29 @@ final class RDEPUBPresentationRuntime {
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages && candidate.pageMap.totalPages >= existing.pageMap.totalPages
) )
} }
private func shouldTrustFullReplaceResolvedPage(
_ resolvedTargetPage: Int?,
livePageBeforeApply: Int
) -> Bool {
guard let resolvedTargetPage else { return false }
return abs(resolvedTargetPage - livePageBeforeApply) <= 1
}
private func schedulePendingCommitRetry() {
guard pendingCommitRetryWorkItem == nil else { return }
let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
self.pendingCommitRetryWorkItem = nil
self.commitPendingPageMapUpdateIfNeeded()
}
pendingCommitRetryWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem)
}
private func cancelPendingCommitRetry() {
pendingCommitRetryWorkItem?.cancel()
pendingCommitRetryWorkItem = nil
}
} }

View File

@ -1,5 +1,27 @@
import UIKit import UIKit
enum RDEPUBTextPageLayoutMetrics {
static let pageNumberTrailingPadding: CGFloat = 4
static let pageNumberFooterPadding: CGFloat = 8
static let pageNumberReservedHeight: CGFloat = ceil(UIFont.systemFont(ofSize: 13).lineHeight) + pageNumberFooterPadding
static func contentInsets(
configuration: RDEPUBReaderConfiguration,
safeAreaInsets: UIEdgeInsets
) -> UIEdgeInsets {
let configInsets = configuration.reflowableContentInsets
return UIEdgeInsets(
top: max(configInsets.top, safeAreaInsets.top),
left: max(configInsets.left, safeAreaInsets.left),
bottom: max(configInsets.bottom, safeAreaInsets.bottom) + pageNumberReservedHeight,
right: max(configInsets.right, safeAreaInsets.right)
)
}
}
final class RDEPUBReaderEnvironment { final class RDEPUBReaderEnvironment {
weak var controller: RDEPUBReaderController? weak var controller: RDEPUBReaderController?
@ -46,11 +68,14 @@ final class RDEPUBReaderEnvironment {
configuration: RDEPUBReaderConfiguration, configuration: RDEPUBReaderConfiguration,
pageSize: CGSize pageSize: CGSize
) -> RDEPUBTextLayoutConfig { ) -> RDEPUBTextLayoutConfig {
let layoutContext = currentLayoutContext(configuration: configuration) let safeAreaInsets = controller?.view.safeAreaInsets ?? .zero
return RDEPUBTextLayoutConfig( return RDEPUBTextLayoutConfig(
frameWidth: max(pageSize.width, 1), frameWidth: max(pageSize.width, 1),
frameHeight: max(pageSize.height, 1), frameHeight: max(pageSize.height, 1),
edgeInsets: layoutContext.safeReflowableContentInsets, edgeInsets: RDEPUBTextPageLayoutMetrics.contentInsets(
configuration: configuration,
safeAreaInsets: safeAreaInsets
),
numberOfColumns: configuration.numberOfColumns, numberOfColumns: configuration.numberOfColumns,
columnGap: configuration.columnGap, columnGap: configuration.columnGap,
avoidOrphans: false, avoidOrphans: false,

View File

@ -191,12 +191,21 @@ final class RDEPUBReaderPaginationCoordinator {
runtime: runtime, runtime: runtime,
layoutSnapshot: layoutSnapshot layoutSnapshot: layoutSnapshot
) )
RDEPUBBackgroundTrace.log(
"Pagination",
"anchorChapterReady spine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
)
let initialChapters = self.loadInitialInteractiveRuntimeChapters( let initialChapters = self.loadInitialInteractiveRuntimeChapters(
anchorChapter: runtimeChapter, anchorChapter: runtimeChapter,
publication: publication, publication: publication,
runtime: runtime, runtime: runtime,
layoutSnapshot: layoutSnapshot layoutSnapshot: layoutSnapshot
) )
let initialPageCount = initialChapters.reduce(0) { $0 + $1.pages.count }
RDEPUBBackgroundTrace.log(
"Pagination",
"initialInteractiveChapters ready count=\(initialChapters.count) pages=\(initialPageCount) spines=\(initialChapters.map(\.spineIndex))"
)
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
@ -220,9 +229,17 @@ final class RDEPUBReaderPaginationCoordinator {
parser: parser, parser: parser,
publication: publication publication: publication
) )
RDEPUBBackgroundTrace.log(
"Pagination",
"startingMetadataWorker token=\(token.uuidString) anchorSpine=\(runtimeChapter.spineIndex) partialPages=\(partialMap.totalPages) partialChapters=\(partialMap.totalChapters)"
)
worker.start(token: token, restoreLocation: restoreLocation) worker.start(token: token, restoreLocation: restoreLocation)
} }
} catch { } catch {
RDEPUBBackgroundTrace.log(
"Pagination",
"initialPaginationFailed error=\(String(describing: error)) prioritizedCandidates=\(prioritizedCandidates)"
)
DispatchQueue.main.async { DispatchQueue.main.async {
guard context.paginationToken == token, guard context.paginationToken == token,
context.controller != nil else { return } context.controller != nil else { return }

View File

@ -78,8 +78,12 @@ final class RDReaderPreloadController {
environment: Environment, environment: Environment,
contentViewProvider: (Int, UIView?) -> UIView? contentViewProvider: (Int, UIView?) -> UIView?
) -> UIView { ) -> UIView {
let reusableView = detachedReusablePageView(for: pageNum) let view: UIView
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView() if let reusableView = detachedReusablePageView(for: pageNum) {
view = reusableView
} else {
view = contentViewProvider(pageNum, nil) ?? UIView()
}
if shouldCache(view: view, for: pageNum, environment: environment) { if shouldCache(view: view, for: pageNum, environment: environment) {
pageCurlCachedViews[pageNum] = view pageCurlCachedViews[pageNum] = view
} else { } else {
@ -111,8 +115,14 @@ final class RDReaderPreloadController {
preloadHostView.frame = parentView.bounds preloadHostView.frame = parentView.bounds
for targetPage in targets { for targetPage in targets {
let existing = detachedReusablePageView(for: targetPage) let contentView: UIView
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView() if let existing = preloadedPageViews[targetPage], existing.superview === preloadHostView {
contentView = existing
} else if let cached = pageCurlCachedViews.removeValue(forKey: targetPage), cached.superview == nil {
contentView = cached
} else {
contentView = contentViewProvider(targetPage, nil) ?? UIView()
}
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment) let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
if shouldCacheContentView { if shouldCacheContentView {
preloadedPageViews[targetPage] = contentView preloadedPageViews[targetPage] = contentView

View File

@ -763,6 +763,14 @@ public class RDReaderView: UIView {
guard let safePageNum = clampedPageNumber(pageNum) else { return } guard let safePageNum = clampedPageNumber(pageNum) else { return }
switch currentDisplayType { switch currentDisplayType {
case .pageCurl: case .pageCurl:
if !animated, safePageNum == currentPage, !detectPageViewControllerFault(pageViewController) {
RDReaderTapDebug.log(
"ReaderView.transitionToPage",
"skip same page page=\(safePageNum) animated=\(animated)"
)
return
}
let request = PageTransitionRequest(pageNum: safePageNum, animated: animated) let request = PageTransitionRequest(pageNum: safePageNum, animated: animated)
if shouldQueuePageTransition(request) { if shouldQueuePageTransition(request) {
return return
@ -823,6 +831,7 @@ public class RDReaderView: UIView {
} }
public func reloadData() { public func reloadData() {
invalidatePageCaches()
switchReaderDisplayType(currentDisplayType) switchReaderDisplayType(currentDisplayType)
topToolView = resolvedTopChromeView() topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView() bottomToolView = resolvedBottomChromeView()
@ -830,8 +839,9 @@ public class RDReaderView: UIView {
public func reloadPageCountOnly() { public func reloadPageCountOnly() {
if currentDisplayType == .pageCurl { if currentDisplayType == .pageCurl {
if !isPageCurlTransitioning, currentPage >= 0, numberOfPages() > 0 { let totalPages = numberOfPages()
transitionToPage(pageNum: currentPage, animated: false) if !isPageCurlTransitioning, currentPage >= totalPages, totalPages > 0 {
transitionToPage(pageNum: totalPages - 1, animated: false)
} }
} else { } else {
collectionView.reloadData() collectionView.reloadData()