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>
This commit is contained in:
shenlei
2026-06-24 17:47:24 +08:00
co-authored by Claude
parent 7de661eb54
commit d15f20b097
59 changed files with 4522 additions and 7220 deletions
@@ -1,6 +1,10 @@
import UIKit
protocol RDReaderCachePolicyProviding {
var shouldAvoidReaderPageCaching: Bool { get }
}
final class RDReaderPreloadController {
var radius: Int = 2
@@ -76,7 +80,11 @@ final class RDReaderPreloadController {
) -> UIView {
let reusableView = detachedReusablePageView(for: pageNum)
let view = contentViewProvider(pageNum, reusableView) ?? reusableView ?? UIView()
pageCurlCachedViews[pageNum] = view
if shouldCache(view: view, for: pageNum, environment: environment) {
pageCurlCachedViews[pageNum] = view
} else {
pageCurlCachedViews.removeValue(forKey: pageNum)
}
return view
}
@@ -105,12 +113,20 @@ final class RDReaderPreloadController {
for targetPage in targets {
let existing = detachedReusablePageView(for: targetPage)
let contentView = contentViewProvider(targetPage, existing) ?? existing ?? UIView()
preloadedPageViews[targetPage] = contentView
let shouldCacheContentView = shouldCache(view: contentView, for: targetPage, environment: environment)
if shouldCacheContentView {
preloadedPageViews[targetPage] = contentView
} else {
preloadedPageViews.removeValue(forKey: targetPage)
}
if contentView.superview !== preloadHostView {
contentView.removeFromSuperview()
preloadHostView.addSubview(contentView)
}
contentView.frame = preloadHostView.bounds
if !shouldCacheContentView {
contentView.removeFromSuperview()
}
}
}
@@ -139,6 +155,14 @@ final class RDReaderPreloadController {
&& pageNum < environment.totalPages
}
private func shouldCache(view: UIView, for pageNum: Int, environment: Environment) -> Bool {
guard shouldCachePage(pageNum, environment: environment) else { return false }
if let policyProvider = view as? RDReaderCachePolicyProviding {
return !policyProvider.shouldAvoidReaderPageCaching
}
return true
}
private func isDualPage(environment: Environment) -> Bool {
environment.landscapeDualPageEnabled
&& environment.isLandscape
@@ -96,6 +96,11 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
}
public func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
let resolvedPage = (pageViewController.viewControllers?.first as? RDReaderPageChildViewController)?.pageNum ?? -1
RDEPUBBackgroundTrace.log(
"PageCurl",
"didFinishAnimating finished=\(finished) completed=\(completed) resolvedPage=\(resolvedPage + 1) currentPageBeforeFinish=\(currentPage + 1)"
)
if completed, let firstVC = pageViewController.viewControllers?.first as? RDReaderPageChildViewController {
let pn = firstVC.pageNum
if pn != RDReaderView.blankPageNum && pn != RDReaderView.blankEndPageNum {
@@ -114,8 +119,17 @@ extension RDReaderView: UIPageViewControllerDataSource, UIPageViewControllerDele
if let target = pendingViewControllers.first as? RDReaderPageChildViewController,
target.pageNum != RDReaderView.blankPageNum,
target.pageNum != RDReaderView.blankEndPageNum {
RDEPUBBackgroundTrace.log(
"PageCurl",
"willTransitionTo targetPage=\(target.pageNum + 1) currentPage=\(currentPage + 1)"
)
predictedPageDirection = target.pageNum >= currentPage
primePageCache(around: target.pageNum, preferredForward: predictedPageDirection)
} else {
RDEPUBBackgroundTrace.log(
"PageCurl",
"willTransitionTo blank target currentPage=\(currentPage + 1)"
)
}
}
}
@@ -267,6 +267,10 @@ public class RDReaderView: UIView {
set { pagingController.pendingTransitionRequest = newValue }
}
var isPageCurlTransitioning: Bool {
currentDisplayType == .pageCurl && pagingController.isTransitioning
}
override init(frame: CGRect) {
super.init(frame: frame)
accessibilityIdentifier = "epub.reader.content"
@@ -401,6 +405,8 @@ public class RDReaderView: UIView {
if let pending = pagingController.finishPageCurlTransition() {
transitionToPage(pageNum: pending.pageNum, animated: pending.animated)
}
updatePagingInteractionSuppression()
}
private var preloadEnvironment: RDReaderPreloadController.Environment {
@@ -603,12 +609,24 @@ public class RDReaderView: UIView {
}
private func updatePagingInteractionSuppression() {
let shouldSuppress = selectionPagingSuppressedContentViews.allObjects.isEmpty == false
let shouldSuppress = activePagingSuppressionContentViews().isEmpty == false
guard shouldSuppress != isPagingInteractionSuppressed else { return }
isPagingInteractionSuppressed = shouldSuppress
setPagingInteractionEnabled(!shouldSuppress)
}
private func activePagingSuppressionContentViews() -> [UIView] {
selectionPagingSuppressedContentViews.allObjects.filter { contentView in
guard contentView.window != nil,
contentView.isDescendant(of: self),
contentView.bounds.isEmpty == false else {
return false
}
let frameInReader = contentView.convert(contentView.bounds, to: self)
return frameInReader.intersects(bounds)
}
}
private func setPagingInteractionEnabled(_ isEnabled: Bool) {
collectionView.isScrollEnabled = isEnabled
pageViewController.gestureRecognizers.forEach { $0.isEnabled = isEnabled }
@@ -721,7 +739,13 @@ public class RDReaderView: UIView {
}
public func reloadPageCountOnly() {
collectionView.reloadData()
if currentDisplayType == .pageCurl {
if !isPageCurlTransitioning, currentPage >= 0, numberOfPages() > 0 {
transitionToPage(pageNum: currentPage, animated: false)
}
} else {
collectionView.reloadData()
}
topToolView = resolvedTopChromeView()
bottomToolView = resolvedBottomChromeView()
}