1. Configurable chapter window size (onDemandChapterWindowSize: 3-15) - Parameterized window radius in RDEPUBChapterRuntimeStore - Updated RDEPUBChapterWindowCoordinator to use configurable radius - RDEPUBChapterWindowSnapshot.from() accepts chapter array instead of fixed prev/next - Even numbers round up to odd (4→5), min 3, max 15 2. Configurable metadata parsing concurrency (metadataParsingConcurrency) - Default equals CPU core count - Parallel execution via OperationQueue in paginateMetadataOnly - Each worker creates independent builder instance - NSLock protects result aggregation 3. Per-chapter and total wall-clock timing instrumentation - Separated render vs I/O timing per chapter - Summary log with wallClockMs, renderTotalMs, writeTotalMs, avgRenderMs - Timing stored in RDEPUBReaderContext for test access 4. UI automation test infrastructure - Added --demo-window-size, --demo-concurrency, --demo-clear-cache launch args - DemoReaderState exposes windowSize, parseMs, parseConcurrency - ConfigurableWindowTests: 5 test cases for window size 3/5/15 - ConcurrentParsingTests: 4 test cases for concurrency 2/4 - MetadataParseBenchmarkTests: serial vs parallel benchmark 5. Bug fixes - Fixed page snap-back during background parsing (isUserInteracting check) - Reduced BookPageMap refresh frequency from 16 to 32 chapters - Moved waitForReadingInteractionToSettle outside operation loop 6. Design doc: dual-layer PageMap (estimated + precise mixed)
349 lines
14 KiB
Swift
349 lines
14 KiB
Swift
import UIKit
|
||
|
||
/// 阅读器共享状态中心:所有 coordinator 通过 context 访问业务状态和便捷方法。
|
||
///
|
||
/// context 持有:
|
||
/// - 业务状态(parser、publication、textBook、pages 等)
|
||
/// - UI 配置(configuration、brightness)
|
||
/// - 持久化策略(persistence)
|
||
/// - 便捷方法(renderStyle、layoutConfig 等)
|
||
/// - 弱引用 controller(仅用于 UIKit 呈现操作)
|
||
final class RDEPUBReaderContext {
|
||
private let activityLock = NSLock()
|
||
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
|
||
|
||
// MARK: - 引用
|
||
|
||
/// 弱引用阅读器控制器,用于 UIKit 呈现操作。
|
||
weak var controller: RDEPUBReaderController?
|
||
/// 弱引用阅读器视图,用于布局和页面状态查询。
|
||
weak var readerView: RDReaderView?
|
||
/// 依赖注入容器,提供解析器、分页器等工厂方法。
|
||
var dependencies: RDEPUBReaderDependencies = .live
|
||
/// 便捷访问当前控制器的运行时协调器集合。
|
||
var runtime: RDEPUBReaderRuntime? {
|
||
controller?.runtime
|
||
}
|
||
|
||
// MARK: - 业务状态
|
||
|
||
/// EPUB 解析器实例。
|
||
var parser: RDEPUBParser?
|
||
/// 解析后的出版物模型。
|
||
var publication: RDEPUBPublication?
|
||
/// 当前阅读会话,管理页面和章节状态。
|
||
var readingSession: RDEPUBReadingSession?
|
||
/// 原生文本排版生成的图书模型(仅文本重排模式)。
|
||
/// 章节模式下为 nil,内容通过 ChapterRuntimeStore 访问。
|
||
var textBook: RDEPUBTextBook?
|
||
/// 全书轻量页码映射(章节模式)。约 100KB/1000章,不持有 NSAttributedString。
|
||
var bookPageMap: RDEPUBBookPageMap?
|
||
/// 当前书籍的所有书签。
|
||
var activeBookmarks: [RDEPUBBookmark] = []
|
||
/// 当前书籍的所有高亮标注。
|
||
var activeHighlights: [RDEPUBHighlight] = []
|
||
/// 当前打开书籍的唯一标识。
|
||
var currentBookIdentifier: String?
|
||
/// 分页操作令牌,用于取消过期的异步分页任务。
|
||
var paginationToken = UUID()
|
||
/// Web 内容分页计算器。
|
||
var paginator: RDEPUBPaginator?
|
||
/// 全文搜索状态。
|
||
var searchState: RDEPUBSearchState?
|
||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||
var lastTextPaginationPageSize: CGSize?
|
||
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
||
var lastMetadataParseWallClockMs: Int = 0
|
||
/// 后台元数据解析使用的并发数。
|
||
var lastMetadataParseConcurrency: Int = 0
|
||
/// 当前用户文本选区。
|
||
var currentSelection: RDEPUBSelection?
|
||
|
||
// MARK: - 控制器状态(从 controller 下沉)
|
||
|
||
/// 阅读器配置(字号、字体、主题等)。
|
||
var configuration: RDEPUBReaderConfiguration = .default
|
||
/// 持久化策略,负责书签、高亮、阅读位置的存取。
|
||
var persistence: RDEPUBReaderPersistence?
|
||
/// 当前打开的 EPUB 文件 URL。
|
||
var epubURL: URL = URL(string: "about:blank")!
|
||
/// 是否正在重新分页。
|
||
var isRepaginating: Bool = false
|
||
/// 是否已完成首次加载。
|
||
var didStartInitialLoad: Bool = false
|
||
/// 是否为外部传入的纯文本图书。
|
||
var isExternalTextBook: Bool = false
|
||
/// 外部纯文本文件的 URL。
|
||
var textFileURL: URL?
|
||
/// 文本图书缓存,避免重复排版。
|
||
var textBookCache = RDEPUBTextBookCache()
|
||
|
||
// MARK: - 初始化
|
||
|
||
init(controller: RDEPUBReaderController) {
|
||
self.controller = controller
|
||
self.readerView = controller.readerView
|
||
}
|
||
|
||
// MARK: - 便捷方法
|
||
|
||
/// 根据当前 readerView 和控制器尺寸构建布局上下文。
|
||
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
|
||
let containerSize = readerView?.bounds.size ?? .zero
|
||
let viewSize = controller?.view.bounds.size ?? containerSize
|
||
let resolvedSize = containerSize == .zero ? viewSize : containerSize
|
||
return RDEPUBNavigatorLayoutContext(
|
||
containerSize: resolvedSize,
|
||
pagesPerScreen: readerView?.pagesPerScreen ?? 1,
|
||
safeAreaInsets: controller?.view.safeAreaInsets ?? .zero,
|
||
userInterfaceIdiom: controller?.traitCollection.userInterfaceIdiom ?? .phone,
|
||
reflowableContentInsets: configuration.reflowableContentInsets
|
||
)
|
||
}
|
||
|
||
/// 根据当前配置生成阅读偏好设置。
|
||
func currentPreferences() -> RDEPUBPreferences {
|
||
configuration.makePreferences()
|
||
}
|
||
|
||
/// 获取当前文本排版的单页尺寸,优先从 readerView 解析,兜底用布局上下文。
|
||
func currentTextPageSize() -> CGSize {
|
||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||
if let readerView, let pageNum {
|
||
let resolvedSize = readerView.resolvedSinglePageSize(pageNum: pageNum)
|
||
if resolvedSize.width > 0, resolvedSize.height > 0 {
|
||
return resolvedSize
|
||
}
|
||
}
|
||
return currentLayoutContext().viewportSize
|
||
}
|
||
|
||
/// 根据当前配置生成文本渲染样式(字体、行距、颜色)。
|
||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||
return RDEPUBTextRenderStyle(
|
||
font: font,
|
||
lineSpacing: lineSpacing,
|
||
textColor: configuration.theme.contentTextColor,
|
||
backgroundColor: configuration.theme.contentBackgroundColor
|
||
)
|
||
}
|
||
|
||
/// 根据页面尺寸和配置生成文本排版参数。
|
||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||
return RDEPUBTextLayoutConfig(
|
||
frameWidth: max(pageSize.width, 1),
|
||
frameHeight: max(pageSize.height, 1),
|
||
edgeInsets: configuration.reflowableContentInsets,
|
||
numberOfColumns: configuration.numberOfColumns,
|
||
columnGap: configuration.columnGap,
|
||
// 小说正文更看重尽量铺满页面,避免页尾出现明显留白。
|
||
avoidOrphans: false,
|
||
avoidWidows: false,
|
||
avoidPageBreakInsideEnabled: true,
|
||
hyphenation: true,
|
||
imageMaxHeightRatio: 0.85,
|
||
fallbackViewportSize: dependencies.environment.fallbackViewportSize
|
||
)
|
||
}
|
||
|
||
/// 获取当前配置对应的文本渲染器实例。
|
||
func resolvedTextRenderer() -> RDEPUBTextRenderer {
|
||
dependencies.makeTextRenderer(configuration.textRenderingEngine)
|
||
}
|
||
|
||
/// 当前活跃的页面列表。
|
||
var activePages: [EPUBPage] {
|
||
readingSession?.activePages ?? []
|
||
}
|
||
|
||
/// 当前活跃的章节信息列表。
|
||
var activeChapters: [EPUBChapterInfo] {
|
||
readingSession?.activeChapters ?? []
|
||
}
|
||
|
||
/// 屏幕亮度代理属性,读写均转发给系统环境。
|
||
var currentBrightness: CGFloat {
|
||
get { dependencies.environment.currentBrightness }
|
||
set { dependencies.environment.currentBrightness = newValue }
|
||
}
|
||
|
||
/// 用新快照替换当前活跃的分页快照。
|
||
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
|
||
readingSession?.setActiveSnapshot(snapshot)
|
||
}
|
||
|
||
/// 清除当前活跃快照,重置运行时状态。
|
||
func clearActiveSnapshot() {
|
||
readingSession?.resetRuntimeState()
|
||
}
|
||
|
||
/// 工厂方法:创建 EPUB 解析器。
|
||
func makeParser() -> RDEPUBParser {
|
||
dependencies.makeParser()
|
||
}
|
||
|
||
/// 工厂方法:创建 Web 内容分页计算器。
|
||
func makePaginator() -> RDEPUBPaginator {
|
||
dependencies.makePaginator()
|
||
}
|
||
|
||
/// 工厂方法:创建 EPUB 文本图书构建器。
|
||
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
|
||
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
|
||
}
|
||
|
||
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
|
||
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
|
||
?? FileManager.default.temporaryDirectory
|
||
let bookID = (currentBookIdentifier ?? "default").sha256Hex
|
||
let directory = cachesDirectory
|
||
.appendingPathComponent("RDEPUBChapterSummaryCache", isDirectory: true)
|
||
.appendingPathComponent(bookID, isDirectory: true)
|
||
return RDEPUBChapterSummaryDiskCache(cacheDirectory: directory)
|
||
}
|
||
|
||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||
let style = currentTextRenderStyle()
|
||
let pageSize = currentTextPageSize()
|
||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||
let renderSignature = [
|
||
style.font.fontName,
|
||
"\(style.font.pointSize)",
|
||
"\(configuration.lineHeightMultiple)",
|
||
"\(style.lineSpacing)",
|
||
layoutConfig.cacheSignature,
|
||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||
].joined(separator: "|")
|
||
|
||
let contentHash: String
|
||
if let parser,
|
||
let publication,
|
||
publication.spine.indices.contains(spineIndex) {
|
||
let href = publication.spine[spineIndex].href
|
||
contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||
} else {
|
||
contentHash = ""
|
||
}
|
||
|
||
return RDEPUBChapterCacheKey(
|
||
bookID: currentBookIdentifier ?? "",
|
||
spineIndex: spineIndex,
|
||
renderSignature: renderSignature,
|
||
chapterContentHash: contentHash
|
||
)
|
||
}
|
||
|
||
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
||
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
||
}
|
||
|
||
func normalizedSpineIndex(for location: RDEPUBLocation) -> Int? {
|
||
guard let publication else { return nil }
|
||
let normalizedLocation = publication.resourceResolver.normalizedLocation(
|
||
location,
|
||
relativeToSpineIndex: nil,
|
||
bookIdentifier: currentBookIdentifier
|
||
) ?? location
|
||
guard let normalizedHref = publication.resourceResolver.normalizedHref(normalizedLocation.href) else {
|
||
return nil
|
||
}
|
||
return publication.spine.firstIndex {
|
||
publication.resourceResolver.normalizedHref($0.href) == normalizedHref
|
||
}
|
||
}
|
||
|
||
/// 工厂方法:创建纯文本图书构建器。
|
||
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
|
||
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
|
||
}
|
||
|
||
/// 获取当前可见页面的阅读位置。
|
||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||
controller?.currentVisibleLocation()
|
||
}
|
||
|
||
/// 从持久化存储加载上次保存的阅读位置。
|
||
func persistenceLocation() -> RDEPUBLocation? {
|
||
guard let currentBookIdentifier else { return nil }
|
||
return persistence?.loadLocation(for: currentBookIdentifier)
|
||
}
|
||
|
||
/// 将阅读位置持久化到存储。
|
||
func persist(location: RDEPUBLocation) {
|
||
guard let currentBookIdentifier else { return }
|
||
persistence?.saveLocation(location, for: currentBookIdentifier)
|
||
}
|
||
|
||
/// 记录最近一次用户翻页/跳转行为,用于后台分页让路。
|
||
func markUserNavigationActivity() {
|
||
activityLock.lock()
|
||
lastUserNavigationTimestamp = CFAbsoluteTimeGetCurrent()
|
||
activityLock.unlock()
|
||
}
|
||
|
||
/// 距离最近一次用户翻页/跳转已经过去的时间。
|
||
func secondsSinceLastUserNavigation() -> CFAbsoluteTime {
|
||
activityLock.lock()
|
||
let timestamp = lastUserNavigationTimestamp
|
||
activityLock.unlock()
|
||
guard timestamp > 0 else { return .greatestFiniteMagnitude }
|
||
return CFAbsoluteTimeGetCurrent() - timestamp
|
||
}
|
||
|
||
/// 根据规范化 href 获取文本章节数据。
|
||
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
|
||
guard let textBook, let publication else { return nil }
|
||
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
|
||
return textBook.chapters.lazy
|
||
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
|
||
.flatMap { textBook.chapterData(for: $0.href) }
|
||
}
|
||
|
||
/// 显示加载指示器。
|
||
func showLoading() {
|
||
controller?.showLoading()
|
||
}
|
||
|
||
/// 隐藏加载指示器。
|
||
func hideLoading() {
|
||
controller?.hideLoading()
|
||
}
|
||
|
||
/// 将错误转发给控制器处理。
|
||
func handle(error: Error) {
|
||
controller?.handle(error: error)
|
||
}
|
||
|
||
/// 触发工具栏状态更新。
|
||
func updateReaderChrome() {
|
||
controller?.updateReaderChrome()
|
||
}
|
||
|
||
/// 刷新可见内容并保持当前阅读位置不变。
|
||
func refreshVisibleContentPreservingLocation() {
|
||
controller?.refreshVisibleContentPreservingLocation()
|
||
}
|
||
|
||
/// 恢复到指定阅读位置。
|
||
func restoreReadingLocation(_ location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||
controller?.restoreReadingLocation(location, animated: animated) ?? false
|
||
}
|
||
|
||
/// 重新分页并保持当前阅读位置。
|
||
func repaginatePreservingCurrentLocation() {
|
||
controller?.repaginatePreservingCurrentLocation()
|
||
}
|
||
|
||
/// 将当前配置应用到阅读器视图。
|
||
func applyReaderViewConfiguration() {
|
||
controller?.applyReaderViewConfiguration()
|
||
}
|
||
|
||
/// 同步书签按钮的 UI 状态。
|
||
func updateBookmarkChrome() {
|
||
controller?.updateBookmarkChrome()
|
||
}
|
||
}
|