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、状态机、内存警告、预加载、位置恢复等
This commit is contained in:
shen
2026-06-23 08:17:08 +08:00
parent c65c190b71
commit 7de661eb54
29 changed files with 3297 additions and 658 deletions
@@ -10,54 +10,108 @@ final class RDEPUBReaderContext {
weak var readerView: RDReaderView?
var dependencies: RDEPUBReaderDependencies = .live
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?
var publication: RDEPUBPublication?
var readingSession: RDEPUBReadingSession?
var textBook: RDEPUBTextBook?
var bookPageMap: RDEPUBBookPageMap?
var activeBookmarks: [RDEPUBBookmark] = []
var activeHighlights: [RDEPUBHighlight] = []
var currentBookIdentifier: String?
var paginationToken = UUID()
var paginator: RDEPUBPaginator?
var searchState: RDEPUBSearchState?
var pendingFullPageMap: RDEPUBBookPageMap?
var lastTextPaginationPageSize: CGSize?
var lastMetadataParseWallClockMs: Int = 0
var lastMetadataParseConcurrency: Int = 0
var currentSelection: RDEPUBSelection? {
get { selectionState.selection }
set {
if let newValue, !newValue.isEmpty {
selectionState = .selected(newValue)
} else {
selectionState = .idle
}
}
var parser: RDEPUBParser? {
get { state.parser }
set { state.parser = newValue }
}
var selectionState: RDEPUBSelectionState = .idle
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
@@ -65,32 +119,43 @@ final class RDEPUBReaderContext {
var epubURL: URL = URL(string: "about:blank")!
var isRepaginating: Bool = false
var isRepaginating: Bool {
get { state.isRepaginating }
set { state.isRepaginating = newValue }
}
var didStartInitialLoad: Bool = false
var didStartInitialLoad: Bool {
get { state.didStartInitialLoad }
set { state.didStartInitialLoad = newValue }
}
var isExternalTextBook: Bool = false
var isExternalTextBook: Bool {
get { state.isExternalTextBook }
set { state.isExternalTextBook = newValue }
}
var textFileURL: URL?
var textFileURL: URL? {
get { state.textFileURL }
set { state.textFileURL = newValue }
}
var textBookCache = RDEPUBTextBookCache()
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 {
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
)
environment.currentLayoutContext(configuration: configuration)
}
func currentPreferences() -> RDEPUBPreferences {
@@ -122,82 +187,60 @@ final class RDEPUBReaderContext {
return mainThreadSize
}
}
return dependencies.environment.fallbackViewportSize
return environment.fallbackViewportSize
}
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
)
environment.currentTextRenderStyle(configuration: configuration)
}
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
)
environment.currentTextLayoutConfig(configuration: configuration, pageSize: pageSize)
}
func resolvedTextRenderer() -> RDEPUBTextRenderer {
dependencies.makeTextRenderer(configuration.textRenderingEngine)
services.resolvedTextRenderer(configuration: configuration)
}
var activePages: [EPUBPage] {
readingSession?.activePages ?? []
state.activePages
}
var activeChapters: [EPUBChapterInfo] {
readingSession?.activeChapters ?? []
state.activeChapters
}
var currentBrightness: CGFloat {
get { dependencies.environment.currentBrightness }
set { dependencies.environment.currentBrightness = newValue }
get { environment.currentBrightness }
set { environment.currentBrightness = newValue }
}
func replaceActiveSnapshot(_ snapshot: RDEPUBReadingSession.PaginationSnapshot) {
readingSession?.setActiveSnapshot(snapshot)
state.replaceActiveSnapshot(snapshot)
}
func clearActiveSnapshot() {
readingSession?.resetRuntimeState()
state.clearActiveSnapshot()
}
func makeParser() -> RDEPUBParser {
dependencies.makeParser()
services.makeParser()
}
func makePaginator() -> RDEPUBPaginator {
dependencies.makePaginator()
services.makePaginator()
}
func makeTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder {
dependencies.makeTextBookBuilder(resolvedTextRenderer(), textBookCache, layoutConfig)
services.makeTextBookBuilder(
configuration: configuration,
cache: textBookCache,
layoutConfig: 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)
services.makeChapterSummaryDiskCache(bookIdentifier: currentBookIdentifier)
}
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
@@ -272,7 +315,10 @@ final class RDEPUBReaderContext {
}
func makePlainTextBookBuilder(layoutConfig: RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder {
dependencies.makePlainTextBookBuilder(resolvedTextRenderer(), layoutConfig)
services.makePlainTextBookBuilder(
configuration: configuration,
layoutConfig: layoutConfig
)
}
func currentVisibleLocation() -> RDEPUBLocation? {