ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift
shenlei 5ae7823ef8 全量换表前用旧页表回环验证当前位置
初始 partial 窗口被全书页表接管时,live 页码(窗口坐标)与新表解析
页码(全书坐标)不可比,±1 启发式误判后 preserveLivePage 会把模型
位置钉在全书第 1 页,导致位置错乱并级联阻塞后续全表提交。改为换表
前先用旧表解析 currentLocation:回环等于 live 页说明位置忠实反映
屏幕内容,换表后无条件信任新表解析结果;不等时才退回 ±1 门槛,保留
对 stale 持久化位置的防护。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 11:56:22 +09:00

472 lines
18 KiB
Swift

import UIKit
enum RDEPUBPendingPageMapUpdateKind {
case reconcileFullMap
case extendPartial(currentPageNumber: Int, currentLocation: RDEPUBLocation?)
case appendForward
}
struct RDEPUBPendingPageMapUpdate {
let pageMap: RDEPUBBookPageMap
let kind: RDEPUBPendingPageMapUpdateKind
}
final class RDEPUBPresentationRuntime {
private unowned let context: RDEPUBReaderContext
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
private var pendingCommitRetryWorkItem: DispatchWorkItem?
init(
context: RDEPUBReaderContext,
locationCoordinator: RDEPUBReaderLocationCoordinator,
jumpSessionManager: RDEPUBJumpSessionManager,
reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
) {
self.context = context
self.locationCoordinator = locationCoordinator
self.jumpSessionManager = jumpSessionManager
self.reconciliationCoordinator = reconciliationCoordinator
}
func applyBookPageMap(
_ bookPageMap: RDEPUBBookPageMap,
restoreLocation: RDEPUBLocation?,
finishPagination: (RDEPUBLocation?) -> Void
) {
context.textBook = nil
context.bookPageMap = bookPageMap
context.pendingPageMapUpdates.removeAll()
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
finishPagination(restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"refreshBookPageMapInPlace: enqueued reconcileFullMap chapters=\(bookPageMap.totalChapters) pages=\(bookPageMap.totalPages)"
)
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
kind: .reconcileFullMap
)
)
}
func commitPendingPageMapUpdateIfNeeded() {
guard let readerView = context.readerView,
let controller = context.controller else { return }
guard !context.pendingPageMapUpdates.isEmpty else {
cancelPendingCommitRetry()
return
}
guard !controller.isRepaginating else {
schedulePendingCommitRetry()
return
}
guard !readerView.isPageCurlTransitioning else {
schedulePendingCommitRetry()
return
}
cancelPendingCommitRetry()
let rankedUpdates = rankedPendingPageMapUpdates()
for (index, update) in rankedUpdates {
if commitPendingPageMapUpdate(
update,
at: index,
readerView: readerView,
controller: controller
) {
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
return
}
}
if !context.pendingPageMapUpdates.isEmpty {
schedulePendingCommitRetry()
}
}
func queueExtendedPartialPageMap(
_ bookPageMap: RDEPUBBookPageMap,
currentPageNumber: Int,
currentLocation: RDEPUBLocation?
) {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
kind: .extendPartial(
currentPageNumber: currentPageNumber,
currentLocation: currentLocation
)
)
)
}
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
}
func queueForwardAppendedPageMap(_ bookPageMap: RDEPUBBookPageMap) {
enqueuePendingPageMapUpdate(
RDEPUBPendingPageMapUpdate(
pageMap: bookPageMap,
kind: .appendForward
)
)
}
func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
let pages = bookPageMap.entries.flatMap { entry in
(0..<entry.pageCount).map { localPageIndex in
EPUBPage(
spineIndex: entry.spineIndex,
chapterIndex: bookPageMap.chapterIndex(forSpineIndex: entry.spineIndex) ?? 0,
pageIndexInChapter: localPageIndex,
totalPagesInChapter: entry.pageCount,
chapterTitle: entry.title,
fixedSpread: nil
)
}
}
let chapters = bookPageMap.entries.map { entry in
EPUBChapterInfo(
spineIndex: entry.spineIndex,
title: entry.title,
pageCount: entry.pageCount
)
}
return (pages, chapters)
}
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDReaderView,
controller: RDEPUBReaderController
) {
let currentLocation = locationCoordinator.currentVisibleLocation()
let livePageBeforeApply = readerView.currentPage + 1
// Resolved against the outgoing page map. When it round-trips to the live
// page, the location faithfully describes what is on screen, so whatever
// page it resolves to in the new map is authoritative even if the two maps
// number pages differently (partial-window -> full-book takeover).
let oldResolvedPage = currentLocation.flatMap { controller.pageNumber(for: $0) }
context.textBook = nil
applyPageMapToLiveModel(newPageMap)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement livePageBeforeApply=\(livePageBeforeApply) totalPages=\(newPageMap.totalPages) totalChapters=\(newPageMap.totalChapters)"
)
if let currentLocation {
let resolvedTargetPage = controller.pageNumber(for: currentLocation)
let shouldTrustResolvedLocation = shouldTrustFullReplaceResolvedPage(
resolvedTargetPage,
livePageBeforeApply: livePageBeforeApply,
locationMatchesLivePage: oldResolvedPage == livePageBeforeApply
)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"applyFullPageMapReplacement decision livePage=\(livePageBeforeApply) oldResolvedPage=\(oldResolvedPage ?? -1) 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 {
readerView.reloadPageCountOnly()
}
if let currentLocation,
context.normalizedSpineIndex(for: currentLocation) != nil,
let activeSession = jumpSessionManager.activeSession {
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
let protectedIndices = activeSession.protectedSpineIndices
if protectedIndices.isSubset(of: candidateIndices) {
jumpSessionManager.endSession(.coverageComplete)
}
}
}
private func enqueuePendingPageMapUpdate(_ update: RDEPUBPendingPageMapUpdate) {
var updates = context.pendingPageMapUpdates
if let existingIndex = updates.firstIndex(where: {
pendingPageMapUpdateKindMatches($0.kind, update.kind)
}) {
let existing = updates[existingIndex]
if shouldReplacePendingPageMapUpdate(existing, with: update) {
updates[existingIndex] = update
}
} else {
updates.append(update)
}
context.pendingPageMapUpdates = updates
commitPendingPageMapUpdateIfNeeded()
}
private func rankedPendingPageMapUpdates() -> [(Int, RDEPUBPendingPageMapUpdate)] {
context.pendingPageMapUpdates.enumerated().sorted { lhs, rhs in
pendingPriority(for: lhs.element.kind) > pendingPriority(for: rhs.element.kind)
}
}
private func commitPendingPageMapUpdate(
_ update: RDEPUBPendingPageMapUpdate,
at index: Int,
readerView: RDReaderView,
controller: RDEPUBReaderController
) -> Bool {
switch update.kind {
case .reconcileFullMap:
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: update.pageMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log(
"Reconciliation",
"commitPendingPageMapUpdate: keepCurrentWindow — removed pending update, totalPages=\(update.pageMap.totalPages)"
)
removePendingPageMapUpdate(at: index)
return false
case .fullReplace(let newPageMap):
removePendingPageMapUpdate(at: index)
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
return true
case .expandWindow, .segmentReplace:
removePendingPageMapUpdate(at: index)
return false
}
case .extendPartial(let capturedPageNumber, let currentLocation):
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap)
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) {
return true
}
// Prefer the live readerView page when the user has moved since the
// extension request was created; otherwise a stale captured location can
// snap pageCurl back to the previous page after the turn completes.
let livePageIndex = max(readerView.currentPage, 0)
rebindVisiblePage(
to: livePageIndex,
readerView: readerView
)
return true
case .appendForward:
removePendingPageMapUpdate(at: index)
applyPageMapToLiveModel(update.pageMap)
readerView.reloadPageCountOnly()
return true
}
}
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.isPageCurlTransitioning {
// Defer the transition until the current page-curl animation completes,
// and re-read the live page at that time to avoid jumping to a stale position.
DispatchQueue.main.async { [weak readerView] in
guard let readerView, !readerView.isPageCurlTransitioning else { return }
let livePageIndex = max(readerView.currentPage, 0)
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisiblePage deferredTransition livePage=\(livePageIndex + 1)"
)
readerView.transitionToPage(pageNum: livePageIndex, animated: false)
}
} else {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
}
} else {
readerView.reloadPageCountOnly()
if pageIndex != readerView.currentPage {
readerView.transitionToPage(pageNum: pageIndex, animated: false)
}
}
}
private func rebindVisibleLocation(
_ location: RDEPUBLocation,
readerView: RDReaderView,
controller: RDEPUBReaderController
) -> Bool {
guard let targetPageNumber = controller.pageNumber(for: location) else {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation failedToResolve locationHref=\(location.href) currentPage=\(readerView.currentPage + 1)"
)
return false
}
if context.bookPageMap != nil,
context.runtime?.prepareOnDemandChapter(
forAbsolutePageNumber: targetPageNumber,
allowSynchronousLoad: false
) == false {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation prepareOnDemandBlocked targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1)"
)
return false
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"rebindVisibleLocation targetPage=\(targetPageNumber) currentPage=\(readerView.currentPage + 1) href=\(location.href)"
)
rebindVisiblePage(
to: max(targetPageNumber - 1, 0),
readerView: readerView
)
return true
}
private func applyPageMapToLiveModel(_ pageMap: RDEPUBBookPageMap) {
context.bookPageMap = pageMap
context.replaceActiveSnapshot(makeSnapshot(from: pageMap))
discardSupersededPendingPageMapUpdates(afterApplying: pageMap)
}
private func removePendingPageMapUpdate(at index: Int) {
var updates = context.pendingPageMapUpdates
guard updates.indices.contains(index) else { return }
updates.remove(at: index)
context.pendingPageMapUpdates = updates
}
private func discardSupersededPendingPageMapUpdates(afterApplying liveMap: RDEPUBBookPageMap) {
let updates = context.pendingPageMapUpdates.filter { update in
update.pageMap.totalChapters > liveMap.totalChapters
|| (
update.pageMap.totalChapters == liveMap.totalChapters
&& update.pageMap.totalPages > liveMap.totalPages
)
}
context.pendingPageMapUpdates = updates
}
private func pendingPriority(for kind: RDEPUBPendingPageMapUpdateKind) -> Int {
switch kind {
case .extendPartial:
return 3
case .appendForward:
return 2
case .reconcileFullMap:
return 1
}
}
private func pendingPageMapUpdateKindMatches(
_ lhs: RDEPUBPendingPageMapUpdateKind,
_ rhs: RDEPUBPendingPageMapUpdateKind
) -> Bool {
switch (lhs, rhs) {
case (.reconcileFullMap, .reconcileFullMap),
(.appendForward, .appendForward),
(.extendPartial, .extendPartial):
return true
default:
return false
}
}
private func shouldReplacePendingPageMapUpdate(
_ existing: RDEPUBPendingPageMapUpdate,
with candidate: RDEPUBPendingPageMapUpdate
) -> Bool {
candidate.pageMap.totalChapters > existing.pageMap.totalChapters
|| (
candidate.pageMap.totalChapters == existing.pageMap.totalChapters
&& candidate.pageMap.totalPages >= existing.pageMap.totalPages
)
}
private func shouldTrustFullReplaceResolvedPage(
_ resolvedTargetPage: Int?,
livePageBeforeApply: Int,
locationMatchesLivePage: Bool
) -> Bool {
guard let resolvedTargetPage else { return false }
if locationMatchesLivePage {
return true
}
// The location did not round-trip to the live page in the outgoing map
// (stale persisted location or mid-transition), so only follow it when it
// stays next to the page the user is actually looking at.
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
}
}