- 设置页打开时仅重新计算当前章节预览,关闭后触发完整补全 - 新增 preview 加载优先级,支持设置页内当前章预览 - 添加设置页防抖机制,合并连续字号/行距变更 - 支持下滑手势关闭设置页,通过 presentation delegate 通知运行时 - 重新分页时跳过导航和尺寸校验,避免状态冲突 - currentTextPageSize 线程安全优化,后台线程使用缓存尺寸 - 更新 CocoaPods 依赖 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
542 lines
22 KiB
Swift
542 lines
22 KiB
Swift
import Foundation
|
||
|
||
final class RDEPUBChapterLoader {
|
||
private unowned let context: RDEPUBReaderContext
|
||
private var summaryDiskCache: RDEPUBChapterSummaryDiskCache?
|
||
|
||
init(context: RDEPUBReaderContext) {
|
||
self.context = context
|
||
}
|
||
|
||
func setSummaryDiskCache(_ cache: RDEPUBChapterSummaryDiskCache) {
|
||
summaryDiskCache = cache
|
||
}
|
||
|
||
// MARK: - 请求优先级
|
||
|
||
enum LoadPriority {
|
||
case navigation // 前台导航:用户主动跳章,完成后检查导航目标队列
|
||
case preview // 设置预览:仅构建当前章并回调,不参与导航目标消费
|
||
case prefetch // 后台预取:±1 相邻章,完成后仅回填缓存 + 刷新快照
|
||
}
|
||
|
||
// 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 {
|
||
completion(.success(cached))
|
||
}
|
||
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 {
|
||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||
if diskSummary != nil {
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "磁盘摘要缓存命中 spine=\(spineIndex)")
|
||
} else {
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "缓存未命中 spine=\(spineIndex)")
|
||
}
|
||
} else {
|
||
diskSummary = nil
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "页数缓存命中 spine=\(spineIndex)")
|
||
}
|
||
let diskPageRanges = diskSummary?.pageRanges.map { $0.nsRange }
|
||
let availablePageRanges = precomputedPageRanges ?? diskPageRanges
|
||
|
||
do {
|
||
let chapter = try RDEPUBBackgroundTrace.measure(
|
||
"ChapterLoader",
|
||
"buildChapter spine=\(spineIndex) priority=\(priority) cachedRanges=\(availablePageRanges?.count ?? 0)"
|
||
) {
|
||
try self.buildChapter(
|
||
spineIndex: spineIndex,
|
||
availablePageRanges: availablePageRanges,
|
||
diskSummary: diskSummary
|
||
)
|
||
}
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter OK: spine=\(spineIndex) pages=\(chapter.pages.count)")
|
||
|
||
// 5. 回填缓存
|
||
store.insertChapter(chapter)
|
||
let pc = RDEPUBRuntimePageCount(
|
||
cacheKey: cacheKey,
|
||
spineIndex: spineIndex,
|
||
pageRanges: chapter.pageRanges,
|
||
pageCount: chapter.pages.count,
|
||
renderSignature: cacheKey.renderSignature
|
||
)
|
||
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
|
||
}
|
||
store.markBuilding(false)
|
||
DispatchQueue.main.async {
|
||
completion(.success(chapter))
|
||
}
|
||
|
||
case .preview:
|
||
store.markBuilding(false)
|
||
DispatchQueue.main.async {
|
||
completion(.success(chapter))
|
||
}
|
||
|
||
case .prefetch:
|
||
// 后台预取:仅回填缓存,标记预取目标完成
|
||
// 不触发跳章,不检查导航目标队列
|
||
store.removePrefetchTarget(spineIndex)
|
||
store.markBuilding(false)
|
||
DispatchQueue.main.async {
|
||
completion(.success(chapter))
|
||
}
|
||
}
|
||
} catch {
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "buildChapter FAILED: spine=\(spineIndex) error=\(error)")
|
||
store.markBuilding(false)
|
||
DispatchQueue.main.async {
|
||
completion(.failure(error))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - 同步加载入口(仅供 legacy 位置迁移使用)
|
||
|
||
/// 约束:
|
||
/// - 必须复用同一个 chapterLoadQueue,保持 WXRead 的单章串行语义
|
||
/// - 不允许恢复整书 RDEPUBTextBook
|
||
/// - 只允许从主线程或明确的非 chapterLoadQueue 上下文调用
|
||
/// - 调用前必须执行 store.assertNotOnChapterLoadQueue()
|
||
/// - 只在首次迁移且目标章未命中缓存时使用
|
||
func loadChapterSynchronouslyForMigration(
|
||
spineIndex: Int,
|
||
store: RDEPUBChapterRuntimeStore?
|
||
) throws -> RDEPUBRuntimeChapter {
|
||
if let cached = store?.chapterData(for: spineIndex) {
|
||
return cached
|
||
}
|
||
|
||
guard let store else {
|
||
throw RDEPUBChapterLoadError.missingParser
|
||
}
|
||
|
||
store.assertNotOnChapterLoadQueue()
|
||
|
||
var result: Result<RDEPUBRuntimeChapter, Error>?
|
||
let semaphore = DispatchSemaphore(value: 0)
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "sync request spine=\(spineIndex)")
|
||
store.chapterLoadQueue.async {
|
||
do {
|
||
result = try RDEPUBBackgroundTrace.measure(
|
||
"ChapterLoader",
|
||
"sync buildChapter spine=\(spineIndex)"
|
||
) {
|
||
try autoreleasepool { () -> Result<RDEPUBRuntimeChapter, Error> in
|
||
let cacheKey = self.makeCacheKey(spineIndex: spineIndex)
|
||
let precomputedPageRanges = store.pageCount(for: cacheKey)?.pageRanges
|
||
let diskSummary: RDEPUBChapterSummary?
|
||
if precomputedPageRanges == nil {
|
||
diskSummary = self.summaryDiskCache?.read(for: cacheKey)
|
||
} else {
|
||
diskSummary = nil
|
||
}
|
||
let chapter = try self.buildChapter(
|
||
spineIndex: spineIndex,
|
||
availablePageRanges: precomputedPageRanges ?? diskSummary?.pageRanges.map(\.nsRange),
|
||
diskSummary: diskSummary
|
||
)
|
||
store.insertChapter(chapter)
|
||
let pageCount = RDEPUBRuntimePageCount(
|
||
cacheKey: cacheKey,
|
||
spineIndex: spineIndex,
|
||
pageRanges: chapter.pageRanges,
|
||
pageCount: chapter.pages.count,
|
||
renderSignature: cacheKey.renderSignature
|
||
)
|
||
store.insertPageCount(pageCount, for: cacheKey)
|
||
return .success(chapter)
|
||
}
|
||
}
|
||
} catch {
|
||
result = .failure(error)
|
||
}
|
||
semaphore.signal()
|
||
}
|
||
semaphore.wait()
|
||
return try result!.get()
|
||
}
|
||
|
||
// MARK: - 单章构建(支持轻量缓存命中后跳过分页)
|
||
|
||
private func buildChapter(
|
||
spineIndex: Int,
|
||
availablePageRanges: [NSRange]?,
|
||
diskSummary: RDEPUBChapterSummary? = nil
|
||
) throws -> RDEPUBRuntimeChapter {
|
||
guard let parser = context.parser,
|
||
let publication = context.publication else {
|
||
throw RDEPUBChapterLoadError.missingParser
|
||
}
|
||
|
||
let pageSize = context.currentTextPageSize()
|
||
let style = context.currentTextRenderStyle()
|
||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||
|
||
if let pageRanges = availablePageRanges {
|
||
// ---- 轻量路径:pageCountCache 或 chapterSummaryDiskCache 命中 ----
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "轻量路径 spine=\(spineIndex) 缓存页数=\(pageRanges.count)")
|
||
return try buildChapterFromCachedPageRanges(
|
||
spineIndex: spineIndex,
|
||
pageRanges: pageRanges,
|
||
parser: parser,
|
||
publication: publication,
|
||
pageSize: pageSize,
|
||
style: style,
|
||
layoutConfig: layoutConfig,
|
||
diskSummary: diskSummary
|
||
)
|
||
}
|
||
|
||
// ---- 完整路径:无缓存,走全量渲染 + 分页 ----
|
||
RDEPUBBackgroundTrace.log("ChapterLoader", "完整路径 spine=\(spineIndex)")
|
||
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
|
||
guard let result = try builder.buildChapter(
|
||
parser: parser,
|
||
publication: publication,
|
||
spineIndex: spineIndex,
|
||
pageSize: pageSize,
|
||
style: style
|
||
) else {
|
||
throw RDEPUBChapterLoadError.emptyChapter(spineIndex: spineIndex)
|
||
}
|
||
|
||
return try assembleRuntimeChapter(
|
||
from: result.chapter,
|
||
spineIndex: spineIndex,
|
||
pageSize: pageSize,
|
||
layoutConfig: layoutConfig
|
||
)
|
||
}
|
||
|
||
// MARK: - 轻量路径:复用已有 pageRanges,跳过完整分页
|
||
|
||
private func buildChapterFromCachedPageRanges(
|
||
spineIndex: Int,
|
||
pageRanges: [NSRange],
|
||
parser: RDEPUBParser,
|
||
publication: RDEPUBPublication,
|
||
pageSize: CGSize,
|
||
style: RDEPUBTextRenderStyle,
|
||
layoutConfig: RDEPUBTextLayoutConfig,
|
||
diskSummary: RDEPUBChapterSummary? = nil
|
||
) throws -> RDEPUBRuntimeChapter {
|
||
let spineItem = publication.spine[spineIndex]
|
||
let href = spineItem.href
|
||
let title = spineItem.title ?? ""
|
||
let baseURL = parser.fileURL(forRelativePath: href)?.deletingLastPathComponent()
|
||
|
||
// 1. 只做 HTML → NSAttributedString 渲染,不做分页
|
||
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
|
||
href: href,
|
||
title: title,
|
||
rawHTML: try requireHTMLString(parser, href: href),
|
||
baseURL: baseURL,
|
||
style: style,
|
||
resourceResolver: publication.resourceResolver,
|
||
pageSize: pageSize,
|
||
layoutConfig: layoutConfig
|
||
)
|
||
let renderer = context.resolvedTextRenderer()
|
||
let rendered = try renderer.renderChapter(request: request)
|
||
|
||
let typesetString = NSMutableAttributedString(attributedString: rendered.attributedString)
|
||
RDEPUBTextRendererSupport.normalizeReadingAttributes(
|
||
in: typesetString, style: style, layoutConfig: layoutConfig
|
||
)
|
||
|
||
// 2. metadata 来源策略:
|
||
// - diskSummary 非空(磁盘路径命中):从摘要恢复完整 metadata
|
||
// - diskSummary 为空(pageCountCache 命中但没走磁盘):从 attributedString 属性推断
|
||
let metadataSource = diskSummary?.pageMetadataList
|
||
|
||
// 3. 直接用缓存的 pageRanges 构建 pages(跳过 CoreText 分页)
|
||
let pages = buildPagesFromRanges(
|
||
pageRanges: pageRanges,
|
||
typesetString: typesetString,
|
||
spineIndex: spineIndex,
|
||
href: href,
|
||
title: title,
|
||
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 }
|
||
)
|
||
|
||
return RDEPUBRuntimeChapter(
|
||
spineIndex: spineIndex,
|
||
href: href,
|
||
title: title,
|
||
sourceAttributedString: nil, // 轻量路径不保留原始 source,降低内存
|
||
typesetAttributedString: typesetString,
|
||
layouter: layouter,
|
||
pageRanges: pageRanges,
|
||
pages: pages,
|
||
chapterOffsetMap: offsetMap
|
||
)
|
||
}
|
||
|
||
// MARK: - 从缓存的 pageRanges 直接构建 RDEPUBTextPage 数组
|
||
|
||
private func buildPagesFromRanges(
|
||
pageRanges: [NSRange],
|
||
typesetString: NSAttributedString,
|
||
spineIndex: Int,
|
||
href: String,
|
||
title: String,
|
||
metadataSource: [RDEPUBChapterSummary.PageMetadataSummary]? = nil
|
||
) -> [RDEPUBTextPage] {
|
||
let totalPageCount = pageRanges.count
|
||
return pageRanges.enumerated().map { (pageIndex, range) in
|
||
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,
|
||
isLastPage: pageIndex == totalPageCount - 1
|
||
)
|
||
}
|
||
return RDEPUBTextPage(
|
||
absolutePageIndex: -1,
|
||
chapterIndex: 0,
|
||
spineIndex: spineIndex,
|
||
href: href,
|
||
chapterTitle: title,
|
||
pageIndexInChapter: pageIndex,
|
||
totalPagesInChapter: totalPageCount,
|
||
chapterContent: typesetString,
|
||
content: pageContent,
|
||
contentRange: range,
|
||
pageStartOffset: range.location,
|
||
pageEndOffset: range.location + range.length - 1,
|
||
metadata: metadata
|
||
)
|
||
}
|
||
}
|
||
|
||
// MARK: - 从 attributedString 的自定义属性推断页 metadata
|
||
|
||
private func inferPageMetadata(
|
||
from string: NSAttributedString,
|
||
range: NSRange,
|
||
isLastPage: Bool
|
||
) -> RDEPUBTextPageMetadata {
|
||
var attachmentRanges: [NSRange] = []
|
||
var attachmentKinds: [RDEPUBTextAttachmentKind] = []
|
||
var blockKinds: [RDEPUBTextBlockKind] = []
|
||
var semanticHints: [RDEPUBTextSemanticHint] = []
|
||
var attachmentPlacements: [RDEPUBTextAttachmentPlacement] = []
|
||
var trailingFragmentID: String? = nil
|
||
|
||
string.enumerateAttribute(.rdPageAttachmentKind, in: range, options: []) { value, attrRange, _ in
|
||
if let rawValue = value as? String,
|
||
let kind = RDEPUBTextAttachmentKind(rawValue: rawValue) {
|
||
attachmentRanges.append(attrRange)
|
||
attachmentKinds.append(kind)
|
||
}
|
||
}
|
||
string.enumerateAttribute(.rdPageBlockKind, in: range, options: []) { value, _, _ in
|
||
if let rawValue = value as? String,
|
||
let kind = RDEPUBTextBlockKind(rawValue: rawValue),
|
||
!blockKinds.contains(kind) {
|
||
blockKinds.append(kind)
|
||
}
|
||
}
|
||
string.enumerateAttribute(.rdPageSemanticHints, in: range, options: []) { value, _, _ in
|
||
if let rawValue = value as? String {
|
||
let hints = rawValue
|
||
.split(separator: ",")
|
||
.compactMap { RDEPUBTextSemanticHint(rawValue: String($0)) }
|
||
for hint in hints where !semanticHints.contains(hint) {
|
||
semanticHints.append(hint)
|
||
}
|
||
}
|
||
}
|
||
string.enumerateAttribute(.rdPageAttachmentPlacement, in: range, options: []) { value, _, _ in
|
||
if let rawValue = value as? String,
|
||
let placement = RDEPUBTextAttachmentPlacement(rawValue: rawValue),
|
||
!attachmentPlacements.contains(placement) {
|
||
attachmentPlacements.append(placement)
|
||
}
|
||
}
|
||
string.enumerateAttribute(.rdPageFragmentID, in: range, options: [.reverse]) { value, _, stop in
|
||
if let fid = value as? String {
|
||
trailingFragmentID = fid
|
||
stop.pointee = true
|
||
}
|
||
}
|
||
|
||
return RDEPUBTextPageMetadata(
|
||
breakReason: isLastPage ? .chapterEnd : .frameLimit,
|
||
blockRange: nil,
|
||
attachmentRanges: attachmentRanges,
|
||
attachmentKinds: attachmentKinds,
|
||
blockKinds: blockKinds,
|
||
semanticHints: semanticHints,
|
||
attachmentPlacements: attachmentPlacements,
|
||
trailingFragmentID: trailingFragmentID,
|
||
diagnostics: []
|
||
)
|
||
}
|
||
|
||
// MARK: - 从完整构建结果组装 RDEPUBRuntimeChapter
|
||
|
||
private func assembleRuntimeChapter(
|
||
from chapter: RDEPUBTextChapter,
|
||
spineIndex: Int,
|
||
pageSize: CGSize,
|
||
layoutConfig: RDEPUBTextLayoutConfig
|
||
) throws -> RDEPUBRuntimeChapter {
|
||
let layouter = RDEPUBTextLayouter(
|
||
attributedString: chapter.attributedContent,
|
||
pageSize: pageSize,
|
||
config: layoutConfig
|
||
)
|
||
|
||
let offsetMap = RDEPUBChapterOffsetMap(
|
||
fragmentOffsets: chapter.fragmentOffsets,
|
||
pageStartOffsets: chapter.pages.map { $0.pageStartOffset },
|
||
pageEndOffsets: chapter.pages.map { $0.pageEndOffset }
|
||
)
|
||
|
||
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,
|
||
renderSignature: cacheKey.renderSignature,
|
||
schemaVersion: RDEPUBChapterSummary.currentSchemaVersion,
|
||
chapterContentHash: cacheKey.chapterContentHash,
|
||
pageMetadataList: chapter.pages.map { .from($0.metadata) }
|
||
)
|
||
summaryDiskCache?.write(summary: summary, for: cacheKey)
|
||
|
||
return RDEPUBRuntimeChapter(
|
||
spineIndex: spineIndex,
|
||
href: chapter.href,
|
||
title: chapter.title,
|
||
sourceAttributedString: nil,
|
||
typesetAttributedString: chapter.attributedContent,
|
||
layouter: layouter,
|
||
pageRanges: pageRanges,
|
||
pages: chapter.pages,
|
||
chapterOffsetMap: offsetMap
|
||
)
|
||
}
|
||
|
||
// 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 = [
|
||
style.font.fontName,
|
||
"\(style.font.pointSize)",
|
||
"\(lineHeightMultiple)",
|
||
"\(style.lineSpacing)",
|
||
layoutConfig.cacheSignature,
|
||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||
].joined(separator: "|")
|
||
|
||
let contentHash = contentHashForSpineIndex(spineIndex)
|
||
|
||
return RDEPUBChapterCacheKey(
|
||
bookID: context.currentBookIdentifier ?? "",
|
||
spineIndex: spineIndex,
|
||
renderSignature: renderSignature,
|
||
chapterContentHash: contentHash
|
||
)
|
||
}
|
||
|
||
private func contentHashForSpineIndex(_ spineIndex: Int) -> String {
|
||
guard let parser = context.parser,
|
||
let publication = context.publication else { return "" }
|
||
let href = publication.spine[spineIndex].href
|
||
guard let html = parser.htmlString(forRelativePath: href) else { return "" }
|
||
return html.sha256Hex
|
||
}
|
||
|
||
private func requireHTMLString(_ parser: RDEPUBParser, href: String) throws -> String {
|
||
guard let html = parser.htmlString(forRelativePath: href) else {
|
||
throw RDEPUBChapterLoadError.emptyChapterHref(href)
|
||
}
|
||
return html
|
||
}
|
||
}
|
||
|
||
enum RDEPUBChapterLoadError: LocalizedError {
|
||
case missingParser
|
||
case emptyChapter(spineIndex: Int)
|
||
case emptyChapterHref(String)
|
||
|
||
var errorDescription: String? {
|
||
switch self {
|
||
case .missingParser:
|
||
return "章节加载失败:缺少解析上下文。"
|
||
case .emptyChapter(let spineIndex):
|
||
return "章节加载失败:第 \(spineIndex) 章无法生成分页内容。"
|
||
case .emptyChapterHref(let href):
|
||
return "章节加载失败:未找到章节资源 \(href)。"
|
||
}
|
||
}
|
||
}
|