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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user