feat(reader): 实现大书快速进入与增量分页路径

- PaginationCoordinator: 新增 textReflowable 快速进入路径,支持增量章节构建
  与 staged 合并策略,避免整书一次性构建的主线程阻塞
- TextBookBuilder: 重构为支持单章构建与增量合并模式
- ChapterPageCounter: 优化分页计数逻辑,支持章节级分页缓存
- PageFrameFactory: 新增 CoreText 页面帧工厂,提升排版性能
- ReaderContext/ReaderRuntime: 适配增量分页状态管理

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-06-02 21:16:57 +08:00
co-authored by Claude Opus 4.7
parent 488350d956
commit c76beed03c
15 changed files with 984 additions and 268 deletions
@@ -9,6 +9,52 @@ import Foundation
/// -
/// -
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) {
@@ -29,38 +75,22 @@ final class RDEPUBReaderPaginationCoordinator {
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 {
let pageSize = controller.currentTextPageSize()
context.lastTextPaginationPageSize = pageSize
let layoutConfig = controller.currentTextLayoutConfig(pageSize: pageSize)
let builder = context.makeTextBookBuilder(layoutConfig: layoutConfig)
let renderStyle = controller.currentTextRenderStyle()
DispatchQueue.global(qos: .userInitiated).async { [weak controller] in
guard let controller else { return }
do {
let textBook = try builder.build(
parser: parser,
publication: publication,
pageSize: pageSize,
style: renderStyle
)
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.context.runtime?.applyTextBook(textBook, restoreLocation: restoreLocation)
}
} catch {
DispatchQueue.main.async {
guard self.context.paginationToken == token else { return }
self.context.handle(error: error)
}
}
}
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(),
@@ -71,6 +101,7 @@ final class RDEPUBReaderPaginationCoordinator {
}
let paginator = context.makePaginator()
print("[EPUB][Pagination] path=web-paginator")
context.paginator = paginator
paginator.calculate(
parser: parser,
@@ -91,6 +122,9 @@ final class RDEPUBReaderPaginationCoordinator {
///
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)
@@ -108,7 +142,7 @@ final class RDEPUBReaderPaginationCoordinator {
_ snapshot: (pages: [EPUBPage], chapters: [EPUBChapterInfo]),
restoreLocation: RDEPUBLocation?
) {
guard let controller = context.controller else { return }
guard context.controller != nil else { return }
context.textBook = nil
context.replaceActiveSnapshot(snapshot)
@@ -168,4 +202,351 @@ final class RDEPUBReaderPaginationCoordinator {
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"))
}
}