feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化

- 实现EPUB阅读器搜索功能及选中注释功能
- 优化CFI模块,修复代码审查发现的11个问题
- 实现大书远距目录跳转与后台补全优化方案
- 优化设置面板与章节运行时联动
- 重构及大量改进优化
This commit is contained in:
shenlei
2026-06-22 20:26:34 +08:00
parent f50495ad91
commit c65c190b71
178 changed files with 11380 additions and 6728 deletions
@@ -1,65 +1,78 @@
import UIKit
/// EPUB
///
/// Facade
final class RDEPUBReaderRuntime {
private unowned let context: RDEPUBReaderContext
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
lazy var chapterLoader: RDEPUBChapterLoader = {
let loader = RDEPUBChapterLoader(context: context)
loader.setSummaryDiskCache(summaryDiskCache)
return loader
}()
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
///
var isSettingsPanelOpen: Bool = false
///
var needsFullRepaginationAfterSettingsClose: Bool = false
///
private var settingsPreviewGeneration: Int = 0
/// preview
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
/// preview
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
private struct SettingsPreviewAnchor {
let spineIndex: Int
let href: String
let offset: Int
}
init(context: RDEPUBReaderContext) {
self.context = context
}
///
func makeTopToolView() -> RDEPUBReaderTopToolView {
chromeCoordinator.makeTopToolView()
}
///
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
chromeCoordinator.makeBottomToolView()
}
///
func startInitialLoadIfNeeded() {
loadCoordinator.startInitialLoadIfNeeded()
}
///
func reloadBook() {
guard let readerView = context.readerView else { return }
context.didStartInitialLoad = false
@@ -80,20 +93,10 @@ final class RDEPUBReaderRuntime {
startInitialLoadIfNeeded()
}
///
/// - Parameters:
/// - location:
/// - animated:
/// - Returns:
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
}
///
/// - Parameters:
/// - pageNumber: 1
/// - animated:
/// - Returns:
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard let controller = context.controller,
@@ -134,7 +137,6 @@ final class RDEPUBReaderRuntime {
return true
}
///
func clearSelection() {
annotationCoordinator.updateCurrentSelection(nil)
}
@@ -230,30 +232,25 @@ final class RDEPUBReaderRuntime {
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
}
///
func search(keyword: String) {
searchCoordinator.search(keyword: keyword)
}
///
@discardableResult
func searchNext() -> Bool {
searchCoordinator.searchNext()
}
///
@discardableResult
func searchPrevious() -> Bool {
searchCoordinator.searchPrevious()
}
///
@discardableResult
func selectSearchMatch(at index: Int) -> Bool {
searchCoordinator.selectSearchMatch(at: index)
}
///
func clearSearch() {
searchCoordinator.clearSearch()
}
@@ -262,32 +259,26 @@ final class RDEPUBReaderRuntime {
searchCoordinator.searchPresentation(for: page)
}
///
func updateReaderChrome() {
chromeCoordinator.updateReaderChrome()
}
///
func presentSettings() {
chromeCoordinator.presentSettings()
}
///
func presentTableOfContents() {
chromeCoordinator.presentTableOfContents()
}
///
func handleBackAction() {
chromeCoordinator.handleBackAction()
}
/// Publication
func loadPublication() {
loadCoordinator.loadPublication()
}
/// Publication
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
@@ -306,17 +297,14 @@ final class RDEPUBReaderRuntime {
)
}
/// Publication
func paginatePublication(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
}
/// TextBook
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
}
///
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
@@ -332,21 +320,26 @@ final class RDEPUBReaderRuntime {
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
// map
// map
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
}
/// 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,
@@ -364,13 +357,12 @@ final class RDEPUBReaderRuntime {
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
case .expandWindow, .segmentReplace:
// candidatePageMap
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
break
}
}
///
private func applyFullPageMapReplacement(
_ newPageMap: RDEPUBBookPageMap,
readerView: RDReaderView,
@@ -380,12 +372,10 @@ final class RDEPUBReaderRuntime {
context.pendingFullPageMap = nil
// map
context.textBook = nil
context.bookPageMap = newPageMap
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
// map
if let currentLocation {
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
let newPage = max(0, newPageNumber - 1)
@@ -397,7 +387,6 @@ final class RDEPUBReaderRuntime {
readerView.reloadPageCountOnly()
}
// JumpSessioncoverage-complete
if let currentLocation,
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
let activeSession = jumpSessionManager.activeSession {
@@ -410,14 +399,12 @@ final class RDEPUBReaderRuntime {
}
}
///
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
///
func repaginatePreservingCurrentLocation() {
//
if isSettingsPanelOpen {
needsFullRepaginationAfterSettingsClose = true
paginationCoordinator.cancelActiveMetadataParseWork()
@@ -427,7 +414,6 @@ final class RDEPUBReaderRuntime {
}
}
/// preview
private func scheduleSettingsPreviewRepagination() {
pendingSettingsPreviewWorkItem?.cancel()
settingsPreviewGeneration += 1
@@ -440,8 +426,12 @@ final class RDEPUBReaderRuntime {
return
}
self.pendingSettingsPreviewWorkItem = nil
let previewAnchor = self.captureSettingsPreviewAnchor()
self.chapterRuntimeStore.invalidateAllForSettingsChange()
self.repaginateCurrentChapterOnly(previewGeneration: previewGeneration)
self.repaginateCurrentChapterOnly(
previewGeneration: previewGeneration,
previewAnchor: previewAnchor
)
}
pendingSettingsPreviewWorkItem = workItem
DispatchQueue.main.asyncAfter(
@@ -450,8 +440,34 @@ final class RDEPUBReaderRuntime {
)
}
/// 使
private func repaginateCurrentChapterOnly(previewGeneration: Int) {
private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? {
guard let bookPageMap = context.bookPageMap,
let readerView = context.readerView else { return nil }
let absolutePageIndex = readerView.currentPage
guard absolutePageIndex >= 0,
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
let chapter = chapterRuntimeStore.chapterData(for: spineIndex),
chapter.pages.indices.contains(localPageIndex) else {
return nil
}
let page = chapter.pages[localPageIndex]
let offset = page.contentRange.length > 0
? page.contentRange.location
: page.pageStartOffset
return SettingsPreviewAnchor(
spineIndex: spineIndex,
href: chapter.href,
offset: offset
)
}
private func repaginateCurrentChapterOnly(
previewGeneration: Int,
previewAnchor: SettingsPreviewAnchor?
) {
guard let bookPageMap = context.bookPageMap,
let readerView = context.readerView else { return }
@@ -485,11 +501,18 @@ final class RDEPUBReaderRuntime {
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
readerView.reloadData()
if let targetPage = self.settingsPreviewTargetPage(
in: chapter,
for: previewAnchor
) {
readerView.transitionToPage(pageNum: targetPage, animated: false)
return
}
if let previewLocation,
self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) {
return
}
readerView.transitionToPage(pageNum: 0, animated: false)
case .failure(let error):
@@ -501,7 +524,37 @@ final class RDEPUBReaderRuntime {
}
}
///
private func settingsPreviewTargetPage(
in chapter: RDEPUBRuntimeChapter,
for anchor: SettingsPreviewAnchor?
) -> Int? {
guard let anchor,
anchor.spineIndex == chapter.spineIndex,
anchor.href == chapter.href,
!chapter.pages.isEmpty else {
return nil
}
if let exactPage = chapter.pages.first(where: { page in
let lowerBound = page.contentRange.location
let upperBound = page.contentRange.location + page.contentRange.length
if page.contentRange.length == 0 {
return anchor.offset == lowerBound
}
return anchor.offset >= lowerBound && anchor.offset < upperBound
}) {
return exactPage.pageIndexInChapter
}
if let nextPage = chapter.pages.first(where: { page in
page.contentRange.location > anchor.offset
}) {
return nextPage.pageIndexInChapter
}
return max(chapter.pages.count - 1, 0)
}
func settingsPanelWillAppear() {
pendingSettingsPreviewWorkItem?.cancel()
pendingSettingsPreviewWorkItem = nil
@@ -510,13 +563,12 @@ final class RDEPUBReaderRuntime {
settingsPreviewGeneration += 1
}
///
func settingsPanelDidDisappear() {
pendingSettingsPreviewWorkItem?.cancel()
pendingSettingsPreviewWorkItem = nil
isSettingsPanelOpen = false
settingsPreviewGeneration += 1
//
if needsFullRepaginationAfterSettingsClose {
needsFullRepaginationAfterSettingsClose = false
RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination")
@@ -524,17 +576,14 @@ final class RDEPUBReaderRuntime {
}
}
///
func refreshVisibleContentPreservingLocation() {
paginationCoordinator.refreshVisibleContentPreservingLocation()
}
/// TextBook
func rebuildExternalTextBook() {
paginationCoordinator.rebuildExternalTextBook()
}
///
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
@@ -548,17 +597,14 @@ final class RDEPUBReaderRuntime {
)
}
///
func currentVisibleLocation() -> RDEPUBLocation? {
locationCoordinator.currentVisibleLocation()
}
///
func currentViewportSignature() -> RDEPUBViewportSignature? {
viewportMonitor.currentViewportSignature()
}
///
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
@@ -574,7 +620,6 @@ final class RDEPUBReaderRuntime {
return false
}
//
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let isDistantJump = if let current = currentSpineIndex {
@@ -584,7 +629,7 @@ final class RDEPUBReaderRuntime {
}
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
@@ -641,14 +686,13 @@ final class RDEPUBReaderRuntime {
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
context.readerView?.reloadData()
// JumpSession
if isDistantJump {
jumpSessionManager.createSession(
anchorSpineIndex: targetSpineIndex,
reason: .tableOfContentsJump,
totalSpineCount: publication.spine.count
)
//
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
}
@@ -723,25 +767,23 @@ final class RDEPUBReaderRuntime {
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
}
@@ -816,6 +858,40 @@ final class RDEPUBReaderRuntime {
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
)
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() {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllForSettingsChange()
@@ -826,7 +902,6 @@ final class RDEPUBReaderRuntime {
backgroundCoverageStore.clearAll()
}
///
func handleMemoryWarning() {
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
@@ -893,6 +968,76 @@ 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 {
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