feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
+33
-111
@@ -1,17 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
|
||||
/// - 文本大书优先恢复分页摘要并切换到按需加载
|
||||
/// - 重新分页时保持当前阅读位置
|
||||
/// - 刷新可见内容并保持位置
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
private final class MetadataParseState {
|
||||
|
||||
var summariesBySpineIndex: [Int: RDEPUBChapterSummary]
|
||||
|
||||
var totalResolvedCount: Int
|
||||
|
||||
var lastAppliedCount: Int
|
||||
|
||||
init(
|
||||
@@ -26,10 +22,13 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
private final class MetadataParseCancellationController {
|
||||
|
||||
let token: UUID
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
private weak var queue: OperationQueue?
|
||||
|
||||
private var cancelled = false
|
||||
|
||||
init(token: UUID) {
|
||||
@@ -66,18 +65,19 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
/// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。
|
||||
|
||||
static var pageMapRefreshInterval: Int = 32
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let metadataParseControlLock = NSLock()
|
||||
|
||||
private var activeMetadataParseCancellationController: MetadataParseCancellationController?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 对出版物执行分页:文本重排优先走摘要恢复/按需加载,Fixed Layout 直接生成快照,Web 内容走 Paginator。
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let parser = context.parser,
|
||||
@@ -143,7 +143,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用文本图书模型:生成分页快照并完成分页流程。
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
@@ -160,7 +159,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照(Fixed Layout 或 Web 内容),并完成分页流程。
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@@ -179,7 +177,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 分页完成后的收尾:刷新视图、恢复阅读位置、处理待定视口变更。
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
@@ -197,7 +194,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置(优先使用待恢复位置,其次当前位置,最后持久化位置)。
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
guard context.publication != nil else { return }
|
||||
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
|
||||
@@ -206,7 +202,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 刷新可见内容并保持当前阅读位置不变。
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
|
||||
@@ -216,7 +211,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重建外部纯文本图书(布局变更后重新排版)。
|
||||
func rebuildExternalTextBook() {
|
||||
guard let controller = context.controller,
|
||||
let textFileURL = controller.textFileURL else { return }
|
||||
@@ -245,15 +239,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
DispatchQueue.global(qos: .utility).async { [weak controller] in
|
||||
guard controller != nil else { return }
|
||||
guard context.controller != nil else { return }
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
context.runtime?.applyBookPageMap(restoredPageMap, restoreLocation: restoreLocation)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prioritizedCandidates = self.prioritizedBuildableSpineIndices(
|
||||
publication: publication,
|
||||
readingSession: readingSession,
|
||||
@@ -284,19 +269,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
let quickWindowChapters = try RDEPUBBackgroundTrace.measure(
|
||||
"QuickOpen",
|
||||
"loadInitialRuntimeChapters anchorSpine=\(runtimeChapter.spineIndex)"
|
||||
) {
|
||||
try self.loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
publication: publication,
|
||||
runtime: runtime
|
||||
)
|
||||
}
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"QuickOpen",
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) quickWindow=\(quickWindowChapters.map { $0.spineIndex }) pages=\(quickWindowChapters.reduce(0) { $0 + $1.pages.count })"
|
||||
"ready anchorSpine=\(runtimeChapter.spineIndex) pages=\(runtimeChapter.pages.count)"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
@@ -307,8 +282,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
totalSpineCount: publication.spine.count,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
let partialMap = self.makePartialPageMap(from: quickWindowChapters)
|
||||
let partialMap = self.makePartialPageMap(from: [runtimeChapter])
|
||||
runtime.applyBookPageMap(partialMap, restoreLocation: restoreLocation)
|
||||
runtime.prefetchForwardChaptersAfterInitialOpen(
|
||||
anchorSpineIndex: runtimeChapter.spineIndex,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
self.paginateMetadataOnly(token: token, restoreLocation: restoreLocation)
|
||||
}
|
||||
} catch {
|
||||
@@ -340,68 +319,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
throw lastError ?? RDEPUBParserError.emptySpine
|
||||
}
|
||||
|
||||
private func loadInitialRuntimeChapters(
|
||||
anchorSpineIndex: Int,
|
||||
publication: RDEPUBPublication,
|
||||
runtime: RDEPUBReaderRuntime
|
||||
) throws -> [RDEPUBRuntimeChapter] {
|
||||
let windowSpineIndices = initialWindowSpineIndices(
|
||||
around: anchorSpineIndex,
|
||||
in: publication,
|
||||
maxChapterCount: context.configuration.onDemandChapterWindowSize
|
||||
)
|
||||
var chapters: [RDEPUBRuntimeChapter] = []
|
||||
for spineIndex in windowSpineIndices {
|
||||
do {
|
||||
let chapter = try runtime.chapterLoader.loadChapterSynchronouslyForMigration(
|
||||
spineIndex: spineIndex,
|
||||
store: runtime.chapterRuntimeStore
|
||||
)
|
||||
chapters.append(chapter)
|
||||
} catch {
|
||||
if spineIndex == anchorSpineIndex {
|
||||
throw error
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("QuickOpen", "skip adjacent spine=\(spineIndex) reason=\(error)")
|
||||
}
|
||||
}
|
||||
return chapters
|
||||
}
|
||||
|
||||
private func initialWindowSpineIndices(
|
||||
around anchorSpineIndex: Int,
|
||||
in publication: RDEPUBPublication,
|
||||
maxChapterCount: Int = 3
|
||||
) -> [Int] {
|
||||
let normalizedMaxChapterCount = RDEPUBReaderConfiguration.normalizedChapterWindowSize(maxChapterCount)
|
||||
let buildableIndices = allBuildableSpineIndices(in: publication)
|
||||
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
|
||||
return [anchorSpineIndex]
|
||||
}
|
||||
|
||||
var selected = [anchorSpineIndex]
|
||||
var nextPosition = anchorPosition + 1
|
||||
var previousPosition = anchorPosition - 1
|
||||
|
||||
while selected.count < normalizedMaxChapterCount,
|
||||
nextPosition < buildableIndices.count || previousPosition >= 0 {
|
||||
if nextPosition < buildableIndices.count {
|
||||
selected.append(buildableIndices[nextPosition])
|
||||
nextPosition += 1
|
||||
if selected.count == normalizedMaxChapterCount {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if previousPosition >= 0 {
|
||||
selected.insert(buildableIndices[previousPosition], at: 0)
|
||||
previousPosition -= 1
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
private func makePartialPageMap(from chapters: [RDEPUBRuntimeChapter]) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
for chapter in chapters {
|
||||
@@ -450,14 +367,9 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
// MARK: - 元数据专用解析(Phase 0)
|
||||
|
||||
/// 失败重试配置
|
||||
private static let maxRetryCount = 3
|
||||
private static let retryDelays: [TimeInterval] = [0.5, 2.0, 8.0]
|
||||
|
||||
/// 后台遍历所有章节,只提取轻量元数据(pageCount、pageRanges、fragmentOffsets),
|
||||
/// 写入磁盘摘要缓存,不累积 RDEPUBTextBook。
|
||||
func paginateMetadataOnly(token: UUID, restoreLocation: RDEPUBLocation?) {
|
||||
let context = self.context
|
||||
guard let parser = context.parser,
|
||||
@@ -481,7 +393,20 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
!cancellationController.isCancelled,
|
||||
context.paginationToken == token else { return }
|
||||
|
||||
// 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256
|
||||
if let restoredPageMap = self.restoreBookPageMapIfPossible(publication: publication) {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"full cache restore hit chapters=\(restoredPageMap.totalChapters) pages=\(restoredPageMap.totalPages)"
|
||||
)
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil,
|
||||
!cancellationController.isCancelled else { return }
|
||||
context.runtime?.refreshBookPageMapInPlace(restoredPageMap)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
@@ -542,7 +467,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 使用优先级排序获取未缓存的 spineIndex
|
||||
let prioritizedSpineIndices: [Int]
|
||||
if let priorityManager = context.runtime?.backgroundPriorityManager {
|
||||
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
||||
@@ -639,6 +563,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: chapter.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
@@ -676,7 +601,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// 锁内只做写入和计数,快照数据后锁外构建 pageMap
|
||||
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||
resultLock.lock()
|
||||
parseState.summariesBySpineIndex[spineIndex] = renderResult
|
||||
@@ -714,7 +638,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
timingLock.unlock()
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
|
||||
// 添加到重试队列(带退避延迟)
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: 0,
|
||||
@@ -779,7 +702,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
||||
)
|
||||
|
||||
// 存储到 BackgroundCoverageStore
|
||||
if let coverageStore = context.runtime?.backgroundCoverageStore {
|
||||
let resolvedSpineIndices = Set(parseState.summariesBySpineIndex.keys)
|
||||
let lowerSpine = resolvedSpineIndices.min() ?? 0
|
||||
@@ -806,7 +728,6 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 调度失败章节的重试
|
||||
private func scheduleRetry(
|
||||
spineIndex: Int,
|
||||
retryCount: Int,
|
||||
@@ -878,6 +799,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
cfiMap: chapter.cfiMap,
|
||||
renderSignature: cacheKey.renderSignature,
|
||||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||||
chapterContentHash: cacheKey.chapterContentHash,
|
||||
@@ -921,7 +843,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
"MetadataParse",
|
||||
"retry failed for spine=\(spineIndex) attempt=\(retryCount + 1) error=\(error)"
|
||||
)
|
||||
// 继续重试
|
||||
|
||||
self.scheduleRetry(
|
||||
spineIndex: spineIndex,
|
||||
retryCount: retryCount + 1,
|
||||
|
||||
Reference in New Issue
Block a user