Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift
T
shenandshen d20196ee34 feat: configurable chapter window & parallel metadata parsing with benchmark
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)
2026-06-03 23:38:11 +08:00

349 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}
}