ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/ChapterRuntime/RDEPUBChapterLoader.swift
shenlei 6ed3fcb071 feat: 设置面板与章节运行时联动优化
- 设置页打开时仅重新计算当前章节预览,关闭后触发完整补全
- 新增 preview 加载优先级,支持设置页内当前章预览
- 添加设置页防抖机制,合并连续字号/行距变更
- 支持下滑手势关闭设置页,通过 presentation delegate 通知运行时
- 重新分页时跳过导航和尺寸校验,避免状态冲突
- currentTextPageSize 线程安全优化,后台线程使用缓存尺寸
- 更新 CocoaPods 依赖

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-17 09:47:50 +08:00

542 lines
22 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)"
}
}
}