feat: EPUB阅读器搜索、注释、CFI模块及大书远距跳转优化
- 实现EPUB阅读器搜索功能及选中注释功能 - 优化CFI模块,修复代码审查发现的11个问题 - 实现大书远距目录跳转与后台补全优化方案 - 优化设置面板与章节运行时联动 - 重构及大量改进优化
This commit is contained in:
+11
@@ -1,22 +1,30 @@
|
||||
import Foundation
|
||||
|
||||
enum RDEPUBBackgroundTrace {
|
||||
|
||||
static func log(_ scope: String, _ message: String) {
|
||||
|
||||
let threadRole = Thread.isMainThread ? "main" : "bg"
|
||||
|
||||
let threadName = resolvedThreadName()
|
||||
|
||||
let queueLabel = resolvedQueueLabel()
|
||||
print("[EPUB][\(scope)][\(threadRole)][queue=\(queueLabel)][thread=\(threadName)] \(message)")
|
||||
}
|
||||
|
||||
static func measure<T>(_ scope: String, _ message: String, work: () throws -> T) rethrows -> T {
|
||||
|
||||
let startedAt = CFAbsoluteTimeGetCurrent()
|
||||
log(scope, "START \(message)")
|
||||
do {
|
||||
|
||||
let result = try work()
|
||||
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "END \(message) elapsedMs=\(elapsedMs)")
|
||||
return result
|
||||
} catch {
|
||||
|
||||
let elapsedMs = Int((CFAbsoluteTimeGetCurrent() - startedAt) * 1000)
|
||||
log(scope, "FAIL \(message) elapsedMs=\(elapsedMs) error=\(error)")
|
||||
throw error
|
||||
@@ -24,12 +32,15 @@ enum RDEPUBBackgroundTrace {
|
||||
}
|
||||
|
||||
private static func resolvedThreadName() -> String {
|
||||
|
||||
if let name = Thread.current.name, !name.isEmpty {
|
||||
return name
|
||||
}
|
||||
|
||||
if Thread.isMainThread {
|
||||
return "main"
|
||||
}
|
||||
|
||||
return String(describing: Unmanaged.passUnretained(Thread.current).toOpaque())
|
||||
}
|
||||
|
||||
|
||||
+13
-27
@@ -1,29 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// BookPageMap 中的单个条目,记录一个章节的轻量元数据。
|
||||
/// 不持有 NSAttributedString,每条约 100 字节。
|
||||
struct RDEPUBBookPageMapEntry {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let title: String
|
||||
/// 该章节的页数
|
||||
|
||||
let pageCount: Int
|
||||
/// 该章节在全书中的绝对起始页码(从 0 开始)
|
||||
|
||||
let absolutePageStart: Int
|
||||
/// fragment ID → 字符偏移量映射
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
}
|
||||
|
||||
/// 全书轻量页码映射:仅存储每章的页数和起始位置,
|
||||
/// 内存成本约 100 字节/章,1000 章 ≈ 100KB。
|
||||
///
|
||||
/// 提供 spineIndex ↔ 绝对页码的双向查询,
|
||||
/// 用于进度条、目录跳转、位置恢复等不依赖内容的场景。
|
||||
struct RDEPUBBookPageMap {
|
||||
|
||||
let entries: [RDEPUBBookPageMapEntry]
|
||||
/// 按 spineIndex 索引的查找表
|
||||
private let indexBySpine: [Int: Int] // spineIndex -> entries 数组下标
|
||||
/// 全书总页数
|
||||
|
||||
private let indexBySpine: [Int: Int]
|
||||
|
||||
let totalPages: Int
|
||||
|
||||
init(entries: [RDEPUBBookPageMapEntry]) {
|
||||
@@ -38,9 +35,6 @@ struct RDEPUBBookPageMap {
|
||||
|
||||
static let empty = RDEPUBBookPageMap(entries: [])
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// spineIndex + 本地页码 → 全书绝对页码
|
||||
func absolutePageIndex(spineIndex: Int, localPageIndex: Int) -> Int? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
let entry = entries[idx]
|
||||
@@ -48,10 +42,9 @@ struct RDEPUBBookPageMap {
|
||||
return entry.absolutePageStart + localPageIndex
|
||||
}
|
||||
|
||||
/// 全书绝对页码 → spineIndex
|
||||
func spineIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard absolutePage >= 0, absolutePage < totalPages else { return nil }
|
||||
// 二分查找:entries 按 absolutePageStart 有序
|
||||
|
||||
var lo = 0, hi = entries.count
|
||||
while lo < hi {
|
||||
let mid = lo + (hi - lo) / 2
|
||||
@@ -65,7 +58,6 @@ struct RDEPUBBookPageMap {
|
||||
return entries[lo - 1].spineIndex
|
||||
}
|
||||
|
||||
/// 全书绝对页码 → 本地页码(章节内偏移)
|
||||
func localPageIndex(forAbsolutePage absolutePage: Int) -> Int? {
|
||||
guard let si = spineIndex(forAbsolutePage: absolutePage),
|
||||
let idx = indexBySpine[si] else { return nil }
|
||||
@@ -75,29 +67,23 @@ struct RDEPUBBookPageMap {
|
||||
return local
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的条目
|
||||
func entry(forSpineIndex spineIndex: Int) -> RDEPUBBookPageMapEntry? {
|
||||
guard let idx = indexBySpine[spineIndex] else { return nil }
|
||||
return entries[idx]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 在 entries 中的章节序号。
|
||||
func chapterIndex(forSpineIndex spineIndex: Int) -> Int? {
|
||||
indexBySpine[spineIndex]
|
||||
}
|
||||
|
||||
/// 获取指定 spineIndex 的页数
|
||||
func pageCount(forSpineIndex spineIndex: Int) -> Int? {
|
||||
entry(forSpineIndex: spineIndex)?.pageCount
|
||||
}
|
||||
|
||||
/// 全书总章节数
|
||||
var totalChapters: Int { entries.count }
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// Builder:从各章的 pageCount 逐步构建 BookPageMap
|
||||
struct Builder {
|
||||
|
||||
private var items: [(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int])] = []
|
||||
|
||||
mutating func add(spineIndex: Int, href: String, title: String, pageCount: Int, fragmentOffsets: [String: Int]) {
|
||||
@@ -105,7 +91,7 @@ struct RDEPUBBookPageMap {
|
||||
}
|
||||
|
||||
func build() -> RDEPUBBookPageMap {
|
||||
// 按 spineIndex 排序
|
||||
|
||||
let sorted = items.sorted { $0.spineIndex < $1.spineIndex }
|
||||
var entries: [RDEPUBBookPageMapEntry] = []
|
||||
var absolutePageStart = 0
|
||||
|
||||
+4
@@ -1,8 +1,12 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterCacheKey: Hashable {
|
||||
|
||||
let bookID: String
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let chapterContentHash: String
|
||||
}
|
||||
+2
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterDataCache {
|
||||
|
||||
private var storage: [Int: RDEPUBRuntimeChapter] = [:]
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(_ spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
|
||||
+120
-59
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterLoader {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -12,24 +14,22 @@ final class RDEPUBChapterLoader {
|
||||
summaryDiskCache = cache
|
||||
}
|
||||
|
||||
// MARK: - 请求优先级
|
||||
|
||||
enum LoadPriority {
|
||||
case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列
|
||||
case preview // 设置预览:仅构建当前章并回调,不参与导航目标消费
|
||||
case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照
|
||||
|
||||
case navigation
|
||||
|
||||
case preview
|
||||
|
||||
case prefetch
|
||||
}
|
||||
|
||||
// MARK: - 主入口:加载单个章节
|
||||
|
||||
/// 在 chapterLoadQueue 上构建单章,完成后回调到主线程
|
||||
func loadChapter(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore,
|
||||
priority: LoadPriority = .navigation,
|
||||
completion: @escaping (Result<RDEPUBRuntimeChapter, Error>) -> Void
|
||||
) {
|
||||
// 1. 查内存缓存(统一回主线程,保证 completion 线程语义一致)
|
||||
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "cache hit spine=\(spineIndex) priority=\(priority)")
|
||||
DispatchQueue.main.async {
|
||||
@@ -38,14 +38,12 @@ final class RDEPUBChapterLoader {
|
||||
return
|
||||
}
|
||||
|
||||
// 2-3. 构建缓存键 + 查内存级 pageCountCache 统一移到串行队列执行,
|
||||
// 避免 contentHashForSpineIndex 的 SHA256 + 磁盘 I/O 阻塞主线程
|
||||
store.markBuilding(true)
|
||||
|
||||
store.chapterLoadQueue.async {
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "queue start spine=\(spineIndex) priority=\(priority)")
|
||||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||||
|
||||
// 仅当内存级 pageCountCache 未命中时才查磁盘摘要
|
||||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||||
let diskSummary: RDEPUBChapterSummary?
|
||||
if precomputedPageRanges == nil {
|
||||
@@ -63,6 +61,7 @@ final class RDEPUBChapterLoader {
|
||||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||||
|
||||
do {
|
||||
|
||||
let chapter = try RDEPUBBackgroundTrace.measure(
|
||||
"ChapterLoader",
|
||||
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||||
@@ -75,7 +74,6 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
||||
|
||||
// 5. 回填缓存
|
||||
store.insertChapter(chapter)
|
||||
let pc = RDEPUBRuntimePageCount(
|
||||
cacheKey: cacheKey,
|
||||
@@ -86,13 +84,12 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
store.insertPageCount(pc, for: cacheKey)
|
||||
|
||||
// 6. 按优先级处理完成逻辑
|
||||
switch priority {
|
||||
case .navigation:
|
||||
// 前台导航:检查是否有更新的导航目标(§14.4 取消语义)
|
||||
|
||||
let nextTarget = store.consumeNavigationTarget()
|
||||
if let target = nextTarget, target != spineIndex {
|
||||
// 当前结果不再是用户目标,丢弃,转而加载新目标
|
||||
|
||||
store.markBuilding(false)
|
||||
self.loadChapter(spineIndex: target, store: store, priority: .navigation, completion: completion)
|
||||
return
|
||||
@@ -109,8 +106,7 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
|
||||
case .prefetch:
|
||||
// 后台预取:仅回填缓存,标记预取目标完成
|
||||
// 不触发跳章,不检查导航目标队列
|
||||
|
||||
store.removePrefetchTarget(spineIndex)
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
@@ -118,6 +114,7 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||||
store.markBuilding(false)
|
||||
DispatchQueue.main.async {
|
||||
@@ -127,14 +124,6 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 同步加载入口(仅供 legacy 位置迁移使用)
|
||||
|
||||
/// 约束:
|
||||
/// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义
|
||||
/// - 不允许恢复整书 RDEPUBTextBook
|
||||
/// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用
|
||||
/// - 调用前必须执行 store.assertNotOnChapterLoadQueue()
|
||||
/// - 只在首次迁移且目标章未命中缓存时使用
|
||||
func loadChapterSynchronouslyForMigration(
|
||||
spineIndex: Int,
|
||||
store: RDEPUBChapterRuntimeStore?
|
||||
@@ -193,8 +182,6 @@ final class RDEPUBChapterLoader {
|
||||
return try result!.get()
|
||||
}
|
||||
|
||||
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
||||
|
||||
private func buildChapter(
|
||||
spineIndex: Int,
|
||||
availablePageRanges: [NSRange]?,
|
||||
@@ -210,7 +197,7 @@ final class RDEPUBChapterLoader {
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
|
||||
if let pageRanges = availablePageRanges {
|
||||
// ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ----
|
||||
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
|
||||
return try buildChapterFromCachedPageRanges(
|
||||
spineIndex: spineIndex,
|
||||
@@ -224,7 +211,6 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// ---- 完整路径:无缓存,走全量渲染 + 分页 ----
|
||||
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
|
||||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||||
guard let result = try builder.buildChapter(
|
||||
@@ -245,8 +231,6 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页
|
||||
|
||||
private func buildChapterFromCachedPageRanges(
|
||||
spineIndex: Int,
|
||||
pageRanges: [NSRange],
|
||||
@@ -259,14 +243,14 @@ final class RDEPUBChapterLoader {
|
||||
) throws -> RDEPUBRuntimeChapter {
|
||||
let spineItem = publication.spine[spineIndex]
|
||||
let href = spineItem.href
|
||||
let title = spineItem.title ?? ""
|
||||
let title = spineItem.title
|
||||
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||||
let rawHTML = try requireHTMLString(parser, href: href)
|
||||
|
||||
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
||||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||||
href: href,
|
||||
title: title,
|
||||
rawHTML: try requireHTMLString(parser, href: href),
|
||||
rawHTML: rawHTML,
|
||||
baseURL: baseURL,
|
||||
style: style,
|
||||
resourceResolver: publication.resourceResolver,
|
||||
@@ -281,14 +265,24 @@ final class RDEPUBChapterLoader {
|
||||
in: typesetString, style: style, layoutConfig: layoutConfig
|
||||
)
|
||||
|
||||
// 2. metadata 来源策略:
|
||||
// - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata
|
||||
// - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断
|
||||
let metadataSource = diskSummary?.pageMetadataList
|
||||
let sanitizedCachedRanges = sanitizedPageRanges(pageRanges, contentLength: typesetString.length)
|
||||
let effectivePageRanges: [NSRange]
|
||||
let metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]?
|
||||
|
||||
if sanitizedCachedRanges.count == pageRanges.count {
|
||||
effectivePageRanges = sanitizedCachedRanges
|
||||
metadataSource = diskSummary?.pageMetadataList
|
||||
} else {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"ChapterLoader",
|
||||
"缓存页范围失效,回退重分页 spine=\(spineIndex) cached=\(pageRanges.count) valid=\(sanitizedCachedRanges.count) textLength=\(typesetString.length)"
|
||||
)
|
||||
effectivePageRanges = typesetString.rd_paginatedFrames(size: pageSize, config: layoutConfig).map(\.contentRange)
|
||||
metadataSource = nil
|
||||
}
|
||||
|
||||
// 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页)
|
||||
let pages = buildPagesFromRanges(
|
||||
pageRanges: pageRanges,
|
||||
pageRanges: effectivePageRanges,
|
||||
typesetString: typesetString,
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
@@ -296,35 +290,39 @@ final class RDEPUBChapterLoader {
|
||||
metadataSource: metadataSource
|
||||
)
|
||||
|
||||
// 4. 构建 layouter(用于后续可能的重新分页场景)
|
||||
let layouter = RDEPUBTextLayouter(
|
||||
attributedString: typesetString,
|
||||
pageSize: pageSize,
|
||||
config: layoutConfig
|
||||
)
|
||||
|
||||
// 5. 构建 chapterOffsetMap
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
pageStartOffsets: pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset }
|
||||
pageEndOffsets: pages.map { $0.pageEndOffset },
|
||||
cfiMap: diskSummary?.cfiMap ?? makeCFIMap(
|
||||
href: href,
|
||||
spineIndex: spineIndex,
|
||||
fragmentOffsets: rendered.fragmentOffsets,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: typesetString.string
|
||||
),
|
||||
chapterText: typesetString.string
|
||||
)
|
||||
|
||||
return RDEPUBRuntimeChapter(
|
||||
spineIndex: spineIndex,
|
||||
href: href,
|
||||
title: title,
|
||||
sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存
|
||||
sourceAttributedString: nil,
|
||||
typesetAttributedString: typesetString,
|
||||
layouter: layouter,
|
||||
pageRanges: pageRanges,
|
||||
pageRanges: effectivePageRanges,
|
||||
pages: pages,
|
||||
chapterOffsetMap: offsetMap
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组
|
||||
|
||||
|
||||
private func buildPagesFromRanges(
|
||||
pageRanges: [NSRange],
|
||||
typesetString: NSAttributedString,
|
||||
@@ -338,10 +336,10 @@ final class RDEPUBChapterLoader {
|
||||
let pageContent = typesetString.attributedSubstring(from: range)
|
||||
let metadata: RDEPUBTextPageMetadata
|
||||
if let metaList = metadataSource, pageIndex < metaList.count {
|
||||
// 从摘要缓存恢复完整 metadata
|
||||
|
||||
metadata = metaList[pageIndex].toPageMetadata()
|
||||
} else {
|
||||
// 无缓存 metadata,从 attributedString 属性推断
|
||||
|
||||
metadata = inferPageMetadata(
|
||||
from: typesetString,
|
||||
range: range,
|
||||
@@ -366,7 +364,21 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 从 attributedString 的自定义属性推断页 metadata
|
||||
private func sanitizedPageRanges(_ pageRanges: [NSRange], contentLength: Int) -> [NSRange] {
|
||||
guard contentLength > 0 else { return [] }
|
||||
|
||||
return pageRanges.compactMap { range in
|
||||
guard range.location >= 0, range.location < contentLength else {
|
||||
return nil
|
||||
}
|
||||
let maxLength = contentLength - range.location
|
||||
let clampedLength = min(max(range.length, 0), maxLength)
|
||||
guard clampedLength > 0 else {
|
||||
return nil
|
||||
}
|
||||
return NSRange(location: range.location, length: clampedLength)
|
||||
}
|
||||
}
|
||||
|
||||
private func inferPageMetadata(
|
||||
from string: NSAttributedString,
|
||||
@@ -431,8 +443,6 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter
|
||||
|
||||
private func assembleRuntimeChapter(
|
||||
from chapter: RDEPUBTextChapter,
|
||||
spineIndex: Int,
|
||||
@@ -448,17 +458,25 @@ final class RDEPUBChapterLoader {
|
||||
let offsetMap = RDEPUBChapterOffsetMap(
|
||||
fragmentOffsets: chapter.fragmentOffsets,
|
||||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
|
||||
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
|
||||
),
|
||||
chapterText: chapter.attributedContent.string
|
||||
)
|
||||
|
||||
let pageRanges = chapter.pages.map { $0.contentRange }
|
||||
|
||||
// 回填磁盘摘要(P2 阶段生效)
|
||||
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,
|
||||
@@ -479,13 +497,10 @@ final class RDEPUBChapterLoader {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 缓存键
|
||||
|
||||
private func makeCacheKey(spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = context.currentTextRenderStyle()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: context.currentTextPageSize())
|
||||
|
||||
// renderSignature 必须覆盖 §8.2 定义的全部参数
|
||||
let lineHeightMultiple = context.configuration.lineHeightMultiple
|
||||
|
||||
let renderSignature = [
|
||||
@@ -521,11 +536,57 @@ final class RDEPUBChapterLoader {
|
||||
}
|
||||
return html
|
||||
}
|
||||
|
||||
private func makeCFIMap(
|
||||
href: String,
|
||||
spineIndex: Int,
|
||||
fragmentOffsets: [String: Int],
|
||||
rawHTML: String?,
|
||||
chapterText: String
|
||||
) -> RDEPUBCFIMap {
|
||||
if let rawHTML {
|
||||
return RDEPUBCFITextNodeMapBuilder.makeMap(
|
||||
href: href,
|
||||
rawHTML: rawHTML,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
let domPaths: [String: RDEPUBCFIPath] = [:]
|
||||
let markers = fragmentOffsets
|
||||
.sorted { $0.value < $1.value }
|
||||
.map { fragmentID, offset in
|
||||
let cfi = RDEPUBCFIGenerator.makeOffsetCFI(
|
||||
href: href,
|
||||
fileIndex: spineIndex,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
return RDEPUBCFIMarker(
|
||||
cfiPath: domPaths[fragmentID] ?? cfi.contentPath,
|
||||
chapterOffset: offset,
|
||||
fragmentID: fragmentID
|
||||
)
|
||||
}
|
||||
return RDEPUBCFIMap(
|
||||
href: href,
|
||||
markers: markers,
|
||||
recoveryMetadata: RDEPUBCFIRecoveryMetadata(
|
||||
domFingerprint: "",
|
||||
normalizedTextChecksum: RDEPUBCFITextNodeMapBuilder.normalizedText(from: chapterText).sha256Hex,
|
||||
fragmentPathMap: domPaths
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum RDEPUBChapterLoadError: LocalizedError {
|
||||
|
||||
case missingParser
|
||||
|
||||
case emptyChapter(spineIndex: Int)
|
||||
|
||||
case emptyChapterHref(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
|
||||
+5
-8
@@ -1,17 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// 章节级位置模型——单章内的精确定位
|
||||
/// 取代旧版 RDEPUBLocation 的全局 progression 方式
|
||||
public struct RDEPUBChapterLocation: Codable, Equatable {
|
||||
/// 章节在 spine 中的索引
|
||||
|
||||
public var spineIndex: Int
|
||||
/// 章内字符偏移(从章首算起,0-based)
|
||||
|
||||
public var chapterOffset: Int
|
||||
/// HTML fragment ID(如章节内锚点 #section1)
|
||||
|
||||
public var fragmentID: String?
|
||||
/// 章内 progression(可选,fragmentID 优先时为 nil)
|
||||
|
||||
public var progressionInChapter: Double?
|
||||
/// schema 版本:1=粗估降级, 2=精确值
|
||||
|
||||
public var schemaVersion: Int
|
||||
|
||||
public init(
|
||||
@@ -28,6 +26,5 @@ public struct RDEPUBChapterLocation: Codable, Equatable {
|
||||
self.schemaVersion = schemaVersion
|
||||
}
|
||||
|
||||
/// 粗估结果标记:schemaVersion == 1 表示 chapterOffset 由 progression 粗估得来
|
||||
var isFallbackEstimate: Bool { schemaVersion == 1 }
|
||||
}
|
||||
+22
-3
@@ -1,16 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterOffsetMap {
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
|
||||
let pageStartOffsets: [Int]
|
||||
|
||||
let pageEndOffsets: [Int]
|
||||
|
||||
/// fragmentID -> 章内字符偏移
|
||||
let cfiMap: RDEPUBCFIMap?
|
||||
|
||||
let chapterText: String?
|
||||
|
||||
func chapterOffset(forFragmentID fragmentID: String) -> Int? {
|
||||
return fragmentOffsets[fragmentID]
|
||||
}
|
||||
|
||||
/// 章内字符偏移 -> 章内页码(从 0 开始)
|
||||
func chapterOffset(forCFI rawCFI: String?) -> Int? {
|
||||
guard let cfi = RDEPUBCFICompatibility.parseLossy(rawCFI) else { return nil }
|
||||
let resolved = RDEPUBCFIResolver.resolve(cfi)
|
||||
let lastOffset = max((pageEndOffsets.max() ?? 0), 0)
|
||||
return RDEPUBCFIRecoveryEngine.recover(
|
||||
cfi: cfi,
|
||||
cfiMap: cfiMap,
|
||||
chapterText: chapterText,
|
||||
fragmentOffsets: fragmentOffsets,
|
||||
fallbackOffset: resolved.chapterOffset,
|
||||
lastOffset: lastOffset
|
||||
)?.chapterOffset
|
||||
}
|
||||
|
||||
func pageIndex(forChapterOffset offset: Int) -> Int? {
|
||||
for i in 0..<pageStartOffsets.count {
|
||||
if offset >= pageStartOffsets[i] && offset <= pageEndOffsets[i] {
|
||||
@@ -19,4 +38,4 @@ struct RDEPUBChapterOffsetMap {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-47
@@ -2,50 +2,36 @@ import UIKit
|
||||
|
||||
final class RDEPUBChapterRuntimeStore {
|
||||
|
||||
// MARK: - 子缓存
|
||||
|
||||
/// 章节运行时主缓存(等价 WXRead chapterDataCache)
|
||||
private let chapterDataCache = RDEPUBChapterDataCache()
|
||||
|
||||
/// 轻量分页结构缓存(等价 WXRead pageCountCache)
|
||||
private let pageCountCache = RDEPUBPageCountCache()
|
||||
|
||||
/// 图片缓存(独立 NSCache,等价 WXRead imageCache)
|
||||
let imageCache = NSCache<NSString, UIImage>()
|
||||
|
||||
/// 串行加载队列(等价 WXRead com.weread.chapterload)
|
||||
/// QoS .userInitiated:用户主动跳章/开书属于前台交互,需尽快完成
|
||||
let chapterLoadQueue = DispatchQueue(label: "com.rdreader.chapterload", qos: .userInitiated)
|
||||
|
||||
private let chapterLoadQueueKey = DispatchSpecificKey<Void>()
|
||||
|
||||
// MARK: - 窗口状态
|
||||
|
||||
/// 当前章 spineIndex
|
||||
private(set) var currentSpineIndex: Int?
|
||||
|
||||
/// 当前窗口内的 spineIndex 集合(以当前章为中心,按配置半径展开)
|
||||
private(set) var windowSpineIndices: [Int] = []
|
||||
|
||||
// MARK: - 请求通道(前台导航 vs 后台预取,语义独立,互不抢占)
|
||||
|
||||
/// 前台导航目标(用户主动跳章:目录/书签/搜索/翻章)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
private var pendingNavigationTarget: Int?
|
||||
|
||||
private let navigationLock = NSLock()
|
||||
|
||||
/// 后台预取目标集合(±1 相邻章预取)
|
||||
/// 预取不抢占前台导航通道,预取完成后仅刷新快照,不触发跳章
|
||||
private var pendingPrefetchTargets: Set<Int> = []
|
||||
|
||||
private let prefetchLock = NSLock()
|
||||
|
||||
/// 是否有章节正在构建中
|
||||
private(set) var isBuilding: Bool = false
|
||||
|
||||
private let buildingLock = NSLock()
|
||||
|
||||
// MARK: - 初始化
|
||||
|
||||
init() {
|
||||
|
||||
imageCache.countLimit = 50
|
||||
|
||||
chapterLoadQueue.setSpecific(key: chapterLoadQueueKey, value: ())
|
||||
}
|
||||
|
||||
@@ -53,8 +39,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
dispatchPrecondition(condition: .notOnQueue(chapterLoadQueue))
|
||||
}
|
||||
|
||||
// MARK: - 缓存查询(线程安全,通过 cache wrapper 的 lock 保护)
|
||||
|
||||
func chapterData(for spineIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
return chapterDataCache[spineIndex]
|
||||
}
|
||||
@@ -63,8 +47,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
return pageCountCache[key]
|
||||
}
|
||||
|
||||
// MARK: - 缓存插入
|
||||
|
||||
func insertChapter(_ chapter: RDEPUBRuntimeChapter) {
|
||||
chapterDataCache[chapter.spineIndex] = chapter
|
||||
}
|
||||
@@ -73,9 +55,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
pageCountCache[key] = pc
|
||||
}
|
||||
|
||||
// MARK: - 窗口管理
|
||||
|
||||
/// 设定当前章,自动计算按半径展开的窗口
|
||||
func setCurrentChapter(spineIndex: Int, totalSpineCount: Int, windowRadius: Int = 1) {
|
||||
currentSpineIndex = spineIndex
|
||||
let radius = max(0, windowRadius)
|
||||
@@ -88,14 +67,11 @@ final class RDEPUBChapterRuntimeStore {
|
||||
windowSpineIndices = Array(lowerBound...upperBound)
|
||||
}
|
||||
|
||||
/// 返回窗口外、应该淘汰的 spineIndex
|
||||
func evictableSpineIndices() -> [Int] {
|
||||
let windowSet = Set(windowSpineIndices)
|
||||
return chapterDataCache.storedSpineIndices.filter { !windowSet.contains($0) }
|
||||
}
|
||||
|
||||
// MARK: - 淘汰
|
||||
|
||||
func evict(spineIndex: Int) {
|
||||
chapterDataCache.remove(spineIndex: spineIndex)
|
||||
pageCountCache.remove(forSpineIndex: spineIndex)
|
||||
@@ -112,28 +88,21 @@ final class RDEPUBChapterRuntimeStore {
|
||||
if let ch = currentChapter {
|
||||
chapterDataCache[current] = ch
|
||||
}
|
||||
// WXRead 语义:内存警告时 pageCountCache 全量清空
|
||||
|
||||
pageCountCache.removeAll()
|
||||
}
|
||||
|
||||
// MARK: - 内存警告
|
||||
|
||||
func handleMemoryWarning() {
|
||||
evictAllExceptCurrent()
|
||||
imageCache.removeAllObjects()
|
||||
}
|
||||
|
||||
// MARK: - 前台导航请求管理(§14.4 取消语义)
|
||||
|
||||
/// 注册前台导航目标(用户主动跳章时调用)
|
||||
/// 仅保留最后一次目标,旧的排队请求可被取消
|
||||
func setNavigationTarget(spineIndex: Int) {
|
||||
navigationLock.lock()
|
||||
pendingNavigationTarget = spineIndex
|
||||
navigationLock.unlock()
|
||||
}
|
||||
|
||||
/// 消费前台导航目标(章节构建完成后调用,检查是否有更新的目标)
|
||||
func consumeNavigationTarget() -> Int? {
|
||||
navigationLock.lock()
|
||||
let target = pendingNavigationTarget
|
||||
@@ -142,31 +111,24 @@ final class RDEPUBChapterRuntimeStore {
|
||||
return target
|
||||
}
|
||||
|
||||
// MARK: - 后台预取请求管理
|
||||
|
||||
/// 注册后台预取目标(±1 相邻章预取时调用)
|
||||
/// 预取不抢占前台导航通道
|
||||
func addPrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.insert(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 标记预取目标已完成
|
||||
func removePrefetchTarget(_ spineIndex: Int) {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.remove(spineIndex)
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 清空所有预取目标(切章时调用,旧预取结果不再需要)
|
||||
func clearPrefetchTargets() {
|
||||
prefetchLock.lock()
|
||||
pendingPrefetchTargets.removeAll()
|
||||
prefetchLock.unlock()
|
||||
}
|
||||
|
||||
/// 检查是否有待处理的预取目标
|
||||
func hasPrefetchTarget(_ spineIndex: Int) -> Bool {
|
||||
prefetchLock.lock()
|
||||
let has = pendingPrefetchTargets.contains(spineIndex)
|
||||
@@ -180,8 +142,6 @@ final class RDEPUBChapterRuntimeStore {
|
||||
buildingLock.unlock()
|
||||
}
|
||||
|
||||
// MARK: - P1: 排版参数变化后整体失效(§8.5)
|
||||
|
||||
func invalidateAllForSettingsChange() {
|
||||
chapterDataCache.removeAll()
|
||||
pageCountCache.removeAll()
|
||||
|
||||
+24
-21
@@ -1,8 +1,11 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterSummaryDiskCache {
|
||||
|
||||
private let cacheDirectory: URL
|
||||
|
||||
private let fileManager = FileManager.default
|
||||
|
||||
private let queue = DispatchQueue(label: "com.rdreader.summarydiskcache", qos: .utility)
|
||||
|
||||
init(cacheDirectory: URL) {
|
||||
@@ -10,28 +13,22 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
|
||||
}
|
||||
|
||||
// MARK: - 写入(异步)
|
||||
|
||||
func write(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.async {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步写入:用于后台整书元数据构建完成前,确保摘要文件已经真实落盘。
|
||||
func writeSynchronously(summary: RDEPUBChapterSummary, for key: RDEPUBChapterCacheKey) {
|
||||
queue.sync {
|
||||
self.writeImmediately(summary: summary, for: key)
|
||||
}
|
||||
}
|
||||
|
||||
/// 等待此前已排队的异步写入全部落盘。
|
||||
func flushPendingWrites() {
|
||||
queue.sync { }
|
||||
}
|
||||
|
||||
// MARK: - 读取(同步,因为 loadChapter 已在串行队列上)
|
||||
|
||||
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBChapterSummary? {
|
||||
let fileURL = self.fileURL(for: key)
|
||||
let data: Data
|
||||
@@ -40,7 +37,7 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
} catch {
|
||||
let nsError = error as NSError
|
||||
if nsError.domain == NSCocoaErrorDomain && nsError.code == NSFileReadNoSuchFileError {
|
||||
// 文件不存在属于正常缓存未命中,不报错
|
||||
|
||||
} else {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterSummaryDiskCache] ⚠️ read IO error for \(fileURL.lastPathComponent): \(error.localizedDescription)")
|
||||
@@ -58,11 +55,6 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 批量读取:二次打开时直接从磁盘构建 BookPageMap
|
||||
|
||||
/// 批量读取指定缓存键列表的摘要,返回 spineIndex → summary 映射。
|
||||
/// 同步方法,应在后台线程调用。
|
||||
/// 由调用方负责构建完整的缓存键列表(含正确的 contentHash)。
|
||||
func readAll(keys: [(key: RDEPUBChapterCacheKey, spineIndex: Int, href: String, title: String)]) -> (
|
||||
summaries: [Int: RDEPUBChapterSummary],
|
||||
mapBuilder: RDEPUBBookPageMap.Builder
|
||||
@@ -85,7 +77,6 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return (summaries, mapBuilder)
|
||||
}
|
||||
|
||||
/// 检查指定缓存键列表是否全部有对应的磁盘摘要。
|
||||
func isCacheComplete(keys: [RDEPUBChapterCacheKey]) -> Bool {
|
||||
for key in keys {
|
||||
if read(for: key) == nil {
|
||||
@@ -95,24 +86,20 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清空所有缓存文件
|
||||
func removeAll() {
|
||||
removeFiles(matching: { _ in true })
|
||||
}
|
||||
|
||||
/// 清空指定书籍的所有缓存文件
|
||||
func removeAll(forBookID bookID: String) {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: bookID)
|
||||
removeFiles { $0.hasPrefix(bookPrefix + "__") }
|
||||
}
|
||||
|
||||
/// 清空指定渲染签名下的所有缓存文件
|
||||
func removeAll(forRenderSignature renderSignature: String) {
|
||||
let renderPrefix = "__" + Self.cacheNamespacePrefix(for: renderSignature) + "__"
|
||||
removeFiles { $0.contains(renderPrefix) }
|
||||
}
|
||||
|
||||
/// 缓存统计信息
|
||||
var cacheStatistics: (fileCount: Int, totalBytes: Int64) {
|
||||
guard let files = try? fileManager.contentsOfDirectory(at: cacheDirectory, includingPropertiesForKeys: [.fileSizeKey]) else {
|
||||
return (0, 0)
|
||||
@@ -128,9 +115,6 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
return (count, totalBytes)
|
||||
}
|
||||
|
||||
// MARK: - key -> 文件路径
|
||||
|
||||
/// 使用确定性字符串拼接生成文件名,不依赖 Hashable.hashValue
|
||||
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
|
||||
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
|
||||
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
|
||||
@@ -171,29 +155,48 @@ final class RDEPUBChapterSummaryDiskCache {
|
||||
}
|
||||
|
||||
struct RDEPUBChapterSummary: Codable {
|
||||
|
||||
let pageRanges: [RangeData]
|
||||
|
||||
let pageCount: Int
|
||||
|
||||
let fragmentOffsets: [String: Int]
|
||||
|
||||
let cfiMap: RDEPUBCFIMap?
|
||||
|
||||
let renderSignature: String
|
||||
|
||||
let schemaVersion: Int
|
||||
|
||||
let chapterContentHash: String
|
||||
|
||||
let pageMetadataList: [PageMetadataSummary]
|
||||
|
||||
static let currentSchemaVersion = 6
|
||||
static let currentSchemaVersion = 9
|
||||
|
||||
struct RangeData: Codable {
|
||||
|
||||
let location: Int
|
||||
|
||||
let length: Int
|
||||
|
||||
var nsRange: NSRange { NSRange(location: location, length: length) }
|
||||
}
|
||||
|
||||
struct PageMetadataSummary: Codable {
|
||||
|
||||
let breakReason: String
|
||||
|
||||
let attachmentRanges: [RangeData]
|
||||
|
||||
let attachmentKinds: [String]
|
||||
|
||||
let blockKinds: [String]
|
||||
|
||||
let semanticHints: [String]
|
||||
|
||||
let attachmentPlacements: [String]
|
||||
|
||||
let trailingFragmentID: String?
|
||||
|
||||
func toPageMetadata() -> RDEPUBTextPageMetadata {
|
||||
|
||||
+13
-46
@@ -1,14 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBChapterWindowCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
private let loader: RDEPUBChapterLoader
|
||||
|
||||
/// 当前窗口快照
|
||||
private(set) var currentSnapshot: RDEPUBChapterWindowSnapshot?
|
||||
|
||||
/// 窗口切换回调
|
||||
var onSnapshotChanged: ((RDEPUBChapterWindowSnapshot) -> Void)?
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore, loader: RDEPUBChapterLoader) {
|
||||
@@ -17,11 +18,8 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
self.loader = loader
|
||||
}
|
||||
|
||||
/// 章节加载完成后恢复位置用
|
||||
private var restoreChapterOffset: Int?
|
||||
|
||||
// MARK: - 打开书籍
|
||||
|
||||
func openBook(at targetSpineIndex: Int, restoreChapterOffset: Int? = nil) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
store.setCurrentChapter(
|
||||
@@ -31,19 +29,15 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
)
|
||||
self.restoreChapterOffset = restoreChapterOffset
|
||||
|
||||
// 标记切章进行中
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: targetSpineIndex)
|
||||
// 清空旧预取目标
|
||||
|
||||
store.clearPrefetchTargets()
|
||||
|
||||
// 加载目标章(前台导航优先级)
|
||||
loadChapterWithFallback(initialSpineIndex: targetSpineIndex, totalSpineCount: totalSpineCount)
|
||||
}
|
||||
|
||||
/// 加载章节,如果当前章节失败则自动尝试下一个可渲染的章节
|
||||
private func loadChapterWithFallback(initialSpineIndex: Int, totalSpineCount: Int) {
|
||||
loader.loadChapter(spineIndex: initialSpineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
@@ -55,7 +49,7 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
#if DEBUG
|
||||
print("[EPUB][WindowCoord] loadChapter failed at spine=\(initialSpineIndex): \(error), trying next")
|
||||
#endif
|
||||
// 自动跳过不可渲染的章节(封面/版权页等 linear=false 的 spine 项)
|
||||
|
||||
let nextIndex = initialSpineIndex + 1
|
||||
if nextIndex < totalSpineCount {
|
||||
self.store.setCurrentChapter(
|
||||
@@ -66,7 +60,7 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
self.store.setNavigationTarget(spineIndex: nextIndex)
|
||||
self.loadChapterWithFallback(initialSpineIndex: nextIndex, totalSpineCount: totalSpineCount)
|
||||
} else {
|
||||
// 所有章节都不可渲染
|
||||
|
||||
self.isSwitchingChapter = false
|
||||
self.handle(error: error)
|
||||
}
|
||||
@@ -74,8 +68,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 构建窗口快照
|
||||
|
||||
private func buildSnapshotAroundCurrent(chapter: RDEPUBRuntimeChapter) {
|
||||
guard let current = store.currentSpineIndex else {
|
||||
#if DEBUG
|
||||
@@ -98,26 +90,20 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
onSnapshotChanged?(snapshot)
|
||||
isApplyingSnapshot = false
|
||||
|
||||
// 首次打开时恢复到指定 chapterOffset
|
||||
if let offset = restoreChapterOffset,
|
||||
let chapter = snapshot.chapterForPage(flattenedPageIndex: snapshot.anchorPageOffset),
|
||||
let pageIndex = chapter.chapterOffsetMap.pageIndex(forChapterOffset: offset) {
|
||||
let targetPage = snapshot.anchorPageOffset + pageIndex
|
||||
context.readerView?.transitionToPage(pageNum: targetPage, animated: false)
|
||||
} else if snapshot.pageCount > 0 {
|
||||
// 首次打开且没有恢复位置时,必须显式落到当前章首屏。
|
||||
// reloadData() 内部 switchReaderDisplayType 已将 currentPage 从 -1 置为 0,
|
||||
// 但 0 不一定是目标章的起始页(anchorPageOffset),仍需显式 transition。
|
||||
|
||||
context.readerView?.transitionToPage(pageNum: snapshot.anchorPageOffset, animated: false)
|
||||
}
|
||||
restoreChapterOffset = nil
|
||||
|
||||
// 预取窗口内尚未加载的章节
|
||||
prefetchAdjacent(current: current)
|
||||
}
|
||||
|
||||
// MARK: - 预取(后台优先级,不抢占前台导航通道)
|
||||
|
||||
private func prefetchAdjacent(current: Int) {
|
||||
for spineIndex in store.windowSpineIndices where spineIndex != current {
|
||||
guard store.chapterData(for: spineIndex) == nil else { continue }
|
||||
@@ -129,9 +115,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 翻章
|
||||
|
||||
/// 到达章末,翻到下一章
|
||||
func flipToNextChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex else { return }
|
||||
let next = current + 1
|
||||
@@ -141,27 +124,23 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
flipToChapter(spineIndex: next, completion: completion)
|
||||
}
|
||||
|
||||
/// 到达章首,翻到上一章
|
||||
func flipToPreviousChapter(completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void) {
|
||||
guard let current = store.currentSpineIndex, current > 0 else { return }
|
||||
flipToChapter(spineIndex: current - 1, completion: completion)
|
||||
}
|
||||
|
||||
/// 跳转到指定章节(目录/书签/搜索)
|
||||
func flipToChapter(
|
||||
spineIndex: Int,
|
||||
completion: @escaping (Result<RDEPUBChapterWindowSnapshot, Error>) -> Void
|
||||
) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 注册前台导航目标
|
||||
store.setNavigationTarget(spineIndex: spineIndex)
|
||||
// 清空后台预取目标
|
||||
|
||||
store.clearPrefetchTargets()
|
||||
// 标记切章进行中
|
||||
|
||||
isSwitchingChapter = true
|
||||
|
||||
// 先淘汰旧窗口外章节
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
@@ -172,7 +151,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
store.evict(spineIndex: idx)
|
||||
}
|
||||
|
||||
// 如果目标章已在缓存中,直接构建快照
|
||||
if let cached = store.chapterData(for: spineIndex) {
|
||||
buildSnapshotAroundCurrent(chapter: cached)
|
||||
isSwitchingChapter = false
|
||||
@@ -182,7 +160,6 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
// 未命中缓存,走加载链路(前台导航优先级)
|
||||
loader.loadChapter(spineIndex: spineIndex, store: store, priority: .navigation) { [weak self] result in
|
||||
guard let self = self else { return }
|
||||
self.isSwitchingChapter = false
|
||||
@@ -198,13 +175,10 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 刷新快照(预取完成后调用)
|
||||
|
||||
func refreshSnapshot() {
|
||||
guard let current = store.currentSpineIndex,
|
||||
let currentChapter = store.chapterData(for: current) else { return }
|
||||
|
||||
// 空闲门槛检查
|
||||
guard isReaderIdle() else {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in
|
||||
self?.refreshSnapshot()
|
||||
@@ -223,13 +197,9 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - P1: 翻章后维护窗口
|
||||
|
||||
/// 当前章常驻,预取新的相邻章
|
||||
func maintainWindow(afterMovingTo spineIndex: Int) {
|
||||
let totalSpineCount = context.publication?.spine.count ?? 0
|
||||
|
||||
// 淘汰窗口外章节
|
||||
store.setCurrentChapter(
|
||||
spineIndex: spineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
@@ -242,18 +212,16 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
prefetchAdjacent(current: spineIndex)
|
||||
}
|
||||
|
||||
// MARK: - 内部辅助
|
||||
|
||||
private func handle(error: Error) {
|
||||
// 日志记录,不中断当前阅读状态
|
||||
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterWindowCoordinator] chapter load error: \(error)")
|
||||
#endif
|
||||
// 确保 loading 指示器在加载失败时也被隐藏(避免永久白屏)
|
||||
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
guard let self else { return }
|
||||
self.context.hideLoading()
|
||||
// 如果有快照但没内容显示,显示错误提示
|
||||
|
||||
if self.currentSnapshot == nil {
|
||||
#if DEBUG
|
||||
print("[RDEPUBChapterWindowCoordinator] No snapshot after error, page will be blank")
|
||||
@@ -293,8 +261,7 @@ final class RDEPUBChapterWindowCoordinator {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 切章进行中标记
|
||||
private var isSwitchingChapter: Bool = false
|
||||
/// 应用快照进行中标记
|
||||
|
||||
private var isApplyingSnapshot: Bool = false
|
||||
}
|
||||
|
||||
+1
-16
@@ -1,26 +1,17 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBChapterWindowSnapshot {
|
||||
/// 窗口中的章节(有序)
|
||||
|
||||
let chapters: [RDEPUBRuntimeChapter]
|
||||
|
||||
/// 展平后的连续页数组(供 RDReaderView 消费)
|
||||
/// 窗口内页码不写回 RDEPUBTextPage 模型;
|
||||
/// flattenedPages 的数组下标就是窗口内连续页码(从 0 开始)。
|
||||
let flattenedPages: [RDEPUBTextPage]
|
||||
|
||||
/// 当前章在 chapters 数组中的索引
|
||||
let anchorChapterIndex: Int
|
||||
|
||||
/// 当前章在 flattenedPages 中的起始页码(从 0 开始,窗口内编号)
|
||||
let anchorPageOffset: Int
|
||||
|
||||
/// 当前窗口首章的 spineIndex,用于调试日志和跨窗口映射
|
||||
let windowStartSpineIndex: Int
|
||||
|
||||
// MARK: - 构建
|
||||
|
||||
/// 从章节窗口构建快照
|
||||
static func from(
|
||||
chapters: [RDEPUBRuntimeChapter],
|
||||
anchorSpineIndex: Int
|
||||
@@ -29,7 +20,6 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
let anchorIndex = sortedChapters.firstIndex { $0.spineIndex == anchorSpineIndex } ?? 0
|
||||
let pageOffset = sortedChapters.prefix(anchorIndex).reduce(0) { $0 + $1.pages.count }
|
||||
|
||||
// 展平页数组
|
||||
var allPages: [RDEPUBTextPage] = []
|
||||
for (chIdx, ch) in sortedChapters.enumerated() {
|
||||
for var page in ch.pages {
|
||||
@@ -49,9 +39,6 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - 查询
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节
|
||||
func chapterForPage(flattenedPageIndex: Int) -> RDEPUBRuntimeChapter? {
|
||||
var offset = 0
|
||||
for ch in chapters {
|
||||
@@ -63,11 +50,9 @@ struct RDEPUBChapterWindowSnapshot {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 窗口内页码(即 flattenedPages 下标)-> 所属章节的 spineIndex
|
||||
func spineIndexForPage(flattenedPageIndex: Int) -> Int? {
|
||||
return chapterForPage(flattenedPageIndex: flattenedPageIndex)?.spineIndex
|
||||
}
|
||||
|
||||
/// 总页数(窗口内)
|
||||
var pageCount: Int { flattenedPages.count }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBPageCountCache {
|
||||
|
||||
private var storage: [RDEPUBChapterCacheKey: RDEPUBRuntimePageCount] = [:]
|
||||
|
||||
private let lock = NSLock()
|
||||
|
||||
subscript(key: RDEPUBChapterCacheKey) -> RDEPUBRuntimePageCount? {
|
||||
|
||||
@@ -1,13 +1,18 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBResolvedPage {
|
||||
|
||||
let page: RDEPUBTextPage
|
||||
|
||||
let chapter: RDEPUBRuntimeChapter
|
||||
|
||||
let chapterIndex: Int
|
||||
}
|
||||
|
||||
final class RDEPUBPageResolver {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private let store: RDEPUBChapterRuntimeStore
|
||||
|
||||
init(context: RDEPUBReaderContext, store: RDEPUBChapterRuntimeStore) {
|
||||
|
||||
+3
-7
@@ -1,26 +1,23 @@
|
||||
import Foundation
|
||||
|
||||
final class RDEPUBRuntimeChapter {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let title: String
|
||||
|
||||
/// 原始富文本(可按策略释放,不强制常驻)
|
||||
var sourceAttributedString: NSAttributedString?
|
||||
|
||||
/// 排版后富文本
|
||||
let typesetAttributedString: NSAttributedString
|
||||
|
||||
/// 排版器
|
||||
let layouter: RDEPUBTextLayouter
|
||||
|
||||
/// 页范围
|
||||
let pageRanges: [NSRange]
|
||||
|
||||
/// 页面数组
|
||||
let pages: [RDEPUBTextPage]
|
||||
|
||||
/// 章节偏移映射
|
||||
let chapterOffsetMap: RDEPUBChapterOffsetMap
|
||||
|
||||
init(
|
||||
@@ -45,7 +42,6 @@ final class RDEPUBRuntimeChapter {
|
||||
self.chapterOffsetMap = chapterOffsetMap
|
||||
}
|
||||
|
||||
/// 释放 sourceAttributedString 以降低内存
|
||||
func releaseSourceText() {
|
||||
sourceAttributedString = nil
|
||||
}
|
||||
|
||||
+5
@@ -1,9 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
struct RDEPUBRuntimePageCount {
|
||||
|
||||
let cacheKey: RDEPUBChapterCacheKey
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let pageRanges: [NSRange]
|
||||
|
||||
let pageCount: Int
|
||||
|
||||
let renderSignature: String
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import CryptoKit
|
||||
|
||||
extension String {
|
||||
|
||||
var sha256Hex: String {
|
||||
|
||||
let digest = SHA256.hash(data: Data(self.utf8))
|
||||
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
}
|
||||
@@ -1,65 +1,56 @@
|
||||
import Foundation
|
||||
|
||||
/// 后台覆盖段:表示某一段章节范围的稳定页码覆盖
|
||||
struct RDEPUBBackgroundCoverageSegment {
|
||||
/// 段的下界 spineIndex
|
||||
|
||||
let lowerSpineIndex: Int
|
||||
/// 段的上界 spineIndex
|
||||
|
||||
let upperSpineIndex: Int
|
||||
/// 页图数据
|
||||
|
||||
let pageMap: RDEPUBBookPageMap
|
||||
/// 已解析的 spineIndex 集合
|
||||
|
||||
let resolvedSpineIndices: Set<Int>
|
||||
/// 创建时间
|
||||
|
||||
let generatedAt: CFAbsoluteTime
|
||||
/// 渲染签名
|
||||
|
||||
let renderSignature: String
|
||||
/// 预估内存占用(字节)
|
||||
|
||||
let estimatedMemoryBytes: Int
|
||||
|
||||
/// 是否包含指定 spineIndex
|
||||
func contains(spineIndex: Int) -> Bool {
|
||||
spineIndex >= lowerSpineIndex && spineIndex <= upperSpineIndex
|
||||
}
|
||||
|
||||
/// 与指定 spineIndex 的距离
|
||||
func distance(to spineIndex: Int) -> Int {
|
||||
if contains(spineIndex: spineIndex) { return 0 }
|
||||
return min(abs(spineIndex - lowerSpineIndex), abs(spineIndex - upperSpineIndex))
|
||||
}
|
||||
}
|
||||
|
||||
/// 覆盖存储策略
|
||||
struct RDEPUBBackgroundCoverageStorePolicy {
|
||||
/// 最大常驻段数
|
||||
|
||||
let maxResidentSegments: Int
|
||||
/// 单段最大章节数
|
||||
|
||||
let maxChaptersPerSegment: Int
|
||||
/// 内存预算(字节)
|
||||
|
||||
let memoryBudgetBytes: Int
|
||||
|
||||
/// 默认策略
|
||||
static let `default` = RDEPUBBackgroundCoverageStorePolicy(
|
||||
maxResidentSegments: 8,
|
||||
maxChaptersPerSegment: 256,
|
||||
memoryBudgetBytes: 8 * 1024 * 1024 // 8MB
|
||||
memoryBudgetBytes: 8 * 1024 * 1024
|
||||
)
|
||||
}
|
||||
|
||||
/// 后台覆盖存储管理器
|
||||
final class RDEPUBBackgroundCoverageStore {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 存储策略
|
||||
private let policy: RDEPUBBackgroundCoverageStorePolicy
|
||||
|
||||
/// 存储的段列表
|
||||
private var segments: [RDEPUBBackgroundCoverageSegment] = []
|
||||
|
||||
/// 当前总内存占用
|
||||
private var currentMemoryBytes: Int = 0
|
||||
|
||||
/// 最后访问时间(用于 LRU 淘汰)
|
||||
private var lastAccessTime: [Int: CFAbsoluteTime] = [:]
|
||||
|
||||
init(context: RDEPUBReaderContext, policy: RDEPUBBackgroundCoverageStorePolicy = .default) {
|
||||
@@ -67,12 +58,10 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
self.policy = policy
|
||||
}
|
||||
|
||||
/// 添加段
|
||||
func addSegment(_ segment: RDEPUBBackgroundCoverageSegment) {
|
||||
// 检查是否需要淘汰
|
||||
|
||||
evictIfNeeded(forNewSegment: segment)
|
||||
|
||||
// 检查是否与现有段重叠,合并或替换
|
||||
var merged = false
|
||||
for (index, existing) in segments.enumerated() {
|
||||
if canMerge(existing, segment) {
|
||||
@@ -94,7 +83,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
currentMemoryBytes += segment.estimatedMemoryBytes
|
||||
}
|
||||
|
||||
// 更新访问时间
|
||||
lastAccessTime[segment.lowerSpineIndex] = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -103,7 +91,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 查找覆盖指定 spineIndex 的段
|
||||
func findSegment(containing spineIndex: Int) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let segment = segments.first { $0.contains(spineIndex: spineIndex) }
|
||||
if let segment {
|
||||
@@ -112,7 +99,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
return segment
|
||||
}
|
||||
|
||||
/// 查找覆盖指定 spineIndex 集合的段
|
||||
func findSegment(covering spineIndices: Set<Int>) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let segment = segments.first { segment in
|
||||
spineIndices.allSatisfy { segment.contains(spineIndex: $0) }
|
||||
@@ -123,19 +109,16 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
return segment
|
||||
}
|
||||
|
||||
/// 获取所有段
|
||||
func allSegments() -> [RDEPUBBackgroundCoverageSegment] {
|
||||
segments
|
||||
}
|
||||
|
||||
/// 清除所有段
|
||||
func clearAll() {
|
||||
segments.removeAll()
|
||||
currentMemoryBytes = 0
|
||||
lastAccessTime.removeAll()
|
||||
}
|
||||
|
||||
/// 清除冷区段(不覆盖当前阅读位置和保护区的段)
|
||||
func clearColdSegments(
|
||||
activeWindowSpineIndices: Set<Int>,
|
||||
protectedSpineIndices: Set<Int>
|
||||
@@ -151,7 +134,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理内存警告
|
||||
func handleMemoryWarning(
|
||||
activeWindowSpineIndices: Set<Int>,
|
||||
protectedSpineIndices: Set<Int>
|
||||
@@ -161,15 +143,13 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
"memory warning: clearing cold segments, current=\(currentMemoryBytes)B"
|
||||
)
|
||||
|
||||
// 第一步:清除冷区段
|
||||
clearColdSegments(
|
||||
activeWindowSpineIndices: activeWindowSpineIndices,
|
||||
protectedSpineIndices: protectedSpineIndices
|
||||
)
|
||||
|
||||
// 如果仍然超限,清除更多段
|
||||
if currentMemoryBytes > policy.memoryBudgetBytes {
|
||||
// 按距离排序,清除最远的段
|
||||
|
||||
let sorted = segments.sorted { lhs, rhs in
|
||||
let lhsDistance = lhs.resolvedSpineIndices.map { idx in
|
||||
activeWindowSpineIndices.map { abs(idx - $0) }.min() ?? Int.max
|
||||
@@ -194,24 +174,20 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查是否需要淘汰
|
||||
private func evictIfNeeded(forNewSegment newSegment: RDEPUBBackgroundCoverageSegment) {
|
||||
// 检查段数限制
|
||||
|
||||
while segments.count >= policy.maxResidentSegments {
|
||||
evictLeastRecentlyUsed()
|
||||
}
|
||||
|
||||
// 检查内存限制
|
||||
while currentMemoryBytes + newSegment.estimatedMemoryBytes > policy.memoryBudgetBytes {
|
||||
evictLeastRecentlyUsed()
|
||||
}
|
||||
}
|
||||
|
||||
/// 淘汰最近最少使用的段
|
||||
private func evictLeastRecentlyUsed() {
|
||||
guard !segments.isEmpty else { return }
|
||||
|
||||
// 找到最久未访问的段
|
||||
var oldestTime = CFAbsoluteTimeGetCurrent()
|
||||
var oldestIndex = 0
|
||||
for (index, segment) in segments.enumerated() {
|
||||
@@ -232,33 +208,27 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 检查两个段是否可以合并
|
||||
private func canMerge(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> Bool {
|
||||
// 渲染签名必须相同
|
||||
|
||||
guard lhs.renderSignature == rhs.renderSignature else { return false }
|
||||
|
||||
// 检查是否重叠或相邻
|
||||
let overlap = lhs.upperSpineIndex >= rhs.lowerSpineIndex - 1 &&
|
||||
rhs.upperSpineIndex >= lhs.lowerSpineIndex - 1
|
||||
return overlap
|
||||
}
|
||||
|
||||
/// 合并两个段
|
||||
private func mergeSegments(_ lhs: RDEPUBBackgroundCoverageSegment, _ rhs: RDEPUBBackgroundCoverageSegment) -> RDEPUBBackgroundCoverageSegment? {
|
||||
let newLower = min(lhs.lowerSpineIndex, rhs.lowerSpineIndex)
|
||||
let newUpper = max(lhs.upperSpineIndex, rhs.upperSpineIndex)
|
||||
let newChapterCount = newUpper - newLower + 1
|
||||
|
||||
// 检查合并后是否超过单段最大章节数
|
||||
if newChapterCount > policy.maxChaptersPerSegment {
|
||||
// 按当前阅读位置切分
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 合并 resolvedSpineIndices
|
||||
let newResolved = lhs.resolvedSpineIndices.union(rhs.resolvedSpineIndices)
|
||||
|
||||
// 合并页图
|
||||
let newerSegment = lhs.generatedAt <= rhs.generatedAt ? rhs : lhs
|
||||
let olderSegment = lhs.generatedAt <= rhs.generatedAt ? lhs : rhs
|
||||
let newPageMap = mergePageMaps(olderSegment.pageMap, newerSegment.pageMap)
|
||||
@@ -274,7 +244,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
)
|
||||
}
|
||||
|
||||
/// 合并两个页图
|
||||
private func mergePageMaps(_ older: RDEPUBBookPageMap, _ newer: RDEPUBBookPageMap) -> RDEPUBBookPageMap {
|
||||
var builder = RDEPUBBookPageMap.Builder()
|
||||
var entriesBySpineIndex: [Int: RDEPUBBookPageMapEntry] = [:]
|
||||
@@ -300,7 +269,6 @@ final class RDEPUBBackgroundCoverageStore {
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/// 估算内存占用
|
||||
private func estimateMemoryBytes(pageMap: RDEPUBBookPageMap, resolvedCount: Int) -> Int {
|
||||
256 + pageMap.entries.count * 96 + resolvedCount * 16
|
||||
}
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import Foundation
|
||||
|
||||
/// 后台补全优先级策略
|
||||
///
|
||||
/// 控制后台元数据解析的优先级排序,确保当前阅读区段优先补全
|
||||
struct RDEPUBBackgroundPriorityPolicy {
|
||||
/// 热区半径:当前章节附近的范围
|
||||
|
||||
let hotRadius: Int
|
||||
/// 温区半径:远距跳转锚点附近的范围
|
||||
|
||||
let warmRadius: Int
|
||||
/// 最大温区跳转锚点数量
|
||||
|
||||
let maxWarmJumpAnchors: Int
|
||||
/// 冷区份额:每轮补全中冷区任务的比例
|
||||
|
||||
let coldLaneShare: Double
|
||||
|
||||
/// 默认策略
|
||||
static let `default` = RDEPUBBackgroundPriorityPolicy(
|
||||
hotRadius: 24,
|
||||
warmRadius: 96,
|
||||
@@ -21,7 +17,6 @@ struct RDEPUBBackgroundPriorityPolicy {
|
||||
coldLaneShare: 0.15
|
||||
)
|
||||
|
||||
/// 根据总章节数动态计算策略
|
||||
static func adaptive(totalBuildableChapters: Int) -> RDEPUBBackgroundPriorityPolicy {
|
||||
let hotRadius = min(max(12, Int(sqrt(Double(totalBuildableChapters)))), 48)
|
||||
let warmRadius = min(max(hotRadius * 3, 32), 192)
|
||||
@@ -34,7 +29,6 @@ struct RDEPUBBackgroundPriorityPolicy {
|
||||
}
|
||||
}
|
||||
|
||||
/// 优先级带
|
||||
enum RDEPUBPriorityBand: Int, Comparable {
|
||||
case hot = 0
|
||||
case warmPrimary = 1
|
||||
@@ -46,39 +40,32 @@ enum RDEPUBPriorityBand: Int, Comparable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 温区跳转锚点
|
||||
struct RDEPUBWarmJumpAnchor {
|
||||
let spineIndex: Int
|
||||
let timestamp: CFAbsoluteTime
|
||||
let sequenceNumber: Int
|
||||
}
|
||||
|
||||
/// 元数据解析工作项
|
||||
struct RDEPUBMetadataParseWorkItem {
|
||||
let spineIndex: Int
|
||||
let generation: Int
|
||||
let priorityBand: RDEPUBPriorityBand
|
||||
|
||||
/// 排序键
|
||||
var sortKey: (bandRank: Int, distanceToCurrent: Int, distanceToNewestJump: Int, spineIndex: Int) {
|
||||
(priorityBand.rawValue, 0, 0, spineIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/// 后台补全优先级管理器
|
||||
final class RDEPUBBackgroundPriorityManager {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 当前策略
|
||||
private(set) var policy: RDEPUBBackgroundPriorityPolicy
|
||||
|
||||
/// 温区跳转锚点列表(按时间倒序)
|
||||
private var warmAnchors: [RDEPUBWarmJumpAnchor] = []
|
||||
|
||||
/// 当前 generation
|
||||
private(set) var currentGeneration: Int = 0
|
||||
|
||||
/// 冷区游标
|
||||
private var coldCursor: Int = 0
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -86,12 +73,10 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
self.policy = .default
|
||||
}
|
||||
|
||||
/// 更新策略
|
||||
func updatePolicy(_ newPolicy: RDEPUBBackgroundPriorityPolicy) {
|
||||
policy = newPolicy
|
||||
}
|
||||
|
||||
/// 添加温区跳转锚点
|
||||
func addWarmAnchor(spineIndex: Int) {
|
||||
let anchor = RDEPUBWarmJumpAnchor(
|
||||
spineIndex: spineIndex,
|
||||
@@ -101,13 +86,12 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
|
||||
warmAnchors.insert(anchor, at: 0)
|
||||
|
||||
// 保留最近 N 个锚点
|
||||
if warmAnchors.count > policy.maxWarmJumpAnchors {
|
||||
warmAnchors = Array(warmAnchors.prefix(policy.maxWarmJumpAnchors))
|
||||
}
|
||||
|
||||
currentGeneration += 1
|
||||
coldCursor = 0 // 重置冷区游标
|
||||
coldCursor = 0
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"PriorityManager",
|
||||
@@ -115,7 +99,6 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
)
|
||||
}
|
||||
|
||||
/// 生成优先级排序的 spineIndex 列表
|
||||
func makeMetadataPriorityOrder(
|
||||
allBuildableIndices: [Int],
|
||||
currentSpineIndex: Int?,
|
||||
@@ -124,7 +107,6 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
let uncachedIndices = allBuildableIndices.filter { !cachedSpineIndices.contains($0) }
|
||||
guard !uncachedIndices.isEmpty else { return [] }
|
||||
|
||||
// 为每个 spineIndex 计算优先级带
|
||||
let items = uncachedIndices.map { spineIndex -> (spineIndex: Int, band: RDEPUBPriorityBand) in
|
||||
let band = classifySpineIndex(
|
||||
spineIndex: spineIndex,
|
||||
@@ -133,33 +115,29 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
return (spineIndex, band)
|
||||
}
|
||||
|
||||
// 排序
|
||||
let sorted = items.sorted { lhs, rhs in
|
||||
// 先按优先级带排序
|
||||
|
||||
if lhs.band != rhs.band {
|
||||
return lhs.band < rhs.band
|
||||
}
|
||||
|
||||
// 同一带内按距离排序
|
||||
let lhsDistanceToCurrent = currentSpineIndex.map { abs(lhs.spineIndex - $0) } ?? Int.max
|
||||
let rhsDistanceToCurrent = currentSpineIndex.map { abs(rhs.spineIndex - $0) } ?? Int.max
|
||||
if lhsDistanceToCurrent != rhsDistanceToCurrent {
|
||||
return lhsDistanceToCurrent < rhsDistanceToCurrent
|
||||
}
|
||||
|
||||
// 距离相同时按 spineIndex 排序
|
||||
return lhs.spineIndex < rhs.spineIndex
|
||||
}
|
||||
|
||||
return sorted.map { $0.spineIndex }
|
||||
}
|
||||
|
||||
/// 分类 spineIndex 到优先级带
|
||||
private func classifySpineIndex(
|
||||
spineIndex: Int,
|
||||
currentSpineIndex: Int?
|
||||
) -> RDEPUBPriorityBand {
|
||||
// 检查是否在热区
|
||||
|
||||
if let current = currentSpineIndex {
|
||||
let distance = abs(spineIndex - current)
|
||||
if distance <= policy.hotRadius {
|
||||
@@ -167,7 +145,6 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否在温区
|
||||
for (index, anchor) in warmAnchors.enumerated() {
|
||||
let distance = abs(spineIndex - anchor.spineIndex)
|
||||
if distance <= policy.warmRadius {
|
||||
@@ -175,16 +152,13 @@ final class RDEPUBBackgroundPriorityManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 冷区
|
||||
return .cold
|
||||
}
|
||||
|
||||
/// 获取当前温区锚点(用于后台任务调度)
|
||||
func currentWarmAnchors() -> [RDEPUBWarmJumpAnchor] {
|
||||
warmAnchors
|
||||
}
|
||||
|
||||
/// 重置状态
|
||||
func reset() {
|
||||
warmAnchors.removeAll()
|
||||
currentGeneration = 0
|
||||
|
||||
@@ -1,56 +1,47 @@
|
||||
import Foundation
|
||||
|
||||
/// 远距跳转会话:保护前台阅读窗口不被后台页图覆盖。
|
||||
///
|
||||
/// 当用户执行远距跳转(目录、书签、搜索)后创建,在保护期内:
|
||||
/// - 不允许任何不覆盖保护区的后台页图接管前台
|
||||
/// - 后台补全围绕当前阅读区段优先
|
||||
struct RDEPUBJumpSession {
|
||||
/// 跳转目标的 spineIndex
|
||||
|
||||
let anchorSpineIndex: Int
|
||||
/// 创建时间
|
||||
|
||||
let createdAt: CFAbsoluteTime
|
||||
/// 受保护的 spineIndex 集合
|
||||
|
||||
let protectedSpineIndices: Set<Int>
|
||||
/// 序列号,用于区分多次跳转
|
||||
|
||||
let sequenceNumber: Int
|
||||
/// 过期时间
|
||||
|
||||
let expiresAt: CFAbsoluteTime
|
||||
/// 跳转原因
|
||||
|
||||
let reason: Reason
|
||||
|
||||
/// 跳转原因枚举
|
||||
enum Reason {
|
||||
case tableOfContentsJump
|
||||
case bookmarkJump
|
||||
case searchJump
|
||||
}
|
||||
|
||||
/// 结束条件枚举
|
||||
enum EndReason {
|
||||
/// 候选页图已完整覆盖保护区
|
||||
|
||||
case coverageComplete
|
||||
/// 用户连续翻页离开保护区
|
||||
|
||||
case navigatedAway
|
||||
/// 超时
|
||||
|
||||
case timeout
|
||||
/// 被新的跳转取代
|
||||
|
||||
case superseded
|
||||
}
|
||||
}
|
||||
|
||||
/// JumpSession 策略配置
|
||||
public struct RDEPUBJumpSessionPolicy: Equatable {
|
||||
/// 连续翻页离开保护区的阈值
|
||||
|
||||
public let exitPageThreshold: Int
|
||||
/// 跳转超时时间
|
||||
|
||||
public let timeout: TimeInterval
|
||||
/// 空闲宽限期
|
||||
|
||||
public let idleGracePeriod: TimeInterval
|
||||
/// 保护区相邻章节半径
|
||||
|
||||
public let protectedNeighborRadius: Int
|
||||
|
||||
/// 默认策略
|
||||
public static let `default` = RDEPUBJumpSessionPolicy(
|
||||
exitPageThreshold: 6,
|
||||
timeout: 20,
|
||||
@@ -71,26 +62,20 @@ public struct RDEPUBJumpSessionPolicy: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// JumpSession 管理器
|
||||
final class RDEPUBJumpSessionManager {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 当前活跃的 JumpSession
|
||||
private(set) var activeSession: RDEPUBJumpSession?
|
||||
|
||||
/// 全局序列号
|
||||
private var nextSequenceNumber: Int = 0
|
||||
|
||||
/// 连续翻页计数器
|
||||
private var consecutivePageCount: Int = 0
|
||||
|
||||
/// 上次翻页方向
|
||||
private var lastPageDirection: PageDirection?
|
||||
|
||||
/// 上次用户活动时间
|
||||
private var lastActivityTime: CFAbsoluteTime = 0
|
||||
|
||||
/// 页面方向
|
||||
enum PageDirection {
|
||||
case forward
|
||||
case backward
|
||||
@@ -100,7 +85,6 @@ final class RDEPUBJumpSessionManager {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 创建新的 JumpSession
|
||||
@discardableResult
|
||||
func createSession(
|
||||
anchorSpineIndex: Int,
|
||||
@@ -110,7 +94,6 @@ final class RDEPUBJumpSessionManager {
|
||||
let policy = context.configuration.jumpSessionPolicy
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
|
||||
// 计算保护区
|
||||
var protectedIndices: Set<Int> = [anchorSpineIndex]
|
||||
for offset in 1...policy.protectedNeighborRadius {
|
||||
let lower = anchorSpineIndex - offset
|
||||
@@ -146,7 +129,6 @@ final class RDEPUBJumpSessionManager {
|
||||
return session
|
||||
}
|
||||
|
||||
/// 记录用户翻页
|
||||
func recordPageChange(fromSpineIndex: Int, toSpineIndex: Int) {
|
||||
guard activeSession != nil else { return }
|
||||
|
||||
@@ -161,29 +143,24 @@ final class RDEPUBJumpSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// 检查是否允许页图接管
|
||||
func shouldAllowPageMapTakeover(candidateSpineIndices: Set<Int>) -> Bool {
|
||||
guard let session = activeSession else {
|
||||
return true // 没有活跃 Session,允许接管
|
||||
return true
|
||||
}
|
||||
|
||||
// 检查候选页图是否覆盖保护区
|
||||
let protectedIndices = session.protectedSpineIndices
|
||||
let coverageRatio = Double(protectedIndices.intersection(candidateSpineIndices).count) /
|
||||
Double(protectedIndices.count)
|
||||
|
||||
// 必须覆盖至少 80% 的保护区
|
||||
return coverageRatio >= 0.8
|
||||
}
|
||||
|
||||
/// 检查是否应该结束 Session
|
||||
func checkSessionEnd(currentSpineIndex: Int, isIdle: Bool) -> RDEPUBJumpSession.EndReason? {
|
||||
guard let session = activeSession else { return nil }
|
||||
|
||||
let now = CFAbsoluteTimeGetCurrent()
|
||||
let policy = context.configuration.jumpSessionPolicy
|
||||
|
||||
// 1. 检查超时
|
||||
if now >= session.expiresAt {
|
||||
if isIdle || (now - lastActivityTime) >= policy.idleGracePeriod {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -194,7 +171,6 @@ final class RDEPUBJumpSessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 检查是否离开保护区
|
||||
if !session.protectedSpineIndices.contains(currentSpineIndex) {
|
||||
if consecutivePageCount >= policy.exitPageThreshold {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -204,14 +180,13 @@ final class RDEPUBJumpSessionManager {
|
||||
return .navigatedAway
|
||||
}
|
||||
} else {
|
||||
// 在保护区内,重置连续翻页计数
|
||||
|
||||
consecutivePageCount = 0
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// 结束当前 Session
|
||||
func endSession(_ reason: RDEPUBJumpSession.EndReason) {
|
||||
guard let session = activeSession else { return }
|
||||
RDEPUBBackgroundTrace.log(
|
||||
@@ -223,7 +198,6 @@ final class RDEPUBJumpSessionManager {
|
||||
lastPageDirection = nil
|
||||
}
|
||||
|
||||
/// 清除 Session(用于重新加载等场景)
|
||||
func clearSession() {
|
||||
activeSession = nil
|
||||
consecutivePageCount = 0
|
||||
|
||||
+11
-27
@@ -1,33 +1,31 @@
|
||||
import Foundation
|
||||
|
||||
/// 页图接管决策
|
||||
enum RDEPUBPageMapTakeoverDecision {
|
||||
/// 保持当前窗口不变
|
||||
|
||||
case keepCurrentWindow
|
||||
/// 扩窗:将候选段合并到当前窗口
|
||||
|
||||
case expandWindow(RDEPUBBackgroundCoverageSegment)
|
||||
/// 分段替换:用候选段替换当前窗口的部分内容
|
||||
|
||||
case segmentReplace(RDEPUBBackgroundCoverageSegment)
|
||||
/// 全量替换:用完整页图替换当前窗口
|
||||
|
||||
case fullReplace(RDEPUBBookPageMap)
|
||||
}
|
||||
|
||||
/// 页图协调器:负责判断后台解析结果是否可以接管前台窗口
|
||||
final class RDEPUBPageMapReconciliationCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 判断是否可以接管前台窗口
|
||||
func evaluateTakeover(
|
||||
candidatePageMap: RDEPUBBookPageMap?,
|
||||
candidateSegment: RDEPUBBackgroundCoverageSegment?,
|
||||
currentWindow: RDEPUBBookPageMap?,
|
||||
jumpSession: RDEPUBJumpSession?
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
// 如果没有当前窗口,允许接管
|
||||
|
||||
guard let currentWindow else {
|
||||
if let candidatePageMap {
|
||||
return .fullReplace(candidatePageMap)
|
||||
@@ -35,7 +33,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
// 获取当前阅读位置
|
||||
let currentSpineIndex = context.runtime?.locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
|
||||
@@ -46,11 +43,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}) ?? 0
|
||||
|
||||
// 检查 JumpSession 保护
|
||||
if let jumpSession {
|
||||
let protectedIndices = jumpSession.protectedSpineIndices
|
||||
if let currentSpineIndex, protectedIndices.contains(currentSpineIndex) {
|
||||
// 在保护区内,检查候选是否覆盖保护区
|
||||
|
||||
if let candidateSegment {
|
||||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||||
let coverageRatio = Double(protectedIndices.intersection(candidateIndices).count) /
|
||||
@@ -66,7 +62,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查边界章节覆盖
|
||||
if let currentSpineIndex {
|
||||
let requiresAdjacentCoverage = currentSpineIndex > 0 && currentSpineIndex < lastBuildableSpineIndex
|
||||
|
||||
@@ -85,7 +80,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查渲染签名一致性
|
||||
if let candidateSegment {
|
||||
let currentRenderSignature = context.currentRenderSignature()
|
||||
if candidateSegment.renderSignature != currentRenderSignature {
|
||||
@@ -97,7 +91,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 评估接管类型
|
||||
if let candidateSegment {
|
||||
return evaluateSegmentTakeover(
|
||||
candidateSegment: candidateSegment,
|
||||
@@ -119,7 +112,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
/// 评估分段接管
|
||||
private func evaluateSegmentTakeover(
|
||||
candidateSegment: RDEPUBBackgroundCoverageSegment,
|
||||
currentWindow: RDEPUBBookPageMap,
|
||||
@@ -129,14 +121,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
|
||||
let candidateIndices = candidateSegment.resolvedSpineIndices
|
||||
|
||||
// 检查是否覆盖当前阅读位置
|
||||
if let currentSpineIndex {
|
||||
if !candidateIndices.contains(currentSpineIndex) {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否覆盖相邻章节
|
||||
if let currentSpineIndex {
|
||||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||||
@@ -146,21 +136,20 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否与当前窗口连续
|
||||
let isContinuous = currentIndices.contains(candidateSegment.lowerSpineIndex - 1) ||
|
||||
currentIndices.contains(candidateSegment.upperSpineIndex + 1) ||
|
||||
candidateIndices.contains(currentWindow.entries.first?.spineIndex ?? Int.max) ||
|
||||
candidateIndices.contains(currentWindow.entries.last?.spineIndex ?? Int.min)
|
||||
|
||||
if isContinuous {
|
||||
// 连续,可以扩窗
|
||||
|
||||
return .expandWindow(candidateSegment)
|
||||
} else {
|
||||
// 不连续,检查是否覆盖当前窗口的大部分
|
||||
|
||||
let overlap = currentIndices.intersection(candidateIndices)
|
||||
let overlapRatio = Double(overlap.count) / Double(currentIndices.count)
|
||||
if overlapRatio > 0.5 {
|
||||
// 覆盖大部分,可以替换
|
||||
|
||||
return .segmentReplace(candidateSegment)
|
||||
}
|
||||
}
|
||||
@@ -168,7 +157,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
/// 评估全量页图接管
|
||||
private func evaluateFullPageMapTakeover(
|
||||
candidatePageMap: RDEPUBBookPageMap,
|
||||
currentWindow: RDEPUBBookPageMap,
|
||||
@@ -177,14 +165,12 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
) -> RDEPUBPageMapTakeoverDecision {
|
||||
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
|
||||
|
||||
// 检查是否覆盖当前阅读位置
|
||||
if let currentSpineIndex {
|
||||
if !candidateIndices.contains(currentSpineIndex) {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否覆盖相邻章节
|
||||
if let currentSpineIndex {
|
||||
let hasPrev = candidateIndices.contains(currentSpineIndex - 1) || currentSpineIndex == 0
|
||||
let hasNext = candidateIndices.contains(currentSpineIndex + 1) ||
|
||||
@@ -194,7 +180,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
// 检查是否完整覆盖
|
||||
let isComplete = candidateIndices.count >= currentWindow.entries.count
|
||||
if isComplete {
|
||||
return .fullReplace(candidatePageMap)
|
||||
@@ -203,7 +188,6 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
return .keepCurrentWindow
|
||||
}
|
||||
|
||||
/// 生成保护区域的 spineIndex 集合
|
||||
func protectedSpineIndices(
|
||||
currentSpineIndex: Int?,
|
||||
jumpSession: RDEPUBJumpSession?
|
||||
@@ -212,7 +196,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
|
||||
|
||||
if let currentSpineIndex {
|
||||
indices.insert(currentSpineIndex)
|
||||
// 添加相邻章节
|
||||
|
||||
if currentSpineIndex > 0 {
|
||||
indices.insert(currentSpineIndex - 1)
|
||||
}
|
||||
|
||||
+37
-38
@@ -1,13 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器标注协调器:负责高亮、批注和书签的增删改查操作。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 管理高亮(highlight)与批注(annotation)的创建、更新和删除
|
||||
/// - 管理书签(bookmark)的添加、切换和删除
|
||||
/// - 处理文本选中后的菜单操作(复制、高亮、批注)
|
||||
/// - 弹出高亮管理器和书签管理器界面
|
||||
final class RDEPUBReaderAnnotationCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -18,19 +12,16 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 根据 ID 查找书签。
|
||||
func bookmark(withID id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeBookmarks.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// 根据 ID 查找高亮。
|
||||
func highlight(withID id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
return controller.activeHighlights.first { $0.id == id }
|
||||
}
|
||||
|
||||
/// 更新当前文本选区状态,并同步刷新底部工具栏的高亮按钮可用性。
|
||||
func updateCurrentSelection(_ selection: RDEPUBSelection?) {
|
||||
if let selection, !selection.isEmpty {
|
||||
applySelectionState(.selected(selection))
|
||||
@@ -39,9 +30,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 统一选区状态变更入口。
|
||||
/// 在 `.selected` 时:更新 context 选区、显示工具栏、刷新 chrome、通知 delegate。
|
||||
/// 在 `.idle` 时:清空选区、刷新 chrome、通知 delegate。
|
||||
func applySelectionState(_ state: RDEPUBSelectionState) {
|
||||
guard let controller else { return }
|
||||
context.selectionState = state
|
||||
@@ -52,9 +40,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
case .selecting:
|
||||
break
|
||||
case .selected(let selection):
|
||||
if controller.readerView.isShowToolView == false {
|
||||
controller.readerView.tapCenter()
|
||||
}
|
||||
controller.updateReaderChrome()
|
||||
controller.delegate?.epubReader(controller, didChangeSelection: selection)
|
||||
case .committingAction:
|
||||
@@ -62,7 +47,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 基于当前选区添加高亮标记,自动去重并持久化。
|
||||
@discardableResult
|
||||
func addHighlight(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -72,7 +56,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
addAnnotation(from: selection, style: .highlight, color: color, note: note)
|
||||
}
|
||||
|
||||
/// 基于选区创建标注(高亮/划线/批注),支持指定样式、颜色和备注。
|
||||
@discardableResult
|
||||
func addAnnotation(
|
||||
from selection: RDEPUBSelection? = nil,
|
||||
@@ -115,7 +98,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return newHighlight
|
||||
}
|
||||
|
||||
/// 插入或更新高亮(upsert),按 ID 匹配已有记录。
|
||||
@discardableResult
|
||||
func upsertHighlight(_ highlight: RDEPUBHighlight) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@@ -132,7 +114,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return scopedHighlight
|
||||
}
|
||||
|
||||
/// 根据 ID 删除高亮并持久化。
|
||||
@discardableResult
|
||||
func removeHighlight(id: String) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@@ -144,7 +125,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// 更新指定高亮的批注备注内容。
|
||||
@discardableResult
|
||||
func updateHighlightNote(id: String, note: String?) -> RDEPUBHighlight? {
|
||||
guard let controller else { return nil }
|
||||
@@ -156,7 +136,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return controller.activeHighlights[index]
|
||||
}
|
||||
|
||||
/// 跳转到指定高亮所在位置。
|
||||
@discardableResult
|
||||
func go(toHighlightID id: String, animated: Bool = true) -> Bool {
|
||||
guard let highlight = highlight(withID: id) else {
|
||||
@@ -165,7 +144,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return navigate(to: highlight, animated: animated)
|
||||
}
|
||||
|
||||
/// 清除所有高亮标记。
|
||||
func removeAllHighlights() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeHighlights.isEmpty else { return }
|
||||
@@ -173,7 +151,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
persistHighlightsAndRefreshContent()
|
||||
}
|
||||
|
||||
/// 将选区位置相对于指定 spine 索引进行规范化。
|
||||
func scopedSelection(
|
||||
_ selection: RDEPUBSelection,
|
||||
relativeToSpineIndex spineIndex: Int?
|
||||
@@ -190,7 +167,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: selection.location.progression,
|
||||
lastProgression: selection.location.lastProgression,
|
||||
fragment: selection.location.fragment,
|
||||
rangeAnchor: selection.location.rangeAnchor
|
||||
rangeAnchor: selection.location.rangeAnchor,
|
||||
cfi: selection.location.cfi,
|
||||
lastCFI: selection.location.lastCFI,
|
||||
rangeCFI: selection.location.rangeCFI
|
||||
)
|
||||
return RDEPUBSelection(
|
||||
bookIdentifier: controller.currentBookIdentifier,
|
||||
@@ -201,7 +181,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
)
|
||||
}
|
||||
|
||||
/// 弹出高亮管理器,支持查看、编辑备注和删除高亮。
|
||||
func presentHighlightsManager() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights else { return }
|
||||
@@ -231,7 +210,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出标注创建面板(高亮/划线/批注选择)。
|
||||
func presentAnnotationCreation() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.allowsHighlights,
|
||||
@@ -241,7 +219,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
presentAnnotationActionSheet(for: currentSelection)
|
||||
}
|
||||
|
||||
/// 弹出已有高亮/批注的操作菜单。
|
||||
func presentHighlightActions(for highlight: RDEPUBHighlight, sourceView: UIView, sourceRect: CGRect) {
|
||||
guard let controller else { return }
|
||||
let alert = UIAlertController(title: "标注操作", message: highlight.text, preferredStyle: .actionSheet)
|
||||
@@ -263,7 +240,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
controller.present(alert, animated: true)
|
||||
}
|
||||
|
||||
/// 处理文本选中后的菜单操作:复制、高亮、批注。
|
||||
func handleSelectionMenuAction(_ action: RDEPUBAnnotationMenuAction, selection: RDEPUBSelection?) {
|
||||
guard let selection else { return }
|
||||
switch action {
|
||||
@@ -277,7 +253,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 在当前位置添加书签,自动去重。
|
||||
@discardableResult
|
||||
func addBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@@ -299,7 +274,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return newBookmark
|
||||
}
|
||||
|
||||
/// 切换当前位置的书签状态:已存在则移除,不存在则添加。
|
||||
@discardableResult
|
||||
func toggleBookmark(note: String? = nil) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@@ -315,7 +289,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return addBookmark(note: note)
|
||||
}
|
||||
|
||||
/// 根据 ID 删除书签并持久化。
|
||||
@discardableResult
|
||||
func removeBookmark(id: String) -> RDEPUBBookmark? {
|
||||
guard let controller else { return nil }
|
||||
@@ -327,7 +300,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return removed
|
||||
}
|
||||
|
||||
/// 跳转到指定书签所在位置。
|
||||
@discardableResult
|
||||
func go(toBookmarkID id: String, animated: Bool = true) -> Bool {
|
||||
guard let controller else { return false }
|
||||
@@ -337,7 +309,6 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return controller.restoreReadingLocation(bookmark.location, animated: animated)
|
||||
}
|
||||
|
||||
/// 弹出书签管理器,支持查看、跳转和删除书签。
|
||||
func presentBookmarksManager() {
|
||||
guard let controller else { return }
|
||||
guard !controller.activeBookmarks.isEmpty else { return }
|
||||
@@ -374,7 +345,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: highlight.location.progression,
|
||||
lastProgression: highlight.location.lastProgression,
|
||||
fragment: highlight.location.fragment,
|
||||
rangeAnchor: highlight.location.rangeAnchor
|
||||
rangeAnchor: highlight.location.rangeAnchor,
|
||||
cfi: highlight.location.cfi,
|
||||
lastCFI: highlight.location.lastCFI,
|
||||
rangeCFI: highlight.location.rangeCFI
|
||||
)
|
||||
return RDEPUBHighlight(
|
||||
id: highlight.id,
|
||||
@@ -407,7 +381,21 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
}
|
||||
controller.delegate?.epubReader(controller, didUpdateHighlights: controller.activeHighlights)
|
||||
controller.updateReaderChrome()
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
refreshVisibleContentPreservingCurrentPage()
|
||||
}
|
||||
|
||||
private func refreshVisibleContentPreservingCurrentPage() {
|
||||
guard let controller else { return }
|
||||
let currentPage = controller.readerView.currentPage
|
||||
guard currentPage >= 0 else {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
return
|
||||
}
|
||||
|
||||
controller.readerView.reloadData()
|
||||
if controller.readerView.currentPage != currentPage {
|
||||
controller.readerView.transitionToPage(pageNum: currentPage, animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
private func presentAnnotationActionSheet(for selection: RDEPUBSelection) {
|
||||
@@ -511,6 +499,11 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
return false
|
||||
}
|
||||
|
||||
if let bookmarkCFI = bookmark.location.cfi,
|
||||
let locationCFI = location.cfi {
|
||||
return bookmarkCFI == locationCFI
|
||||
}
|
||||
|
||||
if let bookmarkAnchor = bookmark.location.rangeAnchor,
|
||||
let locationAnchor = location.rangeAnchor {
|
||||
return bookmarkAnchor == locationAnchor
|
||||
@@ -545,7 +538,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
@@ -559,7 +555,10 @@ final class RDEPUBReaderAnnotationCoordinator {
|
||||
progression: location.progression,
|
||||
lastProgression: location.lastProgression,
|
||||
fragment: location.fragment,
|
||||
rangeAnchor: location.rangeAnchor
|
||||
rangeAnchor: location.rangeAnchor,
|
||||
cfi: location.cfi,
|
||||
lastCFI: location.lastCFI,
|
||||
rangeCFI: location.rangeCFI
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器界面组装协调器:负责阅读器初次启动时的 UI 搭建。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 组装阅读器视图层次结构(readerView、loadingIndicator、errorLabel)
|
||||
/// - 注册内容视图类型
|
||||
/// - 处理外部纯文本图书的启动收尾逻辑
|
||||
final class RDEPUBReaderAssemblyCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -13,7 +7,6 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 组装阅读器界面:添加 readerView、loadingIndicator、errorLabel 到控制器视图,并配置顶部工具栏。
|
||||
func assembleInterface() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else { return }
|
||||
@@ -28,7 +21,6 @@ final class RDEPUBReaderAssemblyCoordinator {
|
||||
#endif
|
||||
}
|
||||
|
||||
/// 外部纯文本图书启动时,加载已保存的书签、高亮和阅读位置,完成分页收尾。
|
||||
func finishExternalTextBookLaunchIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let controller = context.controller,
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器 Chrome 协调器:负责顶部/底部工具栏的创建、更新和交互处理。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 创建并配置顶部工具栏(返回、书签按钮)
|
||||
/// - 创建并配置底部工具栏(目录、书签、高亮、设置按钮)
|
||||
/// - 同步工具栏的主题和状态
|
||||
/// - 弹出设置面板和目录面板
|
||||
/// - 处理返回按钮的关闭逻辑
|
||||
final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationControllerDelegate {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -19,14 +12,11 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
context.controller
|
||||
}
|
||||
|
||||
// MARK: - UIAdaptivePresentationControllerDelegate
|
||||
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
// 设置页面被用户下滑关闭时
|
||||
|
||||
context.runtime?.settingsPanelDidDisappear()
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图,绑定返回、搜索和书签切换回调。
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
@@ -41,7 +31,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
return toolView
|
||||
}
|
||||
|
||||
/// 创建底部工具栏视图,绑定目录、书签、高亮、设置等回调。
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
let toolView = RDEPUBReaderBottomToolView()
|
||||
toolView.onShowTableOfContents = { [weak self] in
|
||||
@@ -62,7 +51,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
return toolView
|
||||
}
|
||||
|
||||
/// 同步更新顶部和底部工具栏的主题、标题、按钮可用性等状态。
|
||||
func updateReaderChrome() {
|
||||
guard let controller else { return }
|
||||
let uiState = makeUIState()
|
||||
@@ -70,7 +58,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
updateSearchBar()
|
||||
}
|
||||
|
||||
/// 构建当前 UI 状态快照
|
||||
func makeUIState() -> RDEPUBReaderUIState {
|
||||
guard let controller else { return .empty }
|
||||
return RDEPUBReaderUIState(
|
||||
@@ -85,7 +72,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
)
|
||||
}
|
||||
|
||||
/// 将 UI 状态应用到顶部和底部工具栏
|
||||
func applyUIState(_ state: RDEPUBReaderUIState) {
|
||||
guard let controller else { return }
|
||||
controller.topToolView.apply(theme: controller.configuration.theme)
|
||||
@@ -107,18 +93,15 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller.bottomToolView.setHighlightsEnabled(state.canShowHighlights)
|
||||
}
|
||||
|
||||
/// 判断当前位置是否有书签
|
||||
private func hasBookmarkAtCurrentLocation() -> Bool {
|
||||
guard let controller else { return false }
|
||||
return context.runtime?.annotationCoordinator.currentBookmark() != nil
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
||||
func presentSettings() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsSettingsPanel else { return }
|
||||
|
||||
// 通知 Runtime 设置页面即将打开
|
||||
context.runtime?.settingsPanelWillAppear()
|
||||
|
||||
let settingsController = RDEPUBReaderSettingsViewController(
|
||||
@@ -147,7 +130,7 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller?.updateConfiguration { $0.theme = theme }
|
||||
}
|
||||
settingsController.onDismiss = { [weak self] in
|
||||
// 通知 Runtime 设置页面已关闭
|
||||
|
||||
self?.context.runtime?.settingsPanelDidDisappear()
|
||||
}
|
||||
|
||||
@@ -157,7 +140,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 弹出目录列表面板,支持点击跳转到指定章节。
|
||||
func presentTableOfContents() {
|
||||
guard let controller else { return }
|
||||
guard controller.configuration.showsTableOfContents else { return }
|
||||
@@ -184,7 +166,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 切换搜索栏的显示/隐藏状态。
|
||||
func toggleSearchBar() {
|
||||
guard let controller else { return }
|
||||
if controller.isSearchBarVisible {
|
||||
@@ -194,7 +175,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步搜索栏的主题和匹配计数。
|
||||
func updateSearchBar() {
|
||||
guard let controller else { return }
|
||||
controller.searchBarView.apply(theme: controller.configuration.theme)
|
||||
@@ -207,7 +187,6 @@ final class RDEPUBReaderChromeCoordinator: NSObject, UIAdaptivePresentationContr
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
close(controller)
|
||||
|
||||
@@ -1,65 +1,51 @@
|
||||
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?
|
||||
/// 后台解析完成的完整 BookPageMap,等待用户下次导航时应用。
|
||||
/// 避免后台解析完成时直接替换 map 导致当前阅读位置跳转。
|
||||
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||||
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
||||
|
||||
var lastMetadataParseWallClockMs: Int = 0
|
||||
/// 后台元数据解析使用的并发数。
|
||||
|
||||
var lastMetadataParseConcurrency: Int = 0
|
||||
/// 当前用户文本选区(对外只读语义,底层由 selectionState 推导)。
|
||||
|
||||
var currentSelection: RDEPUBSelection? {
|
||||
get { selectionState.selection }
|
||||
set {
|
||||
@@ -70,38 +56,30 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
}
|
||||
}
|
||||
/// 统一选区状态模型,收口所有选区相关状态变更。
|
||||
|
||||
var selectionState: RDEPUBSelectionState = .idle
|
||||
|
||||
// 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: - 初始化
|
||||
var persistence: RDEPUBReaderPersistence?
|
||||
|
||||
var epubURL: URL = URL(string: "about:blank")!
|
||||
|
||||
var isRepaginating: Bool = false
|
||||
|
||||
var didStartInitialLoad: Bool = false
|
||||
|
||||
var isExternalTextBook: Bool = false
|
||||
|
||||
var textFileURL: URL?
|
||||
|
||||
var textBookCache = RDEPUBTextBookCache()
|
||||
|
||||
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
|
||||
@@ -115,12 +93,10 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据当前配置生成阅读偏好设置。
|
||||
func currentPreferences() -> RDEPUBPreferences {
|
||||
configuration.makePreferences()
|
||||
}
|
||||
|
||||
/// 获取当前文本排版的单页尺寸,优先从 readerView 解析,兜底用布局上下文。
|
||||
func currentTextPageSize() -> CGSize {
|
||||
if Thread.isMainThread {
|
||||
let pageNum = (readerView?.currentPage ?? -1) >= 0 ? readerView?.currentPage : nil
|
||||
@@ -149,7 +125,6 @@ final class RDEPUBReaderContext {
|
||||
return dependencies.environment.fallbackViewportSize
|
||||
}
|
||||
|
||||
/// 根据当前配置生成文本渲染样式(字体、行距、颜色)。
|
||||
func currentTextRenderStyle() -> RDEPUBTextRenderStyle {
|
||||
let font = configuration.fontChoice.font(ofSize: configuration.fontSize)
|
||||
let lineSpacing = max(font.lineHeight * (configuration.lineHeightMultiple - 1), 4)
|
||||
@@ -161,7 +136,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 根据页面尺寸和配置生成文本排版参数。
|
||||
func currentTextLayoutConfig(pageSize: CGSize) -> RDEPUBTextLayoutConfig {
|
||||
return RDEPUBTextLayoutConfig(
|
||||
frameWidth: max(pageSize.width, 1),
|
||||
@@ -169,7 +143,7 @@ final class RDEPUBReaderContext {
|
||||
edgeInsets: configuration.reflowableContentInsets,
|
||||
numberOfColumns: configuration.numberOfColumns,
|
||||
columnGap: configuration.columnGap,
|
||||
// 小说正文更看重尽量铺满页面,避免页尾出现明显留白。
|
||||
|
||||
avoidOrphans: false,
|
||||
avoidWidows: false,
|
||||
avoidPageBreakInsideEnabled: true,
|
||||
@@ -179,48 +153,39 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前配置对应的文本渲染器实例。
|
||||
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)
|
||||
}
|
||||
@@ -252,8 +217,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用预计算的 contentHash 构建缓存键,避免重复读取 HTML 和计算 SHA-256。
|
||||
/// 后台批量解析必须走此版本。
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
|
||||
chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
@@ -262,8 +225,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用固定的渲染签名与预计算 contentHash 构建缓存键。
|
||||
/// 适合后台任务在启动时冻结分页参数后复用,避免 live context 漂移。
|
||||
func chapterCacheKey(
|
||||
forSpineIndex spineIndex: Int,
|
||||
precomputedContentHash: String,
|
||||
@@ -277,7 +238,6 @@ final class RDEPUBReaderContext {
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前渲染参数签名,所有章节共享同一值。
|
||||
func currentRenderSignature() -> String {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
@@ -311,36 +271,30 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
}
|
||||
|
||||
/// 工厂方法:创建纯文本图书构建器。
|
||||
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
|
||||
@@ -349,7 +303,6 @@ final class RDEPUBReaderContext {
|
||||
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
|
||||
@@ -358,42 +311,34 @@ final class RDEPUBReaderContext {
|
||||
.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()
|
||||
}
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
// RDEPUBReaderDependencies.swift
|
||||
// EPUB 阅读器依赖注入配置,定义显示环境协议与核心组件工厂
|
||||
|
||||
import UIKit
|
||||
|
||||
/// 阅读器显示环境协议,抽象屏幕亮度和视口尺寸以支持测试和多平台适配
|
||||
public protocol RDEPUBReaderDisplayEnvironment: AnyObject {
|
||||
/// 当前屏幕亮度,取值范围 0.0 ~ 1.0
|
||||
|
||||
var currentBrightness: CGFloat { get set }
|
||||
/// 后备视口尺寸,用于无法获取实际视图尺寸时的布局计算
|
||||
|
||||
var fallbackViewportSize: CGSize { get }
|
||||
}
|
||||
|
||||
/// 基于 UIScreen 的默认显示环境实现,直接读写系统屏幕亮度
|
||||
public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
public init() {}
|
||||
|
||||
@@ -25,29 +21,20 @@ public final class RDEPUBUIScreenEnvironment: RDEPUBReaderDisplayEnvironment {
|
||||
}
|
||||
}
|
||||
|
||||
/// EPUB 阅读器核心依赖容器,通过工厂闭包注入各组件以便替换和测试
|
||||
public struct RDEPUBReaderDependencies {
|
||||
/// 显示环境实例
|
||||
|
||||
public var environment: any RDEPUBReaderDisplayEnvironment
|
||||
/// EPUB 解析器工厂
|
||||
|
||||
public var makeParser: () -> RDEPUBParser
|
||||
/// 分页器工厂
|
||||
|
||||
public var makePaginator: () -> RDEPUBPaginator
|
||||
/// 富文本书籍构建器工厂
|
||||
|
||||
public var makeTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextBookCache?, RDEPUBTextLayoutConfig) -> RDEPUBTextBookBuilder
|
||||
/// 纯文本书籍构建器工厂
|
||||
|
||||
public var makePlainTextBookBuilder: (RDEPUBTextRenderer, RDEPUBTextLayoutConfig) -> RDPlainTextBookBuilder
|
||||
/// 文本渲染器工厂
|
||||
|
||||
public var makeTextRenderer: (RDEPUBTextRenderingEngine) -> RDEPUBTextRenderer
|
||||
|
||||
/// 初始化依赖容器
|
||||
/// - Parameters:
|
||||
/// - environment: 显示环境实例
|
||||
/// - makeParser: EPUB 解析器工厂闭包
|
||||
/// - makePaginator: 分页器工厂闭包
|
||||
/// - makeTextBookBuilder: 富文本书籍构建器工厂闭包
|
||||
/// - makePlainTextBookBuilder: 纯文本书籍构建器工厂闭包
|
||||
/// - makeTextRenderer: 文本渲染器工厂闭包
|
||||
public init(
|
||||
environment: any RDEPUBReaderDisplayEnvironment,
|
||||
makeParser: @escaping () -> RDEPUBParser,
|
||||
@@ -64,7 +51,6 @@ public struct RDEPUBReaderDependencies {
|
||||
self.makeTextRenderer = makeTextRenderer
|
||||
}
|
||||
|
||||
/// 默认生产环境依赖,使用系统屏幕环境和标准组件实现
|
||||
public static var live: RDEPUBReaderDependencies {
|
||||
RDEPUBReaderDependencies(
|
||||
environment: RDEPUBUIScreenEnvironment(),
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器加载协调器:负责 EPUB 文件的解析和出版物初始化。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 判断是否需要执行首次加载
|
||||
/// - 后台解析 EPUB 文件并构建 Publication 模型
|
||||
/// - 将解析结果应用到阅读器上下文并触发分页
|
||||
final class RDEPUBReaderLoadCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -13,7 +7,6 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 检查条件后启动首次加载,确保只执行一次且视图已布局。
|
||||
func startInitialLoadIfNeeded() {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView,
|
||||
@@ -26,7 +19,6 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
loadPublication()
|
||||
}
|
||||
|
||||
/// 后台解析 EPUB 文件,加载书签、高亮和阅读位置,完成后回调主线程。
|
||||
func loadPublication() {
|
||||
guard let controller = context.controller else { return }
|
||||
context.showLoading()
|
||||
@@ -65,7 +57,6 @@ final class RDEPUBReaderLoadCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 将解析完成的出版物应用到上下文,设置书签/高亮/会话,并触发分页。
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
|
||||
@@ -1,23 +1,14 @@
|
||||
import Foundation
|
||||
|
||||
/// EPUB 阅读器位置协调器:负责阅读位置的恢复、查询和持久化。
|
||||
///
|
||||
/// 职责:
|
||||
/// - 根据保存的位置恢复阅读进度
|
||||
/// - 获取当前可见页面的阅读位置
|
||||
/// - 从持久化存储加载已保存位置
|
||||
/// - 持久化当前位置并通知委托
|
||||
final class RDEPUBReaderLocationCoordinator {
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
/// 上次翻页时的 spineIndex,用于检测跨章翻页
|
||||
private var lastPageChangeSpineIndex: Int?
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置,返回是否成功跳转。
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
@@ -57,13 +48,11 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
}
|
||||
readerView.transitionToPage(pageNum: max(targetPageNumber - 1, 0), animated: animated)
|
||||
|
||||
// 记录翻页到 JumpSession
|
||||
recordPageChangeIfNeeded()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/// 获取当前可见页面对应的阅读位置。
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let readerView = context.readerView else {
|
||||
@@ -74,8 +63,7 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
if let location = controller.resolvedTextLocation(forPageNumber: pageNumber) {
|
||||
return location
|
||||
}
|
||||
// resolvedTextLocation 可能因章节数据未加载而返回 nil,
|
||||
// 通过 readingSession 的 activePages 构建回退位置
|
||||
|
||||
if let readingSession = context.readingSession,
|
||||
readingSession.activePages.indices.contains(readerView.currentPage) {
|
||||
return readingSession.fallbackLocation(
|
||||
@@ -87,7 +75,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return context.readingSession?.currentReadingLocation(bookIdentifier: context.currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 从持久化存储加载上次保存的阅读位置。
|
||||
func persistenceLocation() -> RDEPUBLocation? {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else {
|
||||
@@ -96,7 +83,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
return controller.persistence?.loadLocation(for: currentBookIdentifier)
|
||||
}
|
||||
|
||||
/// 持久化阅读位置,并通知委托更新目录项和书签状态。
|
||||
func persist(location: RDEPUBLocation) {
|
||||
guard let controller = context.controller,
|
||||
let currentBookIdentifier = context.currentBookIdentifier else { return }
|
||||
@@ -106,7 +92,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
controller.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 记录翻页到 JumpSession
|
||||
func recordPageChangeIfNeeded() {
|
||||
guard let runtime = context.runtime,
|
||||
let bookPageMap = context.bookPageMap,
|
||||
@@ -127,7 +112,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
|
||||
lastPageChangeSpineIndex = currentSpineIndex
|
||||
|
||||
// 检查是否应该结束 JumpSession
|
||||
let isIdle = context.secondsSinceLastUserNavigation() > 2.0
|
||||
if let endReason = runtime.jumpSessionManager.checkSessionEnd(
|
||||
currentSpineIndex: currentSpineIndex,
|
||||
@@ -137,7 +121,6 @@ final class RDEPUBReaderLocationCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置翻页状态(用于重新加载等场景)
|
||||
func resetPageChangeState() {
|
||||
lastPageChangeSpineIndex = nil
|
||||
}
|
||||
|
||||
+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,
|
||||
|
||||
@@ -1,65 +1,78 @@
|
||||
import UIKit
|
||||
|
||||
/// EPUB 阅读器运行时总协调器。
|
||||
/// 统一持有并分发给加载、分页、定位、搜索、工具栏、批注、视口监测等子协调器,
|
||||
/// 作为阅读器控制器的门面(Facade),简化外部调用。
|
||||
final class RDEPUBReaderRuntime {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
lazy var chapterRuntimeStore = RDEPUBChapterRuntimeStore()
|
||||
|
||||
lazy var summaryDiskCache = context.makeChapterSummaryDiskCache()
|
||||
|
||||
lazy var chapterLoader: RDEPUBChapterLoader = {
|
||||
let loader = RDEPUBChapterLoader(context: context)
|
||||
loader.setSummaryDiskCache(summaryDiskCache)
|
||||
return loader
|
||||
}()
|
||||
|
||||
lazy var pageResolver = RDEPUBPageResolver(context: context, store: chapterRuntimeStore)
|
||||
|
||||
lazy var loadCoordinator = RDEPUBReaderLoadCoordinator(context: context)
|
||||
|
||||
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
|
||||
|
||||
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
|
||||
|
||||
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
|
||||
|
||||
lazy var chromeCoordinator = RDEPUBReaderChromeCoordinator(context: context)
|
||||
|
||||
lazy var annotationCoordinator = RDEPUBReaderAnnotationCoordinator(context: context)
|
||||
|
||||
lazy var viewportMonitor = RDEPUBReaderViewportMonitor(context: context)
|
||||
|
||||
lazy var jumpSessionManager = RDEPUBJumpSessionManager(context: context)
|
||||
|
||||
lazy var backgroundPriorityManager = RDEPUBBackgroundPriorityManager(context: context)
|
||||
|
||||
lazy var backgroundCoverageStore = RDEPUBBackgroundCoverageStore(context: context)
|
||||
|
||||
lazy var reconciliationCoordinator = RDEPUBPageMapReconciliationCoordinator(context: context)
|
||||
|
||||
/// 设置页面是否打开
|
||||
var isSettingsPanelOpen: Bool = false
|
||||
|
||||
/// 标记需要在设置页面关闭后触发完整补全
|
||||
var needsFullRepaginationAfterSettingsClose: Bool = false
|
||||
|
||||
/// 设置页内当前章预览的代次,用于丢弃过期结果
|
||||
private var settingsPreviewGeneration: Int = 0
|
||||
/// 设置页内 preview 防抖任务,只保留最后一次请求。
|
||||
|
||||
private var pendingSettingsPreviewWorkItem: DispatchWorkItem?
|
||||
/// 设置页内 preview 防抖延迟,合并连续点击。
|
||||
|
||||
private let settingsPreviewDebounceDelay: TimeInterval = 0.2
|
||||
|
||||
private struct SettingsPreviewAnchor {
|
||||
|
||||
let spineIndex: Int
|
||||
|
||||
let href: String
|
||||
|
||||
let offset: Int
|
||||
}
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
chromeCoordinator.makeTopToolView()
|
||||
}
|
||||
|
||||
/// 创建底部工具栏视图
|
||||
func makeBottomToolView() -> RDEPUBReaderBottomToolView {
|
||||
chromeCoordinator.makeBottomToolView()
|
||||
}
|
||||
|
||||
/// 若尚未加载则启动首次加载流程
|
||||
func startInitialLoadIfNeeded() {
|
||||
loadCoordinator.startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 重新加载当前书籍,清空解析器、分页、批注等状态后从头初始化
|
||||
func reloadBook() {
|
||||
guard let readerView = context.readerView else { return }
|
||||
context.didStartInitialLoad = false
|
||||
@@ -80,20 +93,10 @@ final class RDEPUBReaderRuntime {
|
||||
startInitialLoadIfNeeded()
|
||||
}
|
||||
|
||||
/// 跳转到指定阅读位置
|
||||
/// - Parameters:
|
||||
/// - location: 目标位置
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 跳转是否成功
|
||||
func go(to location: RDEPUBLocation, animated: Bool = false) -> Bool {
|
||||
locationCoordinator.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
|
||||
/// 跳转到指定页码
|
||||
/// - Parameters:
|
||||
/// - pageNumber: 目标页码(从 1 开始)
|
||||
/// - animated: 是否动画过渡
|
||||
/// - Returns: 跳转是否成功
|
||||
@discardableResult
|
||||
func go(toPageNumber pageNumber: Int, animated: Bool = false) -> Bool {
|
||||
guard let controller = context.controller,
|
||||
@@ -134,7 +137,6 @@ final class RDEPUBReaderRuntime {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 清除当前选区
|
||||
func clearSelection() {
|
||||
annotationCoordinator.updateCurrentSelection(nil)
|
||||
}
|
||||
@@ -230,30 +232,25 @@ final class RDEPUBReaderRuntime {
|
||||
annotationCoordinator.handleSelectionMenuAction(action, selection: selection)
|
||||
}
|
||||
|
||||
/// 按关键词搜索全文
|
||||
func search(keyword: String) {
|
||||
searchCoordinator.search(keyword: keyword)
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
searchCoordinator.searchNext()
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
searchCoordinator.searchPrevious()
|
||||
}
|
||||
|
||||
/// 跳转到指定搜索匹配项
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
searchCoordinator.selectSearchMatch(at: index)
|
||||
}
|
||||
|
||||
/// 清除搜索状态
|
||||
func clearSearch() {
|
||||
searchCoordinator.clearSearch()
|
||||
}
|
||||
@@ -262,32 +259,26 @@ final class RDEPUBReaderRuntime {
|
||||
searchCoordinator.searchPresentation(for: page)
|
||||
}
|
||||
|
||||
/// 更新阅读器工具栏显示状态
|
||||
func updateReaderChrome() {
|
||||
chromeCoordinator.updateReaderChrome()
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板
|
||||
func presentSettings() {
|
||||
chromeCoordinator.presentSettings()
|
||||
}
|
||||
|
||||
/// 弹出目录面板
|
||||
func presentTableOfContents() {
|
||||
chromeCoordinator.presentTableOfContents()
|
||||
}
|
||||
|
||||
/// 处理返回操作
|
||||
func handleBackAction() {
|
||||
chromeCoordinator.handleBackAction()
|
||||
}
|
||||
|
||||
/// 启动 Publication 加载流程
|
||||
func loadPublication() {
|
||||
loadCoordinator.loadPublication()
|
||||
}
|
||||
|
||||
/// 将已解析的 Publication 应用到控制器
|
||||
func applyParsedPublication(
|
||||
parser: RDEPUBParser,
|
||||
publication: RDEPUBPublication,
|
||||
@@ -306,17 +297,14 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
/// 对 Publication 执行分页计算
|
||||
func paginatePublication(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用外部 TextBook 并恢复阅读位置
|
||||
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 应用分页快照并恢复阅读位置
|
||||
func applyPaginationSnapshot(
|
||||
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
|
||||
restoreLocation: RDEPUBLocation?
|
||||
@@ -332,21 +320,26 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
// 暂存完整 map,等用户下次导航时再应用,避免当前阅读位置跳转
|
||||
// 此时保留旧 map,用户看到的内容和页码完全不变
|
||||
if let pendingMap = context.pendingFullPageMap {
|
||||
let shouldKeepExisting =
|
||||
pendingMap.totalChapters > bookPageMap.totalChapters ||
|
||||
(pendingMap.totalChapters == bookPageMap.totalChapters &&
|
||||
pendingMap.totalPages >= bookPageMap.totalPages)
|
||||
if shouldKeepExisting {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
context.pendingFullPageMap = bookPageMap
|
||||
}
|
||||
|
||||
/// 用户导航时检查并应用待处理的完整 BookPageMap
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
guard let pendingMap = context.pendingFullPageMap,
|
||||
let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
// 如果正在重新分页,跳过本次检查,避免状态冲突
|
||||
guard !controller.isRepaginating else { return }
|
||||
|
||||
// 使用协调器判断是否允许接管
|
||||
let decision = reconciliationCoordinator.evaluateTakeover(
|
||||
candidatePageMap: pendingMap,
|
||||
candidateSegment: nil,
|
||||
@@ -364,13 +357,12 @@ final class RDEPUBReaderRuntime {
|
||||
applyFullPageMapReplacement(newPageMap, readerView: readerView, controller: controller)
|
||||
|
||||
case .expandWindow, .segmentReplace:
|
||||
// 这些情况在当前实现中不会发生,因为我们传入的是 candidatePageMap
|
||||
|
||||
RDEPUBBackgroundTrace.log("Reconciliation", "decision: unexpected segment decision")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用全量页图替换
|
||||
private func applyFullPageMapReplacement(
|
||||
_ newPageMap: RDEPUBBookPageMap,
|
||||
readerView: RDReaderView,
|
||||
@@ -380,12 +372,10 @@ final class RDEPUBReaderRuntime {
|
||||
|
||||
context.pendingFullPageMap = nil
|
||||
|
||||
// 替换 map 和快照
|
||||
context.textBook = nil
|
||||
context.bookPageMap = newPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newPageMap))
|
||||
|
||||
// 用位置在新 map 中重新解析正确的页码
|
||||
if let currentLocation {
|
||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
||||
let newPage = max(0, newPageNumber - 1)
|
||||
@@ -397,7 +387,6 @@ final class RDEPUBReaderRuntime {
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
// 检查是否应该结束 JumpSession(coverage-complete)
|
||||
if let currentLocation,
|
||||
let currentSpineIndex = context.normalizedSpineIndex(for: currentLocation),
|
||||
let activeSession = jumpSessionManager.activeSession {
|
||||
@@ -410,14 +399,12 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 完成分页流程并恢复阅读位置
|
||||
func finishPagination(restoreLocation: RDEPUBLocation?) {
|
||||
paginationCoordinator.finishPagination(restoreLocation: restoreLocation)
|
||||
}
|
||||
|
||||
/// 重新分页并保持当前阅读位置不变
|
||||
func repaginatePreservingCurrentLocation() {
|
||||
// 如果设置页面打开,只计算当前章节(热区)
|
||||
|
||||
if isSettingsPanelOpen {
|
||||
needsFullRepaginationAfterSettingsClose = true
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
@@ -427,7 +414,6 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 防抖调度设置页内的当前章 preview,只保留最后一次请求。
|
||||
private func scheduleSettingsPreviewRepagination() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
settingsPreviewGeneration += 1
|
||||
@@ -440,8 +426,12 @@ final class RDEPUBReaderRuntime {
|
||||
return
|
||||
}
|
||||
self.pendingSettingsPreviewWorkItem = nil
|
||||
let previewAnchor = self.captureSettingsPreviewAnchor()
|
||||
self.chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
self.repaginateCurrentChapterOnly(previewGeneration: previewGeneration)
|
||||
self.repaginateCurrentChapterOnly(
|
||||
previewGeneration: previewGeneration,
|
||||
previewAnchor: previewAnchor
|
||||
)
|
||||
}
|
||||
pendingSettingsPreviewWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(
|
||||
@@ -450,8 +440,34 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
/// 只重新计算当前章节(设置页面打开时使用)
|
||||
private func repaginateCurrentChapterOnly(previewGeneration: Int) {
|
||||
private func captureSettingsPreviewAnchor() -> SettingsPreviewAnchor? {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let readerView = context.readerView else { return nil }
|
||||
|
||||
let absolutePageIndex = readerView.currentPage
|
||||
guard absolutePageIndex >= 0,
|
||||
let spineIndex = bookPageMap.spineIndex(forAbsolutePage: absolutePageIndex),
|
||||
let localPageIndex = bookPageMap.localPageIndex(forAbsolutePage: absolutePageIndex),
|
||||
let chapter = chapterRuntimeStore.chapterData(for: spineIndex),
|
||||
chapter.pages.indices.contains(localPageIndex) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let page = chapter.pages[localPageIndex]
|
||||
let offset = page.contentRange.length > 0
|
||||
? page.contentRange.location
|
||||
: page.pageStartOffset
|
||||
return SettingsPreviewAnchor(
|
||||
spineIndex: spineIndex,
|
||||
href: chapter.href,
|
||||
offset: offset
|
||||
)
|
||||
}
|
||||
|
||||
private func repaginateCurrentChapterOnly(
|
||||
previewGeneration: Int,
|
||||
previewAnchor: SettingsPreviewAnchor?
|
||||
) {
|
||||
guard let bookPageMap = context.bookPageMap,
|
||||
let readerView = context.readerView else { return }
|
||||
|
||||
@@ -485,11 +501,18 @@ final class RDEPUBReaderRuntime {
|
||||
self.context.replaceActiveSnapshot(self.makeSnapshot(from: partialMap))
|
||||
readerView.reloadData()
|
||||
|
||||
if let targetPage = self.settingsPreviewTargetPage(
|
||||
in: chapter,
|
||||
for: previewAnchor
|
||||
) {
|
||||
readerView.transitionToPage(pageNum: targetPage, animated: false)
|
||||
return
|
||||
}
|
||||
|
||||
if let previewLocation,
|
||||
self.locationCoordinator.restoreReadingLocation(previewLocation, animated: false) {
|
||||
return
|
||||
}
|
||||
|
||||
readerView.transitionToPage(pageNum: 0, animated: false)
|
||||
|
||||
case .failure(let error):
|
||||
@@ -501,7 +524,37 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置页面即将打开
|
||||
private func settingsPreviewTargetPage(
|
||||
in chapter: RDEPUBRuntimeChapter,
|
||||
for anchor: SettingsPreviewAnchor?
|
||||
) -> Int? {
|
||||
guard let anchor,
|
||||
anchor.spineIndex == chapter.spineIndex,
|
||||
anchor.href == chapter.href,
|
||||
!chapter.pages.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let exactPage = chapter.pages.first(where: { page in
|
||||
let lowerBound = page.contentRange.location
|
||||
let upperBound = page.contentRange.location + page.contentRange.length
|
||||
if page.contentRange.length == 0 {
|
||||
return anchor.offset == lowerBound
|
||||
}
|
||||
return anchor.offset >= lowerBound && anchor.offset < upperBound
|
||||
}) {
|
||||
return exactPage.pageIndexInChapter
|
||||
}
|
||||
|
||||
if let nextPage = chapter.pages.first(where: { page in
|
||||
page.contentRange.location > anchor.offset
|
||||
}) {
|
||||
return nextPage.pageIndexInChapter
|
||||
}
|
||||
|
||||
return max(chapter.pages.count - 1, 0)
|
||||
}
|
||||
|
||||
func settingsPanelWillAppear() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
pendingSettingsPreviewWorkItem = nil
|
||||
@@ -510,13 +563,12 @@ final class RDEPUBReaderRuntime {
|
||||
settingsPreviewGeneration += 1
|
||||
}
|
||||
|
||||
/// 设置页面已关闭
|
||||
func settingsPanelDidDisappear() {
|
||||
pendingSettingsPreviewWorkItem?.cancel()
|
||||
pendingSettingsPreviewWorkItem = nil
|
||||
isSettingsPanelOpen = false
|
||||
settingsPreviewGeneration += 1
|
||||
// 如果在设置页面期间有配置变化,触发完整补全
|
||||
|
||||
if needsFullRepaginationAfterSettingsClose {
|
||||
needsFullRepaginationAfterSettingsClose = false
|
||||
RDEPUBBackgroundTrace.log("Runtime", "settingsPanelDidDisappear: triggering full repagination")
|
||||
@@ -524,17 +576,14 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/// 刷新当前可见内容,保持阅读位置不变
|
||||
func refreshVisibleContentPreservingLocation() {
|
||||
paginationCoordinator.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 重建外部 TextBook 数据
|
||||
func rebuildExternalTextBook() {
|
||||
paginationCoordinator.rebuildExternalTextBook()
|
||||
}
|
||||
|
||||
/// 恢复到指定阅读位置
|
||||
@discardableResult
|
||||
func restoreReadingLocation(
|
||||
_ location: RDEPUBLocation,
|
||||
@@ -548,17 +597,14 @@ final class RDEPUBReaderRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
/// 获取当前可见页面的阅读位置
|
||||
func currentVisibleLocation() -> RDEPUBLocation? {
|
||||
locationCoordinator.currentVisibleLocation()
|
||||
}
|
||||
|
||||
/// 获取当前视口签名快照
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
viewportMonitor.currentViewportSignature()
|
||||
}
|
||||
|
||||
/// 视口变化时检查是否需要重新分页
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
@@ -574,7 +620,6 @@ final class RDEPUBReaderRuntime {
|
||||
return false
|
||||
}
|
||||
|
||||
// 检查是否是远距跳转
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isDistantJump = if let current = currentSpineIndex {
|
||||
@@ -584,7 +629,7 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
if context.bookPageMap?.entry(forSpineIndex: targetSpineIndex) != nil {
|
||||
// 如果是远距跳转且目标已在当前窗口中,创建 JumpSession
|
||||
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
@@ -641,14 +686,13 @@ final class RDEPUBReaderRuntime {
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: partialMap))
|
||||
context.readerView?.reloadData()
|
||||
|
||||
// 远距跳转成功后创建 JumpSession
|
||||
if isDistantJump {
|
||||
jumpSessionManager.createSession(
|
||||
anchorSpineIndex: targetSpineIndex,
|
||||
reason: .tableOfContentsJump,
|
||||
totalSpineCount: publication.spine.count
|
||||
)
|
||||
// 添加温区锚点
|
||||
|
||||
backgroundPriorityManager.addWarmAnchor(spineIndex: targetSpineIndex)
|
||||
}
|
||||
|
||||
@@ -723,25 +767,23 @@ final class RDEPUBReaderRuntime {
|
||||
return
|
||||
}
|
||||
|
||||
// 确定当前阅读方向,优先向当前方向扩展
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
let isNearEnd = currentMap.totalPages - currentPageNumber <= minimumTrailingPages
|
||||
let isNearStart = currentPageNumber <= minimumTrailingPages
|
||||
|
||||
// 根据阅读方向决定扩展策略
|
||||
var spineIndicesToAppend: [Int] = []
|
||||
if isNearEnd {
|
||||
// 向后扩展
|
||||
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex ?? -1
|
||||
spineIndicesToAppend = Array(buildableSpineIndices.filter { $0 > lastKnownSpineIndex }.prefix(batchChapterCount))
|
||||
} else if isNearStart {
|
||||
// 向前扩展
|
||||
|
||||
let firstKnownSpineIndex = currentMap.entries.first?.spineIndex ?? Int.max
|
||||
let prependCandidates = buildableSpineIndices.filter { $0 < firstKnownSpineIndex }
|
||||
spineIndicesToAppend = Array(prependCandidates.suffix(batchChapterCount))
|
||||
} else {
|
||||
// 不在边界,不扩展
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
@@ -816,6 +858,40 @@ final class RDEPUBReaderRuntime {
|
||||
readerView.transitionToPage(pageNum: max(currentPageNumber - 1, 0), animated: false)
|
||||
}
|
||||
|
||||
func prefetchForwardChaptersAfterInitialOpen(anchorSpineIndex: Int, totalSpineCount: Int) {
|
||||
guard context.publication != nil else { return }
|
||||
|
||||
chapterRuntimeStore.setCurrentChapter(
|
||||
spineIndex: anchorSpineIndex,
|
||||
totalSpineCount: totalSpineCount,
|
||||
windowRadius: context.configuration.chapterWindowRadius
|
||||
)
|
||||
|
||||
let forwardTargets = chapterRuntimeStore.windowSpineIndices.filter { $0 > anchorSpineIndex }
|
||||
guard !forwardTargets.isEmpty else { return }
|
||||
|
||||
for spineIndex in forwardTargets {
|
||||
if chapterRuntimeStore.chapterData(for: spineIndex) != nil {
|
||||
appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
continue
|
||||
}
|
||||
|
||||
chapterRuntimeStore.addPrefetchTarget(spineIndex)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"initial open prefetch forward spine=\(spineIndex)"
|
||||
)
|
||||
chapterLoader.loadChapter(
|
||||
spineIndex: spineIndex,
|
||||
store: chapterRuntimeStore,
|
||||
priority: .prefetch
|
||||
) { [weak self] result in
|
||||
guard let self, case .success = result else { return }
|
||||
self.appendLoadedForwardChaptersToCurrentPageMapIfPossible()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearOnDemandPageModeState() {
|
||||
paginationCoordinator.cancelActiveMetadataParseWork()
|
||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
@@ -826,7 +902,6 @@ final class RDEPUBReaderRuntime {
|
||||
backgroundCoverageStore.clearAll()
|
||||
}
|
||||
|
||||
/// 处理内存警告
|
||||
func handleMemoryWarning() {
|
||||
let currentSpineIndex = locationCoordinator.currentVisibleLocation()
|
||||
.flatMap { context.normalizedSpineIndex(for: $0) }
|
||||
@@ -893,6 +968,76 @@ final class RDEPUBReaderRuntime {
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private func appendLoadedForwardChaptersToCurrentPageMapIfPossible() {
|
||||
guard let publication = context.publication,
|
||||
let currentMap = context.bookPageMap,
|
||||
let readerView = context.readerView,
|
||||
let lastKnownSpineIndex = currentMap.entries.last?.spineIndex else {
|
||||
return
|
||||
}
|
||||
|
||||
let buildableSpineIndices = publication.spine.indices.filter {
|
||||
let item = publication.spine[$0]
|
||||
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
||||
}
|
||||
|
||||
var appendedEntries: [RDEPUBBookPageMapEntry] = []
|
||||
for spineIndex in buildableSpineIndices where spineIndex > lastKnownSpineIndex {
|
||||
guard let chapter = chapterRuntimeStore.chapterData(for: spineIndex) else {
|
||||
break
|
||||
}
|
||||
appendedEntries.append(
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: chapter.spineIndex,
|
||||
href: chapter.href,
|
||||
title: chapter.title,
|
||||
pageCount: chapter.pages.count,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: chapter.chapterOffsetMap.fragmentOffsets
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
guard !appendedEntries.isEmpty else { return }
|
||||
|
||||
let existingEntries = currentMap.entries.map {
|
||||
RDEPUBBookPageMapEntry(
|
||||
spineIndex: $0.spineIndex,
|
||||
href: $0.href,
|
||||
title: $0.title,
|
||||
pageCount: $0.pageCount,
|
||||
absolutePageStart: 0,
|
||||
fragmentOffsets: $0.fragmentOffsets
|
||||
)
|
||||
}
|
||||
|
||||
var absolutePageStart = 0
|
||||
let newEntries = (existingEntries + appendedEntries).map { entry -> RDEPUBBookPageMapEntry in
|
||||
let normalizedEntry = RDEPUBBookPageMapEntry(
|
||||
spineIndex: entry.spineIndex,
|
||||
href: entry.href,
|
||||
title: entry.title,
|
||||
pageCount: entry.pageCount,
|
||||
absolutePageStart: absolutePageStart,
|
||||
fragmentOffsets: entry.fragmentOffsets
|
||||
)
|
||||
absolutePageStart += entry.pageCount
|
||||
return normalizedEntry
|
||||
}
|
||||
|
||||
let newMap = RDEPUBBookPageMap(entries: newEntries)
|
||||
guard newMap.totalPages > currentMap.totalPages else { return }
|
||||
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"Runtime",
|
||||
"appendLoadedForwardChapters chapters=\(newMap.totalChapters) pages=\(newMap.totalPages)"
|
||||
)
|
||||
|
||||
context.bookPageMap = newMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: newMap))
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
|
||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
let pages = bookPageMap.entries.flatMap { entry in
|
||||
(0..<entry.pageCount).map { localPageIndex in
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
/// 搜索协调器,负责管理全文搜索的执行、结果导航和搜索状态通知。
|
||||
final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -12,8 +12,6 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 按关键词执行全文搜索,匹配结果自动导航到首个命中位置
|
||||
/// - Parameter keyword: 搜索关键词
|
||||
func search(keyword: String) {
|
||||
guard let controller else { return }
|
||||
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
@@ -37,21 +35,16 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
/// 跳转到下一个搜索匹配项
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
func searchNext() -> Bool {
|
||||
advanceSearch(by: 1)
|
||||
}
|
||||
|
||||
/// 跳转到上一个搜索匹配项
|
||||
/// - Returns: 是否成功跳转
|
||||
@discardableResult
|
||||
func searchPrevious() -> Bool {
|
||||
advanceSearch(by: -1)
|
||||
}
|
||||
|
||||
/// 跳转到指定索引的搜索匹配项
|
||||
@discardableResult
|
||||
func selectSearchMatch(at index: Int) -> Bool {
|
||||
guard let controller else { return false }
|
||||
@@ -66,7 +59,6 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
return navigateToCurrentSearchMatch(animated: true)
|
||||
}
|
||||
|
||||
/// 清除搜索状态并刷新当前可见内容
|
||||
func clearSearch() {
|
||||
guard let controller else { return }
|
||||
controller.searchState = nil
|
||||
@@ -74,9 +66,6 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
controller.refreshVisibleContentPreservingLocation()
|
||||
}
|
||||
|
||||
/// 构建指定页面的搜索结果展示信息,用于高亮渲染
|
||||
/// - Parameter page: 目标页面
|
||||
/// - Returns: 搜索展示数据,若无搜索状态则返回 nil
|
||||
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
||||
guard let controller else { return nil }
|
||||
guard let searchState = controller.searchState,
|
||||
@@ -175,6 +164,7 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
|
||||
let progressionDenominator = max(fullLength - 1, 1)
|
||||
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
||||
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
||||
matches.append(
|
||||
RDEPUBSearchMatch(
|
||||
href: normalizedHref,
|
||||
@@ -183,7 +173,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
localMatchIndex: localMatchIndex,
|
||||
rangeLocation: foundRange.location,
|
||||
rangeLength: foundRange.length,
|
||||
rangeAnchor: chapterData.rangeAnchor(for: foundRange)
|
||||
rangeAnchor: rangeAnchor,
|
||||
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
||||
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
||||
)
|
||||
)
|
||||
|
||||
@@ -210,6 +202,7 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
title: runtimeChapter.title,
|
||||
attributedContent: runtimeChapter.typesetAttributedString,
|
||||
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
||||
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
|
||||
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
||||
pages: runtimeChapter.pages
|
||||
)
|
||||
@@ -266,7 +259,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
rangeAnchor: searchMatch.rangeAnchor,
|
||||
cfi: searchMatch.cfi,
|
||||
rangeCFI: searchMatch.rangeCFI
|
||||
)
|
||||
return controller.restoreReadingLocation(location, animated: animated)
|
||||
}
|
||||
@@ -298,7 +293,9 @@ final class RDEPUBReaderSearchCoordinator {
|
||||
progression: searchMatch.progression,
|
||||
lastProgression: searchMatch.progression,
|
||||
fragment: nil,
|
||||
rangeAnchor: searchMatch.rangeAnchor
|
||||
rangeAnchor: searchMatch.rangeAnchor,
|
||||
cfi: searchMatch.cfi,
|
||||
rangeCFI: searchMatch.rangeCFI
|
||||
)
|
||||
|
||||
if let textBook = controller.textBook, let publication = controller.publication {
|
||||
|
||||
@@ -1,30 +1,26 @@
|
||||
import Foundation
|
||||
|
||||
/// 阅读器 UI 状态模型:统一管理顶部/底部工具栏按钮的可用性和显示状态。
|
||||
///
|
||||
/// 解决问题:之前书签、高亮等按钮的状态分散在 ChromeCoordinator 和 AnnotationCoordinator 中,
|
||||
/// 导致状态更新入口不一致,容易出现不同步的情况。
|
||||
struct RDEPUBReaderUIState {
|
||||
/// 顶部书签按钮是否可用(当前有书籍标识时可用)
|
||||
|
||||
let canToggleBookmark: Bool
|
||||
/// 顶部书签按钮是否选中(当前位置已加书签时选中)
|
||||
|
||||
let hasBookmarkAtCurrentLocation: Bool
|
||||
/// 底部书签列表按钮是否可用(有书签数据时可用)
|
||||
|
||||
let canShowBookmarks: Bool
|
||||
/// 底部新建标注按钮是否可用(有选中文本时可用)
|
||||
|
||||
let canAddHighlight: Bool
|
||||
/// 底部高亮列表按钮是否可用(有高亮数据时可用)
|
||||
|
||||
let canShowHighlights: Bool
|
||||
/// 是否显示目录按钮
|
||||
|
||||
let showsTableOfContents: Bool
|
||||
/// 是否显示高亮相关按钮(新建标注和高亮列表)
|
||||
|
||||
let allowsHighlights: Bool
|
||||
/// 是否显示设置按钮
|
||||
|
||||
let showsSettingsPanel: Bool
|
||||
}
|
||||
|
||||
extension RDEPUBReaderUIState {
|
||||
/// 默认的空状态
|
||||
|
||||
static let empty = RDEPUBReaderUIState(
|
||||
canToggleBookmark: false,
|
||||
hasBookmarkAtCurrentLocation: false,
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import UIKit
|
||||
|
||||
/// 视口监测器,监听视图布局和屏幕旋转等视口变化事件,
|
||||
/// 检测变化是否显著,必要时触发重新分页或内容重建。
|
||||
final class RDEPUBReaderViewportMonitor {
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
private var lastAppliedViewportSignature: RDEPUBViewportSignature?
|
||||
|
||||
private var pendingViewportChangeReason: RDEPUBViewportChangeReason?
|
||||
|
||||
private var pendingPresentationRestoreLocation: RDEPUBLocation?
|
||||
|
||||
private var isWaitingForViewportTransitionCompletion = false
|
||||
|
||||
init(context: RDEPUBReaderContext) {
|
||||
@@ -18,7 +20,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 视图布局完成后检查视口是否发生变化,首次布局时触发初始加载
|
||||
func viewDidLayoutSubviews() {
|
||||
guard let controller else { return }
|
||||
guard let viewportSignature = currentViewportSignature() else { return }
|
||||
@@ -41,8 +42,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
handleViewportChangeIfNeeded(reason: .viewLayout, viewportSignature: viewportSignature)
|
||||
}
|
||||
|
||||
/// 屏幕旋转前捕获当前阅读位置,旋转完成后检测视口变化并处理
|
||||
/// - Parameter coordinator: 转场协调器
|
||||
func viewWillTransition(with coordinator: UIViewControllerTransitionCoordinator) {
|
||||
guard let controller else { return }
|
||||
guard controller.didStartInitialLoad else { return }
|
||||
@@ -57,7 +56,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 重置所有视口状态,用于重新加载书籍
|
||||
func resetForReload() {
|
||||
lastAppliedViewportSignature = currentViewportSignature()
|
||||
pendingViewportChangeReason = nil
|
||||
@@ -65,19 +63,16 @@ final class RDEPUBReaderViewportMonitor {
|
||||
isWaitingForViewportTransitionCompletion = false
|
||||
}
|
||||
|
||||
/// 消费并返回待恢复的阅读位置(一次性读取后清空)
|
||||
func consumePendingPresentationRestoreLocation() -> RDEPUBLocation? {
|
||||
defer { pendingPresentationRestoreLocation = nil }
|
||||
return pendingPresentationRestoreLocation
|
||||
}
|
||||
|
||||
/// 主动捕获当前阅读位置到待恢复队列,供后续视口变化后恢复使用
|
||||
func capturePendingPresentationRestoreLocation() {
|
||||
guard let controller else { return }
|
||||
pendingPresentationRestoreLocation = controller.currentVisibleLocation() ?? controller.persistenceLocation()
|
||||
}
|
||||
|
||||
/// 分页完成后处理挂起的视口变化(如有),避免分页过程中重复触发
|
||||
func processPendingChangeAfterPagination() {
|
||||
guard let pendingReason = pendingViewportChangeReason else { return }
|
||||
pendingViewportChangeReason = nil
|
||||
@@ -86,7 +81,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取当前视口签名,包含容器尺寸和安全区域信息
|
||||
func currentViewportSignature() -> RDEPUBViewportSignature? {
|
||||
guard let controller else { return nil }
|
||||
let containerSize = controller.readerView.bounds.size == .zero ? controller.view.bounds.size : controller.readerView.bounds.size
|
||||
@@ -102,7 +96,6 @@ final class RDEPUBReaderViewportMonitor {
|
||||
)
|
||||
}
|
||||
|
||||
/// 检测视口签名是否发生显著变化,若变化则触发重新分页或重建外部 TextBook
|
||||
func handleViewportChangeIfNeeded(
|
||||
reason: RDEPUBViewportChangeReason,
|
||||
viewportSignature: RDEPUBViewportSignature? = nil
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
// RDEPUBSelectionState.swift
|
||||
// 统一选区状态模型
|
||||
// 收口选区相关状态的冗余表达,降低 view 层与 controller 层各持有一份选区状态
|
||||
// 所带来的维护成本和时序问题。
|
||||
|
||||
import Foundation
|
||||
|
||||
/// 统一选区状态枚举
|
||||
/// 替代原先散落在 view/controller/coordinator 的 `currentSelection != nil` 判断
|
||||
enum RDEPUBSelectionState: Equatable {
|
||||
/// 无选区
|
||||
|
||||
case idle
|
||||
/// 用户正在拖拽选区(长按手势已开始,尚未松手)
|
||||
|
||||
case selecting(anchor: Int)
|
||||
/// 选区已完成(用户松手,有有效文本)
|
||||
|
||||
case selected(RDEPUBSelection)
|
||||
/// 正在执行选区菜单动作(拷贝/高亮/批注),动作完成后回到 idle
|
||||
|
||||
case committingAction(RDEPUBSelection, action: RDEPUBAnnotationMenuAction)
|
||||
|
||||
/// 当前是否有有效选区(selecting / selected / committingAction 均视为有选区)
|
||||
var hasSelection: Bool {
|
||||
switch self {
|
||||
case .idle:
|
||||
@@ -27,7 +20,6 @@ enum RDEPUBSelectionState: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
/// 当前选区数据(如有)
|
||||
var selection: RDEPUBSelection? {
|
||||
switch self {
|
||||
case .idle, .selecting:
|
||||
|
||||
Reference in New Issue
Block a user