ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderRuntime.swift
shenlei d15f20b097 feat: 交互协调器拆分、附件提示、暗色图片适配、选区放大镜及文档清理
- 拆分 ContentDelegates/TextContentView 为独立协调器(InteractionCoordinator、LocationResolution、ExternalLinks、AttachmentTooltip)
- 新增 RDEPUBAttachmentTooltipView/OverlayView 附件气泡提示
- 新增 RDEPUBDarkImageAdjuster 暗色模式图片亮度适配
- 新增 RDEPUBSelectionLoupeView 选区放大镜
- 新增 MetadataParseWorker/CancellationController 元数据解析取消机制
- 重构 PresentationRuntime/PaginationCoordinator 精简职责
- 优化 ChapterLoader/WarmupOrchestrator 异步章节加载
- CFI 模块微调与 NoteModels 更新
- 清理冗余文档,更新架构/UML/业务逻辑文档

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-24 17:47:24 +08:00

655 lines
22 KiB
Swift

import UIKit
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)
loader.onDeferredCFIMapReady = { [weak self] spineIndex in
self?.handleDeferredCFIMapReady(for: spineIndex)
}
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)
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
private var settingsPreviewGeneration: Int = 0
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
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
context.parser = nil
context.publication = nil
context.clearActiveSnapshot()
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
context.pendingPageMapUpdates.removeAll()
context.activeBookmarks = []
context.activeHighlights = []
context.searchState = nil
clearOnDemandPageModeState()
viewportMonitor.resetForReload()
annotationCoordinator.updateCurrentSelection(nil)
readerView.reloadData()
startInitialLoadIfNeeded()
}
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
locationCoordinator.restoreReadingLocation(location, animated: animated)
}
@discardableResult
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
guard context.controller != nil,
let readerView = context.readerView,
pageNumber > 0 else {
return false
}
if let textBook = context.textBook {
guard textBook.page(at: pageNumber) != nil else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
if context.bookPageMap != nil {
guard prepareOnDemandChapter(forAbsolutePageNumber: pageNumber) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
guard context.activePages.indices.contains(pageNumber - 1) else {
return false
}
readerView.transitionToPage(pageNum: pageNumber - 1, animated: animated)
if let location = locationCoordinator.currentVisibleLocation() {
context.persist(location: location)
}
return true
}
func clearSelection() {
annotationCoordinator.updateCurrentSelection(nil)
}
func bookmark(withID id: String) -> RDEPUBBookmark? {
annotationCoordinator.bookmark(withID: id)
}
func highlight(withID id: String) -> RDEPUBHighlight? {
annotationCoordinator.highlight(withID: id)
}
@discardableResult
func addHighlight(
from selection: RDEPUBSelection? = nil,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
annotationCoordinator.addHighlight(from: selection, color: color, note: note)
}
@discardableResult
func addAnnotation(
from selection: RDEPUBSelection? = nil,
style: RDEPUBHighlightStyle,
color: String = "#F8E16C",
note: String? = nil
) -> RDEPUBHighlight? {
annotationCoordinator.addAnnotation(from: selection, style: style, color: color, note: note)
}
@discardableResult
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
annotationCoordinator.upsertHighlight(highlight)
}
@discardableResult
func removeHighlight(id: String) -> RDEPUBHighlight? {
annotationCoordinator.removeHighlight(id: id)
}
@discardableResult
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
annotationCoordinator.updateHighlightNote(id: id, note: note)
}
@discardableResult
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
annotationCoordinator.go(toHighlightID: id, animated: animated)
}
func removeAllHighlights() {
annotationCoordinator.removeAllHighlights()
}
@discardableResult
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
annotationCoordinator.addBookmark(note: note)
}
@discardableResult
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
annotationCoordinator.toggleBookmark(note: note)
}
@discardableResult
func removeBookmark(id: String) -> RDEPUBBookmark? {
annotationCoordinator.removeBookmark(id: id)
}
@discardableResult
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
annotationCoordinator.go(toBookmarkID: id, animated: animated)
}
func presentBookmarksManager() {
annotationCoordinator.presentBookmarksManager()
}
func presentHighlightsManager() {
annotationCoordinator.presentHighlightsManager()
}
func presentAnnotationCreation() {
annotationCoordinator.presentAnnotationCreation()
}
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
annotationCoordinator.presentHighlightActions(for: highlight, sourceView: sourceView, sourceRect: sourceRect)
}
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
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()
}
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
searchCoordinator.searchPresentation(for: page)
}
func updateReaderChrome() {
chromeCoordinator.updateReaderChrome()
}
func presentSettings() {
chromeCoordinator.presentSettings()
}
func presentTableOfContents() {
chromeCoordinator.presentTableOfContents()
}
func handleBackAction() {
chromeCoordinator.handleBackAction()
}
func loadPublication() {
loadCoordinator.loadPublication()
}
func applyParsedPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
restoreLocation: RDEPUBLocation?,
bookmarks: [RDEPUBBookmark],
highlights: [RDEPUBHighlight]
) {
loadCoordinator.applyParsedPublication(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
restoreLocation: restoreLocation,
bookmarks: bookmarks,
highlights: highlights
)
}
func paginatePublication(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
}
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
}
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
paginationCoordinator.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
func applyBookPageMap(_ bookPageMap: RDEPUBBookPageMap, restoreLocation: RDEPUBLocation?) {
presentationRuntime.applyBookPageMap(
bookPageMap,
restoreLocation: restoreLocation
) { [weak self] restoreLocation in
self?.paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
}
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
presentationRuntime.refreshBookPageMapInPlace(bookPageMap)
}
func applyPendingFullPageMapIfNeeded() {
presentationRuntime.commitPendingPageMapUpdateIfNeeded()
}
func finishPagination(restoreLocation: RDEPUBLocation?) {
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
}
func repaginatePreservingCurrentLocation() {
if isSettingsPanelOpen {
needsFullRepaginationAfterSettingsClose = true
paginationCoordinator.cancelActiveMetadataParseWork()
scheduleSettingsPreviewRepagination()
} else {
paginationCoordinator.repaginatePreservingCurrentLocation()
}
}
private func scheduleSettingsPreviewRepagination() {
pendingSettingsPreviewWorkItem?.cancel()
settingsPreviewGeneration += 1
let previewGeneration = settingsPreviewGeneration
let workItem = DispatchWorkItem { [weak self] in
guard let self,
self.isSettingsPanelOpen,
previewGeneration == self.settingsPreviewGeneration else {
return
}
self.pendingSettingsPreviewWorkItem = nil
let previewAnchor = self.captureSettingsPreviewAnchor()
self.chapterRuntimeStore.invalidateAllForSettingsChange()
self.repaginateCurrentChapterOnly(
previewGeneration: previewGeneration,
previewAnchor: previewAnchor
)
}
pendingSettingsPreviewWorkItem = workItem
DispatchQueue.main.asyncAfter(
deadline: .now() + settingsPreviewDebounceDelay,
execute: workItem
)
}
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 }
let currentPageNumber = readerView.currentPage + 1
guard let currentSpineIndex = bookPageMap.spineIndex(forAbsolutePage: currentPageNumber - 1) else {
return
}
let previewLocation = locationCoordinator.currentVisibleLocation() ?? context.persistenceLocation()
RDEPUBBackgroundTrace.log(
"Runtime",
"repaginateCurrentChapterOnly spine=\(currentSpineIndex) generation=\(previewGeneration)"
)
chapterLoader.loadChapter(
spineIndex: currentSpineIndex,
store: chapterRuntimeStore,
priority: .preview
) { [weak self] result in
guard let self,
self.isSettingsPanelOpen,
previewGeneration == self.settingsPreviewGeneration,
let readerView = self.context.readerView else {
return
}
switch result {
case .success(let chapter):
let partialMap = self.makePartialPageMap(from: [chapter])
self.presentationRuntime.applySettingsPreviewPageMap(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):
RDEPUBBackgroundTrace.log(
"Runtime",
"repaginateCurrentChapterOnly FAILED spine=\(currentSpineIndex) generation=\(previewGeneration) error=\(error)"
)
}
}
}
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
isSettingsPanelOpen = true
needsFullRepaginationAfterSettingsClose = false
settingsPreviewGeneration += 1
}
func settingsPanelDidDisappear() {
pendingSettingsPreviewWorkItem?.cancel()
pendingSettingsPreviewWorkItem = nil
isSettingsPanelOpen = false
settingsPreviewGeneration += 1
if needsFullRepaginationAfterSettingsClose {
needsFullRepaginationAfterSettingsClose = false
RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination")
paginationCoordinator.repaginatePreservingCurrentLocation()
}
}
func refreshVisibleContentPreservingLocation() {
paginationCoordinator.refreshVisibleContentPreservingLocation()
}
func rebuildExternalTextBook() {
paginationCoordinator.rebuildExternalTextBook()
}
@discardableResult
func restoreReadingLocation(
_ location: RDEPUBLocation,
animated: Bool = false,
targetHighlightRangeInfo: String? = nil
) -> Bool {
locationCoordinator.restoreReadingLocation(
location,
animated: animated,
targetHighlightRangeInfo: targetHighlightRangeInfo
)
}
func currentVisibleLocation() -> RDEPUBLocation? {
locationCoordinator.currentVisibleLocation()
}
func currentViewportSignature() -> RDEPUBViewportSignature? {
viewportMonitor.currentViewportSignature()
}
func handleViewportChangeIfNeeded(
reason: RDEPUBViewportChangeReason,
viewportSignature: RDEPUBViewportSignature? = nil
) {
viewportMonitor.handleViewportChangeIfNeeded(reason: reason, viewportSignature: viewportSignature)
}
@discardableResult
func ensureOnDemandNavigationTargetAvailable(for location: RDEPUBLocation) -> Bool {
chapterWarmupOrchestrator.ensureNavigationTargetAvailable(for: location)
}
@discardableResult
func prepareOnDemandChapter(
forAbsolutePageNumber pageNumber: Int,
allowSynchronousLoad: Bool = true,
completion: ((Bool) -> Void)? = nil
) -> Bool {
chapterWarmupOrchestrator.prepareOnDemandChapter(
forAbsolutePageNumber: pageNumber,
allowSynchronousLoad: allowSynchronousLoad,
completion: completion
)
}
func extendPartialBookPageMapIfNeeded(
currentPageNumber: Int,
minimumTrailingPages: Int = 2,
batchChapterCount: Int = 3
) {
chapterWarmupOrchestrator.extendPartialBookPageMapIfNeeded(
currentPageNumber: currentPageNumber,
minimumTrailingPages: minimumTrailingPages,
batchChapterCount: batchChapterCount
)
}
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
chapterWarmupOrchestrator.prefetchForwardChaptersAfterInitialOpen(
anchorSpineIndex: anchorSpineIndex,
totalSpineCount: totalSpineCount
)
}
func clearOnDemandPageModeState() {
paginationCoordinator.cancelActiveMetadataParseWork()
chapterRuntimeStore.invalidateAllForSettingsChange()
context.bookPageMap = nil
context.pendingPageMapUpdates.removeAll()
jumpSessionManager.clearSession()
backgroundPriorityManager.reset()
backgroundCoverageStore.clearAll()
chapterWarmupOrchestrator.clear()
presentationRuntime.clear()
}
func handleMemoryWarning() {
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
.flatMap { context.normalizedSpineIndex(for: $0) }
let activeWindowIndices: Set<Int> = if let currentSpineIndex {
[currentSpineIndex, currentSpineIndex - 1, currentSpineIndex + 1]
} else {
[]
}
let protectedIndices = jumpSessionManager.activeSession?.protectedSpineIndices ?? []
backgroundCoverageStore.handleMemoryWarning(
activeWindowSpineIndices: activeWindowIndices,
protectedSpineIndices: protectedIndices
)
}
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 handleDeferredCFIMapReady(for spineIndex: Int) {
guard let currentLocation = locationCoordinator.currentVisibleLocation(),
let visibleSpineIndex = context.normalizedSpineIndex(for: currentLocation),
visibleSpineIndex == spineIndex else {
return
}
refreshVisibleContentPreservingLocation()
}
}