Files
ReadViewSDK/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderPaginationCoordinator.swift
T
shenleiandClaude Opus 4.7 c76beed03c feat(reader): 实现大书快速进入与增量分页路径
- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建
  与 staged 合并策略,避免整书一次性构建的主线程阻塞
- TextBookBuilder: 重构为支持单章构建与增量合并模式
- ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存
- PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能
- ReaderContext/ReaderRuntime: 适配增量分页状态管理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 21:16:57 +08:00

553 lines
21 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
/// EPUB 阅读器分页协调器:负责出版物的分页计算和页面数据更新。
///
/// 职责:
/// - 根据出版物类型(文本重排/Fixed Layout/Web 内容)选择分页策略
/// - 后台构建文本图书模型并应用分页快照
/// - 重新分页时保持当前阅读位置
/// - 刷新可见内容并保持位置
/// - 重建外部纯文本图书
final class RDEPUBReaderPaginationCoordinator {
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
private let backgroundChapterPause: TimeInterval = 0.04
private let incrementalMergeChapterThreshold = 20
private let stagedIncrementalApplyDelay: TimeInterval = 1.0
private var stagedIncrementalApplyWorkItem: DispatchWorkItem?
private let stagedBookRequestLock = NSLock()
private var stagedBookRequest: StagedBookRequest?
private struct QuickBuildState {
var quickWindow: [Int]
var chapterStore: IncrementalChapterStore
var book: RDEPUBTextBook
}
private struct StagedBookRequest {
var chapterStore: IncrementalChapterStore
var orderedSpineIndices: [Int]
var restoreLocation: RDEPUBLocation?
var isComplete: Bool
}
private final class IncrementalChapterStore {
private let lock = NSLock()
private var builtChapters: [Int: RDEPUBTextChapter] = [:]
func insert(_ chapter: RDEPUBTextChapter, for spineIndex: Int) {
lock.lock()
builtChapters[spineIndex] = chapter
lock.unlock()
}
func contains(_ spineIndex: Int) -> Bool {
lock.lock()
let contains = builtChapters[spineIndex] != nil
lock.unlock()
return contains
}
func snapshot(orderedBy spineIndices: [Int]) -> [Int: RDEPUBTextChapter] {
lock.lock()
let chapters = builtChapters
lock.unlock()
return chapters
}
}
private unowned let context: RDEPUBReaderContext
init(context: RDEPUBReaderContext) {
self.context = context
}
/// 对出版物执行分页:文本重排走 TextBookBuilderFixed Layout 直接生成快照,Web 内容走 Paginator。
func paginatePublication(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let parser = context.parser,
let publication = context.publication,
let readingSession = context.readingSession else {
return
}
controller.isRepaginating = true
controller.errorLabel.isHidden = true
controller.showLoading()
let token = UUID()
context.paginationToken = token
print("[EPUB][Pagination] profile=\(publication.readingProfile.rawValue) layout=\(publication.layout.rawValue) spine=\(publication.spine.count)")
if publication.readingProfile == .textReflowable {
print("[EPUB][Pagination] path=text-reflowable-fast-entry")
paginateTextPublication(
parser: parser,
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation,
token: token
)
return
}
if publication.layout == .fixed {
print("[EPUB][Pagination] path=fixed-layout")
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
return
}
let paginator = context.makePaginator()
print("[EPUB][Pagination] path=web-paginator")
context.paginator = paginator
paginator.calculate(
parser: parser,
hostingView: controller.ensurePaginationHostView(),
presentation: controller.currentPreferences().presentationStyle(viewportSize: controller.currentLayoutContext().viewportSize)
) { [weak controller] pageCounts in
guard let controller, self.context.paginationToken == token else { return }
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: pageCounts,
preferences: controller.currentPreferences(),
layoutContext: controller.currentLayoutContext()
)
self.context.paginator = nil
self.context.runtime?.applyPaginationSnapshot(snapshot, restoreLocation: restoreLocation)
}
}
/// 应用文本图书模型:生成分页快照并完成分页流程。
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller else { return }
stagedIncrementalApplyWorkItem?.cancel()
stagedIncrementalApplyWorkItem = nil
clearStagedBookRequest()
context.textBook = textBook
let snapshot = controller.nativeTextSnapshot(from: textBook)
context.replaceActiveSnapshot(snapshot)
guard !textBook.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
/// 应用分页快照(Fixed Layout 或 Web 内容),并完成分页流程。
func applyPaginationSnapshot(
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
guard context.controller != nil else { return }
context.textBook = nil
context.replaceActiveSnapshot(snapshot)
guard !snapshot.pages.isEmpty else {
context.handle(error: RDEPUBParserError.emptySpine)
return
}
finishPagination(restoreLocation: restoreLocation)
}
/// 分页完成后的收尾:刷新视图、恢复阅读位置、处理待定视口变更。
func finishPagination(restoreLocation: RDEPUBLocation?) {
guard let controller = context.controller,
let readerView = context.readerView else { return }
controller.isRepaginating = false
controller.hideLoading()
readerView.reloadData()
if let targetLocation = restoreLocation {
controller.restoreReadingLocation(targetLocation)
} else {
readerView.transitionToPage(pageNum: 0)
context.readingSession?.transition(to: .idle)
}
context.runtime?.viewportMonitor.processPendingChangeAfterPagination()
}
/// 重新分页并保持当前阅读位置(优先使用待恢复位置,其次当前位置,最后持久化位置)。
func repaginatePreservingCurrentLocation() {
guard context.publication != nil else { return }
let restoreLocation = context.runtime?.viewportMonitor.consumePendingPresentationRestoreLocation()
?? context.currentVisibleLocation()
?? context.persistenceLocation()
paginatePublication(restoreLocation: restoreLocation)
}
/// 刷新可见内容并保持当前阅读位置不变。
func refreshVisibleContentPreservingLocation() {
guard let readerView = context.readerView else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
readerView.reloadData()
if let restoreLocation {
_ = context.restoreReadingLocation(restoreLocation)
}
}
/// 重建外部纯文本图书(布局变更后重新排版)。
func rebuildExternalTextBook() {
guard let controller = context.controller,
let textFileURL = controller.textFileURL else { return }
let restoreLocation = context.currentVisibleLocation() ?? context.persistenceLocation()
let pageSize = controller.currentTextPageSize()
let style = controller.currentTextRenderStyle()
let builder = context.makePlainTextBookBuilder(layoutConfig: controller.currentTextLayoutConfig(pageSize: pageSize))
if let newBook = try? builder.build(textFileURL: textFileURL, pageSize: pageSize, style: style) {
context.runtime?.applyTextBook(newBook, restoreLocation: restoreLocation)
}
}
/// 文本 EPUB 大书快速进入:先构建目标章节进入阅读器,再后台补齐完整 TextBook。
private func paginateTextPublication(
parser: RDEPUBParser,
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?,
token: UUID
) {
guard let controller = context.controller else { return }
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStyle = controller.currentTextRenderStyle()
let quickSpineCandidates = prioritizedBuildableSpineIndices(
publication: publication,
readingSession: readingSession,
restoreLocation: restoreLocation
)
DispatchQueue.global(qos: .utility).async { [weak controller] in
guard controller != nil else { return }
var didApplyQuickChapter = false
do {
self.waitForReadingInteractionToSettle()
let allBuildableIndices = self.allBuildableSpineIndices(in: publication)
let quickBuildState = try self.buildQuickTextBook(
builder: builder,
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle,
prioritizedCandidates: quickSpineCandidates
)
if let quickBook = quickBuildState?.book {
DispatchQueue.main.sync {
guard self.context.paginationToken == token else { return }
didApplyQuickChapter = true
self.context.runtime?.applyTextBook(quickBook, restoreLocation: restoreLocation)
}
}
let chapterStore = quickBuildState?.chapterStore ?? IncrementalChapterStore()
var pendingIncrementalCount = 0
let incrementalBuildOrder = self.incrementalBuildOrder(
allBuildableIndices: allBuildableIndices,
quickWindow: quickBuildState?.quickWindow ?? []
)
for spineIndex in incrementalBuildOrder where !chapterStore.contains(spineIndex) {
self.waitForReadingInteractionToSettle()
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: renderStyle
) else {
continue
}
chapterStore.insert(result.chapter, for: spineIndex)
pendingIncrementalCount += 1
Thread.sleep(forTimeInterval: self.backgroundChapterPause)
if pendingIncrementalCount >= self.incrementalMergeChapterThreshold {
pendingIncrementalCount = 0
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: false
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
}
}
}
self.stageBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: allBuildableIndices,
restoreLocation: restoreLocation,
isComplete: true
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.scheduleStagedIncrementalTextBookApplication()
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
if didApplyQuickChapter {
self.context.isRepaginating = false
self.context.hideLoading()
} else {
self.context.handle(error: error)
}
}
}
}
}
private func buildQuickTextBook(
builder: RDEPUBTextBookBuilder,
parser: RDEPUBParser,
publication: RDEPUBPublication,
pageSize: CGSize,
style: RDEPUBTextRenderStyle,
prioritizedCandidates: [Int]
) throws -> QuickBuildState? {
var anchorResult: RDEPUBTextChapterBuildResult?
for spineIndex in prioritizedCandidates {
guard let result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
), !result.chapter.pages.isEmpty else {
continue
}
anchorResult = result
break
}
guard let anchorResult else {
return nil
}
let quickWindow = quickWindowSpineIndices(
around: anchorResult.chapter.spineIndex,
in: publication
)
let chapterStore = IncrementalChapterStore()
for spineIndex in quickWindow {
let result: RDEPUBTextChapterBuildResult?
if spineIndex == anchorResult.chapter.spineIndex {
result = anchorResult
} else {
result = try builder.buildChapter(
parser: parser,
publication: publication,
spineIndex: spineIndex,
pageSize: pageSize,
style: style
)
}
guard let chapter = result?.chapter,
!chapter.pages.isEmpty else {
continue
}
chapterStore.insert(chapter, for: spineIndex)
}
guard let book = textBook(from: chapterStore.snapshot(orderedBy: quickWindow), orderedBy: quickWindow) else {
return nil
}
print("[EPUB][Pagination] quick-book chapters=\(book.chapters.count) pages=\(book.pages.count) anchor=\(anchorResult.chapter.href)")
return QuickBuildState(
quickWindow: quickWindow,
chapterStore: chapterStore,
book: book
)
}
private func prioritizedBuildableSpineIndices(
publication: RDEPUBPublication,
readingSession: RDEPUBReadingSession,
restoreLocation: RDEPUBLocation?
) -> [Int] {
let preferred = readingSession.initialSpineIndex(for: restoreLocation)
return publication.spine.indices
.filter { isBuildableTextSpine(at: $0, in: publication) }
.sorted { lhs, rhs in
abs(lhs - preferred) < abs(rhs - preferred)
}
}
private func quickWindowSpineIndices(
around anchorSpineIndex: Int,
in publication: RDEPUBPublication,
maxChapterCount: Int = 3
) -> [Int] {
let buildableIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableIndices.firstIndex(of: anchorSpineIndex) else {
return [anchorSpineIndex]
}
var selected: [Int] = [anchorSpineIndex]
var nextPosition = anchorPosition + 1
var previousPosition = anchorPosition - 1
while selected.count < maxChapterCount,
nextPosition < buildableIndices.count || previousPosition >= 0 {
if nextPosition < buildableIndices.count {
selected.append(buildableIndices[nextPosition])
nextPosition += 1
if selected.count == maxChapterCount {
break
}
}
if previousPosition >= 0 {
selected.insert(buildableIndices[previousPosition], at: 0)
previousPosition -= 1
}
}
return selected
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
func scheduleStagedIncrementalTextBookApplication() {
stagedIncrementalApplyWorkItem?.cancel()
let workItem = DispatchWorkItem { [weak self] in
self?.applyStagedIncrementalTextBookIfPossible()
}
stagedIncrementalApplyWorkItem = workItem
DispatchQueue.main.asyncAfter(deadline: .now() + stagedIncrementalApplyDelay, execute: workItem)
}
private func applyStagedIncrementalTextBookIfPossible() {
guard context.secondsSinceLastUserNavigation() >= backgroundInteractionCooldown,
!context.isRepaginating,
context.readingSession?.navigatorState == .idle else {
scheduleStagedIncrementalTextBookApplication()
return
}
guard let staged = consumeStagedBookRequest() else {
stagedIncrementalApplyWorkItem = nil
return
}
stagedIncrementalApplyWorkItem = nil
guard let stagedBook = textBook(
from: staged.chapterStore.snapshot(orderedBy: staged.orderedSpineIndices),
orderedBy: staged.orderedSpineIndices
) else {
return
}
let restoreLocation = context.currentVisibleLocation() ?? staged.restoreLocation
let logPrefix = staged.isComplete ? "full-book" : "applied-staged-book"
print("[EPUB][Pagination] \(logPrefix) chapters=\(stagedBook.chapters.count) pages=\(stagedBook.pages.count)")
context.runtime?.applyTextBook(stagedBook, restoreLocation: restoreLocation)
}
private func waitForReadingInteractionToSettle() {
while context.secondsSinceLastUserNavigation() < backgroundInteractionCooldown {
Thread.sleep(forTimeInterval: 0.08)
}
}
private func incrementalBuildOrder(
allBuildableIndices: [Int],
quickWindow: [Int]
) -> [Int] {
guard let firstWindowIndex = quickWindow.first,
let lastWindowIndex = quickWindow.last else {
return allBuildableIndices
}
let forward = allBuildableIndices.filter { $0 > lastWindowIndex }
let backward = allBuildableIndices.filter { $0 < firstWindowIndex }
return quickWindow + forward + backward
}
private func textBook(
from builtChapters: [Int: RDEPUBTextChapter],
orderedBy spineIndices: [Int]
) -> RDEPUBTextBook? {
var chapters: [RDEPUBTextChapter] = []
var pages: [RDEPUBTextPage] = []
for spineIndex in spineIndices {
guard var chapter = builtChapters[spineIndex],
!chapter.pages.isEmpty else {
continue
}
chapter.chapterIndex = chapters.count
let normalizedPages = chapter.pages.enumerated().map { localPageIndex, page -> RDEPUBTextPage in
var page = page
page.absolutePageIndex = pages.count + localPageIndex
page.chapterIndex = chapters.count
page.pageIndexInChapter = localPageIndex
page.totalPagesInChapter = chapter.pages.count
return page
}
chapter.pages = normalizedPages
chapters.append(chapter)
pages.append(contentsOf: normalizedPages)
}
guard !pages.isEmpty else {
return nil
}
return RDEPUBTextBook(chapters: chapters, pages: pages)
}
private func stageBookRequest(
chapterStore: IncrementalChapterStore,
orderedSpineIndices: [Int],
restoreLocation: RDEPUBLocation?,
isComplete: Bool
) {
stagedBookRequestLock.lock()
stagedBookRequest = StagedBookRequest(
chapterStore: chapterStore,
orderedSpineIndices: orderedSpineIndices,
restoreLocation: restoreLocation,
isComplete: isComplete
)
stagedBookRequestLock.unlock()
}
private func consumeStagedBookRequest() -> StagedBookRequest? {
stagedBookRequestLock.lock()
defer { stagedBookRequestLock.unlock() }
let request = stagedBookRequest
stagedBookRequest = nil
return request
}
private func clearStagedBookRequest() {
stagedBookRequestLock.lock()
stagedBookRequest = nil
stagedBookRequestLock.unlock()
}
private func isBuildableTextSpine(at index: Int, in publication: RDEPUBPublication) -> Bool {
guard publication.spine.indices.contains(index) else { return false }
let item = publication.spine[index]
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
}
}