ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBPresentationRuntime.swift
shen 7de661eb54 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、状态机、内存警告、预加载、位置恢复等
2026-06-23 08:17:08 +08:00

185 lines
7.0 KiB
Swift

import UIKit
final class RDEPUBPresentationRuntime {
private unowned let context: RDEPUBReaderContext
private unowned let locationCoordinator: RDEPUBReaderLocationCoordinator
private unowned let jumpSessionManager: RDEPUBJumpSessionManager
private unowned let reconciliationCoordinator: RDEPUBPageMapReconciliationCoordinator
let navigationStateMachine = RDEPUBNavigationStateMachine()
private(set) var paginationState = RDEPUBPaginationState()
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
) {
navigationStateMachine.transition(to: .presentingWindow)
context.textBook = nil
context.bookPageMap = bookPageMap
context.pendingFullPageMap = nil
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.pendingFullPageMap = nil
paginationState.source = .initialPartial
finishPagination(restoreLocation)
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
if let pendingMap = context.pendingFullPageMap {
let shouldKeepExisting =
pendingMap.totalChapters > bookPageMap.totalChapters ||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
pendingMap.totalPages >= bookPageMap.totalPages)
if shouldKeepExisting {
return
}
}
navigationStateMachine.transition(to: .reconcilingFullMap)
context.pendingFullPageMap = bookPageMap
paginationState.pendingFullPageMap = bookPageMap
}
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
guard !controller.isRepaginating else { return }
navigationStateMachine.transition(to: .reconcilingFullMap)
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
case .fullReplace(let newPageMap):
RDEPUBBackgroundTrace.log("Reconciliation", "decision: fullReplace")
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
}
}
func applyExtendedPartialPageMap(
_ bookPageMap: RDEPUBBookPageMap,
currentPageNumber: Int,
currentLocation: RDEPUBLocation?
) {
guard let readerView = context.readerView else { return }
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.source = .asyncExtension
readerView.reloadPageCountOnly()
if let currentLocation,
locationCoordinator.restoreReadingLocation(currentLocation, animated: false) {
return
}
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
}
func applySettingsPreviewPageMap(_ bookPageMap: RDEPUBBookPageMap) {
navigationStateMachine.transition(to: .presentingWindow)
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationState.activePageMap = bookPageMap
paginationState.source = .settingsPreview
}
func clear() {
paginationState = RDEPUBPaginationState()
navigationStateMachine.transition(to: .idle)
}
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()
context.pendingFullPageMap = nil
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
paginationState.activePageMap = newPageMap
paginationState.pendingFullPageMap = nil
paginationState.source = .fullReplacement
navigationStateMachine.transition(to: .presentingWindow)
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
readerView.reloadPageCountOnly()
if newPage != readerView.currentPage {
readerView.transitionToPage(pageNum: newPage, animated: false)
}
} 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)
}
}
}
}