ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift
shen 7de661eb54 feat: 架构整改 — Context拆分、Runtime拆分、异步章节加载、UI测试覆盖
Phase 1: Context 拆分
- 新增 RDEPUBReaderState/RDEPUBReaderEnvironment/RDEPUBReaderServices
- RDEPUBReaderContext 改为过渡门面,代理到 State/Environment/Services

Phase 2: Runtime 拆分
- 新增 RDEPUBPresentationRuntime 处理分页状态管理
- 新增 RDEPUBChapterWarmupOrchestrator 处理章节预热与加载编排
- RDEPUBReaderRuntime 从 1277 行收缩,公共 API 转发到新 facade

Phase 0.5: 性能优化
- prepareOnDemandChapter 支持异步模式(allowSynchronousLoad: false)
- extendPartialBookPageMapIfNeeded 改为 DispatchGroup 并发加载
- RDEPUBChapterOffsetMap.cfiMap 加 NSLock 保护数据竞争
- CFI Map 构建延迟到后台队列(scheduleDeferredCFIMapBuildIfNeeded)
- RDEPUBTextPageRenderView 引入静态位图缓存
- RDEPUBTextContentView 新增 loadingSpinner 占位页

Phase 3: 状态机
- 新增 RDEPUBNavigationStateMachine(含 DEBUG 合法转换校验)
- 新增 RDEPUBPaginationState 记录分页来源

Review 修复
- makeSummary 重复方法合并
- ensureNavigationTargetAvailable 同步路径加注释标记

UI 测试
- 新增 AsyncChapterLoadingTests(20 个测试,覆盖全部架构整改场景)
- 跨章节翻页、延迟 CFI、状态机、内存警告、预加载、位置恢复等
2026-06-23 08:17:08 +08:00

392 lines
12 KiB
Swift

