feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖
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、状态机、内存警告、预加载、位置恢复等
This commit is contained in:
+611
@@ -0,0 +1,611 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWarmupOrchestrator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private unowned let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
private unowned let loader: RDEPUBChapterLoader
|
||||
|
||||
private unowned let presentationRuntime: RDEPUBPresentationRuntime
|
||||
|
||||
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
|
||||
|
||||
private unowned let backgroundPriorityManager: RDEPUBBackgroundPriorityManager
|
||||
|
||||
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
|
||||
|
||||
private let refreshVisibleContentPreservingLocation: () -> Void
|
||||
|
||||
private let asyncLoadStateLock = NSLock()
|
||||
|
||||
private var asynchronouslyPreparingSpineIndices: Set<Int> = []
|
||||
|
||||
private var isExtendingPartialBookPageMap = false
|
||||
|
||||
init(
|
||||
context: RDEPUBReaderContext,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
loader: RDEPUBChapterLoader,
|
||||
presentationRuntime: RDEPUBPresentationRuntime,
|
||||
locationCoordinator: RDEPUBReaderLocationCoordinator,
|
||||
backgroundPriorityManager: RDEPUBBackgroundPriorityManager,
|
||||
jumpSessionManager: RDEPUBJumpSessionManager,
|
||||
refreshVisibleContentPreservingLocation: @escaping () -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.store = store
|
||||
self.loader = loader
|
||||
self.presentationRuntime = presentationRuntime
|
||||
self.locationCoordinator = locationCoordinator
|
||||
self.backgroundPriorityManager = backgroundPriorityManager
|
||||
self.jumpSessionManager = jumpSessionManager
|
||||
self.refreshVisibleContentPreservingLocation = refreshVisibleContentPreservingLocation
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func prepareOnDemandChapter(
|
||||
forAbsolutePageNumber pageNumber: Int,
|
||||
allowSynchronousLoad: Bool = true,
|
||||
completion: ((Bool) -> Void)? = nil
|
||||
) -> Bool {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let publication = context.publication else {
|
||||
return false
|
||||
}
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
presentationRuntime.navigationStateMachine.transition(to: .preparingChapter(spineIndex: spineIndex))
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
|
||||
)
|
||||
|
||||
if store.chapterData(for: spineIndex) == nil {
|
||||
guard allowSynchronousLoad else {
|
||||
scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: spineIndex,
|
||||
triggerPageNumber: pageNumber,
|
||||
completion: completion
|
||||
)
|
||||
return false
|
||||
}
|
||||
do {
|
||||
_ = try loader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: store
|
||||
)
|
||||
} catch {
|
||||
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
maybePrefetchUpcomingChapters(aroundAbsolutePageNumber: pageNumber, in: bookPageMap)
|
||||
scheduleAdjacentChapterPrefetches(for: spineIndex, totalSpineCount: publication.spine.count)
|
||||
return true
|
||||
}
|
||||
|
||||
func extendPartialBookPageMapIfNeeded(
|
||||
currentPageNumber: Int,
|
||||
minimumTrailingPages: Int = 2,
|
||||
batchChapterCount: Int = 3
|
||||
) {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = buildableSpineIndices(in: publication)
|
||||
guard currentMap.totalChapters < buildableSpineIndices.count else {
|
||||
return
|
||||
}
|
||||
|
||||
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
|
||||
let isNearStart = currentPageNumber <= minimumTrailingPages
|
||||
|
||||
var spineIndicesToAppend: [Int] = []
|
||||
if isNearEnd {
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
|
||||
} else if isNearStart {
|
||||
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
|
||||
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
|
||||
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
guard !spineIndicesToAppend.isEmpty, beginPartialBookPageMapExtension() else {
|
||||
return
|
||||
}
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")"
|
||||
)
|
||||
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
let loadedChaptersLock = NSLock()
|
||||
var loadedChapters: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
let group = DispatchGroup()
|
||||
|
||||
for spineIndex in spineIndicesToAppend {
|
||||
group.enter()
|
||||
loader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: .prefetch
|
||||
) { result in
|
||||
defer { group.leave() }
|
||||
switch result {
|
||||
case .success(let chapter):
|
||||
loadedChaptersLock.lock()
|
||||
loadedChapters[spineIndex] = chapter
|
||||
loadedChaptersLock.unlock()
|
||||
case .failure(let error):
|
||||
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
group.notify(queue: .main) { [weak self] in
|
||||
guard let self else { return }
|
||||
defer { self.endPartialBookPageMapExtension() }
|
||||
self.applyAsyncPartialBookPageMapExtension(
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation,
|
||||
currentMap: currentMap,
|
||||
loadedChapters: loadedChapters
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
|
||||
guard context.publication != nil else { return }
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
let forwardTargets = store.windowSpineIndices.filter { $0 > anchorSpineIndex }
|
||||
guard !forwardTargets.isEmpty else { return }
|
||||
|
||||
for spineIndex in forwardTargets {
|
||||
if store.chapterData(for: spineIndex) != nil {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
|
||||
store.addPrefetchTarget(spineIndex)
|
||||
RDEPUBBackgroundTrace.log("Runtime", "initial open prefetch forward spine=\(spineIndex)")
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func ensureNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
|
||||
guard context.bookPageMap != nil,
|
||||
let publication = context.publication,
|
||||
let targetSpineIndex = context.normalizedSpineIndex(for: location) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isDistantJump = if let current = currentSpineIndex {
|
||||
abs(current - targetSpineIndex) > context.configuration.jumpSessionPolicy.protectedNeighborRadius * 2
|
||||
} else {
|
||||
false
|
||||
}
|
||||
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if let pendingMap = context.pendingFullPageMap,
|
||||
pendingMap.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
presentationRuntime.applyPendingFullPageMapIfNeeded()
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: targetSpineIndex) else {
|
||||
return false
|
||||
}
|
||||
|
||||
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
|
||||
context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
let chapters = loadPartialWindowChapters(
|
||||
around: anchorPosition,
|
||||
in: buildableIndices,
|
||||
targetSpineIndex: targetSpineIndex,
|
||||
windowSize: normalizedWindowSize
|
||||
)
|
||||
guard !chapters.isEmpty else { return false }
|
||||
|
||||
store.setCurrentChapter(
|
||||
spineIndex: targetSpineIndex,
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = makePartialPageMap(from: chapters)
|
||||
context.bookPageMap = partialMap
|
||||
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: partialMap))
|
||||
context.readerView?.reloadData()
|
||||
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
|
||||
}
|
||||
|
||||
return partialMap.entry(forSpineIndex: targetSpineIndex) != nil
|
||||
}
|
||||
|
||||
func clear() {
|
||||
asyncLoadStateLock.lock()
|
||||
asynchronouslyPreparingSpineIndices.removeAll()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
|
||||
private func applyAsyncPartialBookPageMapExtension(
|
||||
currentPageNumber: Int,
|
||||
currentLocation: RDEPUBLocation?,
|
||||
currentMap: RDEPUBBookPageMap,
|
||||
loadedChapters: [Int: RDEPUBRuntimeChapter]
|
||||
) {
|
||||
let appendedEntries = loadedChapters.keys.sorted().compactMap { spineIndex -> RDEPUBBookPageMapEntry? in
|
||||
guard let chapter = loadedChapters[spineIndex] else { return nil }
|
||||
return RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
}
|
||||
guard !appendedEntries.isEmpty else {
|
||||
return
|
||||
}
|
||||
|
||||
let combinedEntries = (currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
} + appendedEntries).sorted { $0.spineIndex < $1.spineIndex }
|
||||
|
||||
var absolutePageStart = 0
|
||||
let normalizedEntries = combinedEntries.map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalized = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalized
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: normalizedEntries)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"extendPartialBookPageMap applied chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
presentationRuntime.applyExtendedPartialPageMap(
|
||||
newMap,
|
||||
currentPageNumber: currentPageNumber,
|
||||
currentLocation: currentLocation
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleAsynchronousChapterPreparation(
|
||||
spineIndex: Int,
|
||||
triggerPageNumber: Int,
|
||||
completion: ((Bool) -> Void)?
|
||||
) {
|
||||
guard beginAsynchronousChapterPreparation(for: spineIndex) else { return }
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async spine=\(spineIndex) page=\(triggerPageNumber)"
|
||||
)
|
||||
loader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: store,
|
||||
priority: .preview
|
||||
) { [weak self] result in
|
||||
guard let self else { return }
|
||||
self.endAsynchronousChapterPreparation(for: spineIndex)
|
||||
switch result {
|
||||
case .success:
|
||||
self.presentationRuntime.navigationStateMachine.transition(to: .presentingWindow)
|
||||
completion?(true)
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
self.refreshVisibleContentIfNeeded(afterPreparing: spineIndex, triggerPageNumber: triggerPageNumber)
|
||||
case .failure(let error):
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"prepareOnDemandChapter async FAILED: spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
completion?(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleAdjacentChapterPrefetches(for spineIndex: Int, totalSpineCount: Int) {
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
for evictable in store.evictableSpineIndices() {
|
||||
store.evict(spineIndex: evictable)
|
||||
}
|
||||
|
||||
for adjacentSpineIndex in store.windowSpineIndices where adjacentSpineIndex != spineIndex {
|
||||
guard store.chapterData(for: adjacentSpineIndex) == nil else { continue }
|
||||
store.addPrefetchTarget(adjacentSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
|
||||
)
|
||||
loader.loadChapter(
|
||||
spineIndex: adjacentSpineIndex,
|
||||
store: store,
|
||||
priority: .prefetch
|
||||
) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func maybePrefetchUpcomingChapters(
|
||||
aroundAbsolutePageNumber pageNumber: Int,
|
||||
in bookPageMap: RDEPUBBookPageMap,
|
||||
threshold: Int = 3,
|
||||
lookaheadChapterCount: Int = 2
|
||||
) {
|
||||
guard let publication = context.publication else { return }
|
||||
let absolutePageIndex = pageNumber - 1
|
||||
guard let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = store.chapterData(for: spineIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
let remainingPages = chapter.pages.count - localPageIndex - 1
|
||||
guard remainingPages <= threshold else { return }
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
guard let currentPosition = buildableIndices.firstIndex(of: spineIndex) else { return }
|
||||
|
||||
let targets = buildableIndices.dropFirst(currentPosition + 1).prefix(lookaheadChapterCount)
|
||||
for targetSpineIndex in targets {
|
||||
guard store.chapterData(for: targetSpineIndex) == nil else { continue }
|
||||
store.addPrefetchTarget(targetSpineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"boundary prefetch currentSpine=\(spineIndex) targetSpine=\(targetSpineIndex) remainingPages=\(remainingPages)"
|
||||
)
|
||||
loader.loadChapter(spineIndex: targetSpineIndex, store: store, priority: .prefetch) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableIndices = buildableSpineIndices(in: publication)
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in buildableIndices where spineIndex > lastKnownSpineIndex {
|
||||
guard let chapter = store.chapterData(for: spineIndex) else { break }
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
|
||||
let existingEntries = currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
var absolutePageStart = 0
|
||||
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalizedEntry = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalizedEntry
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: newEntries)
|
||||
guard newMap.totalPages > currentMap.totalPages else { return }
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(presentationRuntime.makeSnapshot(from: newMap))
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
private func refreshVisibleContentIfNeeded(afterPreparing spineIndex: Int, triggerPageNumber: Int) {
|
||||
guard let readerView = context.readerView,
|
||||
let bookPageMap = context.bookPageMap else {
|
||||
return
|
||||
}
|
||||
let visiblePageNumber = readerView.currentPage + 1
|
||||
if visiblePageNumber == triggerPageNumber {
|
||||
refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
guard visiblePageNumber > 0,
|
||||
let visibleSpineIndex = bookPageMap.spineIndex(forAbsolutePage: visiblePageNumber - 1),
|
||||
visibleSpineIndex == spineIndex else {
|
||||
return
|
||||
}
|
||||
refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
// Intentional synchronous path: called from TOC distant jumps where
|
||||
// the user expects immediate navigation. The loading indicator is shown
|
||||
// by the caller. Do NOT convert to async without UX consideration.
|
||||
private func loadPartialWindowChapters(
|
||||
around anchorPosition: Int,
|
||||
in buildableSpineIndices: [Int],
|
||||
targetSpineIndex: Int,
|
||||
windowSize: Int
|
||||
) -> [RDEPUBRuntimeChapter] {
|
||||
let lowerBound = max(anchorPosition - max(windowSize / 2, 0), 0)
|
||||
let upperBound = min(lowerBound + max(windowSize, 1), buildableSpineIndices.count)
|
||||
let startIndex = max(0, upperBound - max(windowSize, 1))
|
||||
let window = Array(buildableSpineIndices[startIndex..<upperBound])
|
||||
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in window {
|
||||
do {
|
||||
let chapter = try loader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: store
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == targetSpineIndex {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"ensureNavigationTarget FAILED target spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
return []
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"ensureNavigationTarget skip adjacent spine=\(spineIndex) error=\(error)"
|
||||
)
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
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 buildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
|
||||
publication.spine.indices.filter { index in
|
||||
let item = publication.spine[index]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
}
|
||||
|
||||
private func beginAsynchronousChapterPreparation(for spineIndex: Int) -> Bool {
|
||||
asyncLoadStateLock.lock()
|
||||
defer { asyncLoadStateLock.unlock() }
|
||||
return asynchronouslyPreparingSpineIndices.insert(spineIndex).inserted
|
||||
}
|
||||
|
||||
private func endAsynchronousChapterPreparation(for spineIndex: Int) {
|
||||
asyncLoadStateLock.lock()
|
||||
asynchronouslyPreparingSpineIndices.remove(spineIndex)
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
|
||||
private func beginPartialBookPageMapExtension() -> Bool {
|
||||
asyncLoadStateLock.lock()
|
||||
defer { asyncLoadStateLock.unlock() }
|
||||
guard !isExtendingPartialBookPageMap else { return false }
|
||||
isExtendingPartialBookPageMap = true
|
||||
return true
|
||||
}
|
||||
|
||||
private func endPartialBookPageMapExtension() {
|
||||
asyncLoadStateLock.lock()
|
||||
isExtendingPartialBookPageMap = false
|
||||
asyncLoadStateLock.unlock()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user