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:
shen
2026-06-23 08:17:08 +08:00
parent c65c190b71
commit 7de661eb54
29 changed files with 3297 additions and 658 deletions
@@ -11,6 +11,9 @@ final class RDEPUBReaderRuntime {
lazy var chapterLoader: RDEPUBChapterLoader = {
let loader = RDEPUBChapterLoader(context: context)
loader.setSummaryDiskCache(summaryDiskCache)
loader.onDeferredCFIMapReady = { [weak self] spineIndex in
self?.handleDeferredCFIMapReady(for: spineIndex)
}
return loader
}()
@@ -38,6 +41,26 @@ final class RDEPUBReaderRuntime {
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
lazy var presentationRuntime = RDEPUBPresentationRuntime(
context: context,
locationCoordinator: locationCoordinator,
jumpSessionManager: jumpSessionManager,
reconciliationCoordinator: reconciliationCoordinator
)
lazy var chapterWarmupOrchestrator = RDEPUBChapterWarmupOrchestrator(
context: context,
store: chapterRuntimeStore,
loader: chapterLoader,
presentationRuntime: presentationRuntime,
locationCoordinator: locationCoordinator,
backgroundPriorityManager: backgroundPriorityManager,
jumpSessionManager: jumpSessionManager,
refreshVisibleContentPreservingLocation: { [weak self] in
self?.refreshVisibleContentPreservingLocation()
}
)
var isSettingsPanelOpen: Bool = false
var needsFullRepaginationAfterSettingsClose: Bool = false
@@ -99,7 +122,7 @@ final class RDEPUBReaderRuntime {
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard let controller = context.controller,
guard context.controller != nil,
let readerView = context.readerView,
pageNumber > 0 else {
return false
@@ -313,90 +336,20 @@ final class RDEPUBReaderRuntime {
}
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
context.textBook = nil
context.bookPageMap = bookPageMap
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
presentationRuntime.applyBookPageMap(
bookPageMap,
restoreLocation: restoreLocation
) { [weak self] restoreLocation in
self?.paginationCoordinator.finishPagination(restoreLocation: 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
}
}
context.pendingFullPageMap = bookPageMap
presentationRuntime.refreshBookPageMapInPlace(bookPageMap)
}
func applyPendingFullPageMapIfNeeded() {
guard let pendingMap = context.pendingFullPageMap,
let readerView = context.readerView,
let controller = context.controller else { return }
guard !controller.isRepaginating else { return }
let decision = reconciliationCoordinator.evaluateTakeover(
candidatePageMap: pendingMap,
candidateSegment: nil,
currentWindow: context.bookPageMap,
jumpSession: jumpSessionManager.activeSession
)
switch decision {
case .keepCurrentWindow:
RDEPUBBackgroundTrace.log("Reconciliation", "decision: keepCurrentWindow")
return
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")
break
}
}
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))
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,
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
let activeSession = jumpSessionManager.activeSession {
let candidateIndices = Set(newPageMap.entries.map { $0.spineIndex })
let protectedIndices = activeSession.protectedSpineIndices
let isFullyCovered = protectedIndices.isSubset(of: candidateIndices)
if isFullyCovered {
jumpSessionManager.endSession(.coverageComplete)
}
}
presentationRuntime.applyPendingFullPageMapIfNeeded()
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
@@ -497,8 +450,7 @@ final class RDEPUBReaderRuntime {
switch result {
case .success(let chapter):
let partialMap = self.makePartialPageMap(from: [chapter])
self.context.bookPageMap = partialMap
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
self.presentationRuntime.applySettingsPreviewPageMap(partialMap)
readerView.reloadData()
if let targetPage = self.settingsPreviewTargetPage(
@@ -614,282 +566,39 @@ final class RDEPUBReaderRuntime {
@discardableResult
func ensureOnDemandNavigationTargetAvailable(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 {
applyPendingFullPageMapIfNeeded()
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
}
return true
}
}
let buildableSpineIndices = publication.spine.indices.filter { index in
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
guard let anchorPosition = buildableSpineIndices.firstIndex(of: targetSpineIndex) else {
return false
}
let normalizedWindowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
context.configuration.onDemandChapterWindowSize
)
let chapters = loadPartialWindowChapters(
around: anchorPosition,
in: buildableSpineIndices,
targetSpineIndex: targetSpineIndex,
windowSize: normalizedWindowSize
)
guard !chapters.isEmpty else {
return false
}
chapterRuntimeStore.setCurrentChapter(
spineIndex: targetSpineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
)
let partialMap = makePartialPageMap(from: chapters)
context.bookPageMap = partialMap
context.replaceActiveSnapshot(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
chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location)
}
@discardableResult
func prepareOnDemandChapter(forAbsolutePageNumber pageNumber: Int) -> 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
}
chapterRuntimeStore.setCurrentChapter(
spineIndex: spineIndex,
totalSpineCount: publication.spine.count,
windowRadius: context.configuration.chapterWindowRadius
func prepareOnDemandChapter(
forAbsolutePageNumber pageNumber: Int,
allowSynchronousLoad: Bool = true,
completion: ((Bool) -> Void)? = nil
) -> Bool {
chapterWarmupOrchestrator.prepareOnDemandChapter(
forAbsolutePageNumber: pageNumber,
allowSynchronousLoad: allowSynchronousLoad,
completion: completion
)
RDEPUBBackgroundTrace.log(
"Runtime",
"prepareOnDemandChapter page=\(pageNumber) absoluteIndex=\(absolutePageIndex) spine=\(spineIndex)"
)
if chapterRuntimeStore.chapterData(for: spineIndex) == nil {
do {
_ = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
} catch {
RDEPUBBackgroundTrace.log("Runtime", "prepareOnDemandChapter FAILED: spine=\(spineIndex) error=\(error)")
return false
}
}
for evictable in chapterRuntimeStore.evictableSpineIndices() {
chapterRuntimeStore.evict(spineIndex: evictable)
}
for adjacentSpineIndex in chapterRuntimeStore.windowSpineIndices where adjacentSpineIndex != spineIndex {
guard chapterRuntimeStore.chapterData(for: adjacentSpineIndex) == nil else { continue }
chapterRuntimeStore.addPrefetchTarget(adjacentSpineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"schedule prefetch currentSpine=\(spineIndex) adjacentSpine=\(adjacentSpineIndex)"
)
chapterLoader.loadChapter(
spineIndex: adjacentSpineIndex,
store: chapterRuntimeStore,
priority: .prefetch
) { _ in }
}
return true
}
func extendPartialBookPageMapIfNeeded(currentPageNumber: Int, minimumTrailingPages: Int = 2, batchChapterCount: Int = 3) {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView else {
return
}
let buildableSpineIndices = publication.spine.indices.filter {
let item = publication.spine[$0]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
guard currentMap.totalChapters < buildableSpineIndices.count else {
return
}
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
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 else {
return
}
RDEPUBBackgroundTrace.log(
"Runtime",
"extendPartialBookPageMap currentPage=\(currentPageNumber) totalPages=\(currentMap.totalPages) appendSpines=\(spineIndicesToAppend) direction=\(isNearEnd ? "forward" : "backward")"
func extendPartialBookPageMapIfNeeded(
currentPageNumber: Int,
minimumTrailingPages: Int = 2,
batchChapterCount: Int = 3
) {
chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded(
currentPageNumber: currentPageNumber,
minimumTrailingPages: minimumTrailingPages,
batchChapterCount: batchChapterCount
)
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in spineIndicesToAppend {
do {
let chapter = try chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
appendedEntries.append(
RDEPUBBookPageMapEntry(
spineIndex: chapter.spineIndex,
href: chapter.href,
title: chapter.title,
pageCount: chapter.pages.count,
absolutePageStart: 0,
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
)
)
} catch {
RDEPUBBackgroundTrace.log("Runtime", "extendPartialBookPageMap skip spine=\(spineIndex) error=\(error)")
}
}
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)"
)
context.bookPageMap = newMap
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
readerView.reloadData()
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
guard context.publication != nil else { return }
chapterRuntimeStore.setCurrentChapter(
spineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount,
windowRadius: context.configuration.chapterWindowRadius
chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount
)
let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex }
guard !forwardTargets.isEmpty else { return }
for spineIndex in forwardTargets {
if chapterRuntimeStore.chapterData(for: spineIndex) != nil {
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
continue
}
chapterRuntimeStore.addPrefetchTarget(spineIndex)
RDEPUBBackgroundTrace.log(
"Runtime",
"initial open prefetch forward spine=\(spineIndex)"
)
chapterLoader.loadChapter(
spineIndex: spineIndex,
store: chapterRuntimeStore,
priority: .prefetch
) { [weak self] result in
guard let self, case .success = result else { return }
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
}
}
}
func clearOnDemandPageModeState() {
@@ -900,6 +609,8 @@ final class RDEPUBReaderRuntime {
jumpSessionManager.clearSession()
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()
chapterWarmupOrchestrator.clear()
presentationRuntime.clear()
}
func handleMemoryWarning() {
@@ -918,42 +629,6 @@ final class RDEPUBReaderRuntime {
)
}
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 chapterLoader.loadChapterSynchronouslyForMigration(
spineIndex: spineIndex,
store: chapterRuntimeStore
)
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 {
@@ -968,96 +643,12 @@ final class RDEPUBReaderRuntime {
return builder.build()
}
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
guard let publication = context.publication,
let currentMap = context.bookPageMap,
let readerView = context.readerView,
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
private func handleDeferredCFIMapReady(for spineIndex: Int) {
guard let currentLocation = locationCoordinator.currentVisibleLocation(),
let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation),
visibleSpineIndex == spineIndex else {
return
}
let buildableSpineIndices = publication.spine.indices.filter {
let item = publication.spine[$0]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
var appendedEntries: [RDEPUBBookPageMapEntry] = []
for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex {
guard let chapter = chapterRuntimeStore.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(makeSnapshot(from: newMap))
readerView.reloadPageCountOnly()
}
private 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)
refreshVisibleContentPreservingLocation()
}
}