import UIKit
final class RDEPUBReaderContext {
private let activityLock = NSLock()
private var lastUserNavigationTimestamp: CFAbsoluteTime = 0
weak var controller: RDEPUBReaderController?
weak var readerView: RDReaderView?
let state: RDEPUBReaderState
let environment: RDEPUBReaderEnvironment
let services: RDEPUBReaderServices
var dependencies: RDEPUBReaderDependencies {
get { services.dependencies }
set {
services.dependencies = newValue
environment.displayEnvironment = newValue.environment
}
}
var runtime: RDEPUBReaderRuntime? {
controller?.runtime
}
var parser: RDEPUBParser? {
get { state.parser }
set { state.parser = newValue }
}
var publication: RDEPUBPublication? {
get { state.publication }
set { state.publication = newValue }
}
var readingSession: RDEPUBReadingSession? {
get { state.readingSession }
set { state.readingSession = newValue }
}
var textBook: RDEPUBTextBook? {
get { state.textBook }
set { state.textBook = newValue }
}
var bookPageMap: RDEPUBBookPageMap? {
get { state.bookPageMap }
set { state.bookPageMap = newValue }
}
var activeBookmarks: [RDEPUBBookmark] {
get { state.activeBookmarks }
set { state.activeBookmarks = newValue }
}
var activeHighlights: [RDEPUBHighlight] {
get { state.activeHighlights }
set { state.activeHighlights = newValue }
}
var currentBookIdentifier: String? {
get { state.currentBookIdentifier }
set { state.currentBookIdentifier = newValue }
}
var paginationToken: UUID {
get { state.paginationToken }
set { state.paginationToken = newValue }
}
var paginator: RDEPUBPaginator? {
get { state.paginator }
set { state.paginator = newValue }
}
var searchState: RDEPUBSearchState? {
get { state.searchState }
set { state.searchState = newValue }
}
var pendingFullPageMap: RDEPUBBookPageMap? {
get { state.pendingFullPageMap }
set { state.pendingFullPageMap = newValue }
}
var lastTextPaginationPageSize: CGSize? {
get { state.lastTextPaginationPageSize }
set { state.lastTextPaginationPageSize = newValue }
}
var lastMetadataParseWallClockMs: Int {
get { state.lastMetadataParseWallClockMs }
set { state.lastMetadataParseWallClockMs = newValue }
}
var lastMetadataParseConcurrency: Int {
get { state.lastMetadataParseConcurrency }
set { state.lastMetadataParseConcurrency = newValue }
}
var currentSelection: RDEPUBSelection? {
get { state.currentSelection }
set { state.currentSelection = newValue }
}
var selectionState: RDEPUBSelectionState {
get { state.selectionState }
set { state.selectionState = newValue }
}
var configuration: RDEPUBReaderConfiguration = .default
var persistence: RDEPUBReaderPersistence?
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool {
get { state.isRepaginating }
set { state.isRepaginating = newValue }
}
var didStartInitialLoad: Bool {
get { state.didStartInitialLoad }
set { state.didStartInitialLoad = newValue }
}
var isExternalTextBook: Bool {
get { state.isExternalTextBook }
set { state.isExternalTextBook = newValue }
}
var textFileURL: URL? {
get { state.textFileURL }
set { state.textFileURL = newValue }
}
var textBookCache: RDEPUBTextBookCache { state.textBookCache }
init(controller: RDEPUBReaderController) {
self.controller = controller
self.readerView = controller.readerView
let state = RDEPUBReaderState()
self.state = state
self.environment = RDEPUBReaderEnvironment(
controller: controller,
readerView: controller.readerView,
displayEnvironment: RDEPUBUIScreenEnvironment()
)
self.services = RDEPUBReaderServices(dependencies: .live)
}
func currentLayoutContext() -> RDEPUBNavigatorLayoutContext {
environment.currentLayoutContext(configuration: configuration)
}
func currentPreferences() -> RDEPUBPreferences {
configuration.makePreferences()
}
func currentTextPageSize() -> CGSize {
if Thread.isMainThread {
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
}
}
let viewportSize = currentLayoutContext().viewportSize
if viewportSize.width > 0, viewportSize.height > 0 {
return viewportSize
}
} else if let lastTextPaginationPageSize,
lastTextPaginationPageSize.width > 0,
lastTextPaginationPageSize.height > 0 {
return lastTextPaginationPageSize
} else {
let mainThreadSize = DispatchQueue.main.sync { [weak self] in
self?.currentTextPageSize() ?? .zero
}
if mainThreadSize.width > 0, mainThreadSize.height > 0 {
return mainThreadSize
}
}
return environment.fallbackViewportSize
}
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
environment.currentTextRenderStyle(configuration: configuration)
}
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
services.resolvedTextRenderer(configuration: configuration)
}
var activePages: [EPUBPage] {
state.activePages
}
var activeChapters: [EPUBChapterInfo] {
state.activeChapters
}
var currentBrightness: CGFloat {
get { environment.currentBrightness }
set { environment.currentBrightness = newValue }
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
state.replaceActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
state.clearActiveSnapshot()
}
func makeParser() -> RDEPUBParser {
services.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
services.makePaginator()
}
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
services.makeTextBookBuilder(
configuration: configuration,
cache: textBookCache,
layoutConfig: layoutConfig
)
}
func makeChapterSummaryDiskCache() -> RDEPUBChapterSummaryDiskCache {
services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
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 chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: contentHash,
renderSignature: currentRenderSignature()
)
}
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
chapterCacheKey(
forSpineIndex: spineIndex,
precomputedContentHash: precomputedContentHash,
renderSignature: currentRenderSignature()
)
}
func chapterCacheKey(
forSpineIndex spineIndex: Int,
precomputedContentHash: String,
renderSignature: String
) -> RDEPUBChapterCacheKey {
RDEPUBChapterCacheKey(
bookID: currentBookIdentifier ?? "",
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: precomputedContentHash
)
}
func currentRenderSignature() -> String {
let style = currentTextRenderStyle()
let pageSize = currentTextPageSize()
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
return [
style.font.fontName,
"\(style.font.pointSize)",
"\(configuration.lineHeightMultiple)",
"\(style.lineSpacing)",
layoutConfig.cacheSignature,
"\(RDEPUBChapterSummary.currentSchemaVersion)"
].joined(separator: "|")
}
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 {
services.makePlainTextBookBuilder(
configuration: configuration,
layoutConfig: 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
}
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()
}
}