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:
+90
-26
@@ -6,6 +6,8 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
var onDeferredCFIMapReady: ((Int) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
@@ -32,6 +34,11 @@ final class RDEPUBChapterLoader {
|
||||
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
||||
store: store
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
completion(.success(cached))
|
||||
}
|
||||
@@ -83,6 +90,11 @@ final class RDEPUBChapterLoader {
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pc, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
|
||||
switch priority {
|
||||
case .navigation:
|
||||
@@ -129,6 +141,13 @@ final class RDEPUBChapterLoader {
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
if let cached = store?.chapterData(for: spineIndex) {
|
||||
if let store {
|
||||
scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: cached,
|
||||
cacheKey: makeCacheKey(spineIndex: spineIndex),
|
||||
store: store
|
||||
)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
@@ -170,6 +189,11 @@ final class RDEPUBChapterLoader {
|
||||
renderSignature: cacheKey.renderSignature
|
||||
)
|
||||
store.insertPageCount(pageCount, for: cacheKey)
|
||||
self.scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for: chapter,
|
||||
cacheKey: cacheKey,
|
||||
store: store
|
||||
)
|
||||
return .success(chapter)
|
||||
}
|
||||
}
|
||||
@@ -300,13 +324,7 @@ final class RDEPUBChapterLoader {
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset },
|
||||
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: typesetString.string
|
||||
),
|
||||
cfiMap: diskSummary?.cfiMap,
|
||||
chapterText: typesetString.string
|
||||
)
|
||||
|
||||
@@ -459,30 +477,13 @@ final class RDEPUBChapterLoader {
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset },
|
||||
cfiMap: chapter.cfiMap ?? makeCFIMap(
|
||||
href: chapter.href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
rawHTML: context.parser?.htmlString(forRelativePath: chapter.href),
|
||||
chapterText: chapter.attributedContent.string
|
||||
),
|
||||
cfiMap: chapter.cfiMap,
|
||||
chapterText: chapter.attributedContent.string
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
|
||||
let cacheKey = makeCacheKey(spineIndex: spineIndex)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: pageRanges.map { .init(location: $0.location, length: $0.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: offsetMap.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||||
)
|
||||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||||
summaryDiskCache?.write(summary: makeSummary(for: chapter.pages, fragmentOffsets: chapter.fragmentOffsets, offsetMap: offsetMap, cacheKey: cacheKey), for: cacheKey)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
@@ -522,6 +523,69 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
private func scheduleDeferredCFIMapBuildIfNeeded(
|
||||
for chapter: RDEPUBRuntimeChapter,
|
||||
cacheKey: RDEPUBChapterCacheKey,
|
||||
store: RDEPUBChapterRuntimeStore
|
||||
) {
|
||||
guard chapter.chapterOffsetMap.cfiMap == nil,
|
||||
store.beginBuildingCFIMap(for: chapter.spineIndex) else {
|
||||
return
|
||||
}
|
||||
|
||||
let spineIndex = chapter.spineIndex
|
||||
let href = chapter.href
|
||||
let fragmentOffsets = chapter.chapterOffsetMap.fragmentOffsets
|
||||
let chapterText = chapter.chapterOffsetMap.chapterText
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
defer { store.endBuildingCFIMap(for: spineIndex) }
|
||||
guard let rawHTML = self.context.parser?.htmlString(forRelativePath: href),
|
||||
let chapterText else {
|
||||
return
|
||||
}
|
||||
|
||||
let cfiMap = self.makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText
|
||||
)
|
||||
chapter.updateCFIMap(cfiMap)
|
||||
self.summaryDiskCache?.write(
|
||||
summary: self.makeSummary(
|
||||
for: chapter.pages,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets,
|
||||
offsetMap: chapter.chapterOffsetMap,
|
||||
cacheKey: cacheKey
|
||||
),
|
||||
for: cacheKey
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
self.onDeferredCFIMapReady?(spineIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeSummary(
|
||||
for pages: [RDEPUBTextPage],
|
||||
fragmentOffsets: [String: Int],
|
||||
offsetMap: RDEPUBChapterOffsetMap,
|
||||
cacheKey: RDEPUBChapterCacheKey
|
||||
) -> RDEPUBChapterSummary {
|
||||
RDEPUBChapterSummary(
|
||||
pageRanges: pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: pages.count,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
cfiMap: offsetMap.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
pageMetadataList: pages.map { .from($0.metadata) }
|
||||
)
|
||||
}
|
||||
|
||||
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
||||
guard let parser = context.parser,
|
||||
let publication = context.publication else { return "" }
|
||||
|
||||
Reference in New Issue
Block a user