新增全书字符统计与阅读百分比,并沉淀后台渲染流水线优化方案

- 新增 RDEPUBTextStatisticsCoordinator:后台单线程按章渲染统计全书字符数,footer 显示精确阅读百分比;统计未就绪时(含 PageMap 未覆盖的 loading 页)显示"计算中",不再回退旧页码
- 统计结果按 bookID + renderSignature + 章节内容哈希做磁盘缓存,布局/字号变化自动失效;缓存命中不创建 renderer
- 移除"0.8 秒翻页时间窗轮询让路"逻辑,后续由事件驱动的交互加载门替代(见方案文档 5.6 节)
- 新增 Doc/FeatureSolution/TEXT_STATISTICS_PIPELINE_OPTIMIZATION.md:三条渲染流水线收敛为"交互 + 生产者"、交互加载门、全局后台渲染预算池、后台渲染登记表、两阶段近似百分比、自适应并发的完整优化方案与验收标准

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-07-03 18:38:41 +09:00
co-authored by Claude Fable 5
parent 71f4feb12f
commit 8b68975ee1
14 changed files with 835 additions and 14 deletions
@@ -61,7 +61,8 @@ final class RDEPUBMetadataParseWorker {
cancellationController: RDEPUBMetadataParseCancellationController,
token: UUID,
parser: RDEPUBParser,
publication: RDEPUBPublication
publication: RDEPUBPublication,
targetSpineIndices: [Int]? = nil
) {
self.context = context
self.cancellationController = cancellationController
@@ -74,11 +75,19 @@ final class RDEPUBMetadataParseWorker {
self.layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
self.style = context.currentTextRenderStyle()
self.renderSignature = context.currentRenderSignature()
self.allBuildableIndices = publication.spine.indices.filter { index in
let buildableIndices = publication.spine.indices.filter { index in
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"))
}
if let targetSpineIndices, !targetSpineIndices.isEmpty {
let buildableSet = Set(buildableIndices)
self.allBuildableIndices = Self.uniquedPreservingOrder(
targetSpineIndices.filter { buildableSet.contains($0) }
)
} else {
self.allBuildableIndices = buildableIndices
}
self.summaryDiskCache = context.runtime?.summaryDiskCache
self.workerCount = max(1, context.configuration.metadataParsingConcurrency)
self.pageMapRefreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
@@ -523,4 +532,13 @@ final class RDEPUBMetadataParseWorker {
}
return builder.build()
}
private static func uniquedPreservingOrder(_ values: [Int]) -> [Int] {
var seen: Set<Int> = []
var result: [Int] = []
for value in values where seen.insert(value).inserted {
result.append(value)
}
return result
}
}
@@ -164,6 +164,7 @@ final class RDEPUBPageMapReconciliationCoordinator {
lastBuildableSpineIndex: Int
) -> RDEPUBPageMapTakeoverDecision {
let candidateIndices = Set(candidatePageMap.entries.map { $0.spineIndex })
let currentIndices = Set(currentWindow.entries.map { $0.spineIndex })
let currentEntries = currentWindow.entries.count
if let currentSpineIndex {
@@ -189,8 +190,8 @@ final class RDEPUBPageMapReconciliationCoordinator {
}
}
let isComplete = candidateIndices.count >= currentEntries
if isComplete {
let coversCurrentWindow = currentIndices.isSubset(of: candidateIndices)
if coversCurrentWindow {
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: fullReplace — candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries) candidatePages=\(candidatePageMap.totalPages) currentPages=\(currentWindow.totalPages)"
@@ -198,6 +199,10 @@ final class RDEPUBPageMapReconciliationCoordinator {
return .fullReplace(candidatePageMap)
}
RDEPUBBackgroundTrace.log(
"Reconciliation",
"evaluateFullPageMapTakeover: keepCurrentWindow — candidate does not cover currentWindow candidateChapters=\(candidateIndices.count) currentChapters=\(currentEntries)"
)
return .keepCurrentWindow
}
@@ -222,4 +227,4 @@ final class RDEPUBPageMapReconciliationCoordinator {
return indices
}
}
}
@@ -62,6 +62,11 @@ final class RDEPUBReaderContext {
set { state.bookPageMap = newValue }
}
var textStatistics: RDEPUBBookTextStatistics? {
get { state.textStatistics }
set { state.textStatistics = newValue }
}
var activeBookmarks: [RDEPUBBookmark] {
get { state.activeBookmarks }
set { state.activeBookmarks = newValue }
@@ -82,6 +87,11 @@ final class RDEPUBReaderContext {
set { state.paginationToken = newValue }
}
var textStatisticsToken: UUID {
get { state.textStatisticsToken }
set { state.textStatisticsToken = newValue }
}
var searchState: RDEPUBSearchState? {
get { state.searchState }
set { state.searchState = newValue }
@@ -107,6 +117,11 @@ final class RDEPUBReaderContext {
set { state.lastMetadataParseConcurrency = newValue }
}
var lastTextStatisticsWallClockMs: Int {
get { state.lastTextStatisticsWallClockMs }
set { state.lastTextStatisticsWallClockMs = newValue }
}
var currentSelection: RDEPUBSelection? {
get { state.currentSelection }
set { state.currentSelection = newValue }
@@ -20,7 +20,7 @@ final class RDEPUBReaderLoadCoordinator {
}
func loadPublication() {
guard let context, let controller = context.controller else { return }
guard let context else { return }
context.showLoading()
let loadToken = UUID()
context.paginationToken = loadToken
@@ -29,6 +29,7 @@ final class RDEPUBReaderPaginationCoordinator {
context.paginationToken = token
if publication.readingProfile == .textReflowable {
context.runtime?.startTextStatistics(parser: parser, publication: publication)
paginateTextPublication(
parser: parser,
publication: publication,
@@ -39,6 +40,8 @@ final class RDEPUBReaderPaginationCoordinator {
return
}
context.runtime?.cancelTextStatistics()
if publication.layout == .fixed {
let snapshot = readingSession.makePaginationSnapshot(
pageCounts: Array(repeating: 1, count: publication.spine.count),
@@ -213,12 +216,18 @@ final class RDEPUBReaderPaginationCoordinator {
totalSpineCount: publication.spine.count
)
let cancellationController = self.beginMetadataParseCancellationController(for: token)
let metadataCoverageSpineIndices = self.metadataCoverageSpineIndices(
anchorSpineIndex: runtimeChapter.spineIndex,
publication: publication,
initialChapters: initialChapters
)
let worker = RDEPUBMetadataParseWorker(
context: context,
cancellationController: cancellationController,
token: token,
parser: parser,
publication: publication
publication: publication,
targetSpineIndices: metadataCoverageSpineIndices
)
worker.start(token: token, restoreLocation: restoreLocation)
}
@@ -329,6 +338,35 @@ final class RDEPUBReaderPaginationCoordinator {
}
}
private func metadataCoverageSpineIndices(
anchorSpineIndex: Int,
publication: RDEPUBPublication,
initialChapters: [RDEPUBRuntimeChapter]
) -> [Int] {
let buildableSpineIndices = allBuildableSpineIndices(in: publication)
guard let anchorPosition = buildableSpineIndices.firstIndex(of: anchorSpineIndex) else {
return initialChapters.map(\.spineIndex)
}
let windowSize = RDEPUBReaderConfiguration.normalizedChapterWindowSize(
context.configuration.onDemandChapterWindowSize
)
let radius = windowSize / 2
let lowerPosition = max(anchorPosition - radius, 0)
let upperPosition = min(anchorPosition + radius, buildableSpineIndices.count - 1)
let windowSpineIndices = Array(buildableSpineIndices[lowerPosition...upperPosition])
return uniquedPreservingOrder(initialChapters.map(\.spineIndex) + windowSpineIndices)
}
private func uniquedPreservingOrder(_ values: [Int]) -> [Int] {
var seen: Set<Int> = []
var result: [Int] = []
for value in values where seen.insert(value).inserted {
result.append(value)
}
return result
}
private func allBuildableSpineIndices(in publication: RDEPUBPublication) -> [Int] {
publication.spine.indices.filter { isBuildableTextSpine(at: $0, in: publication) }
}
@@ -23,6 +23,8 @@ final class RDEPUBReaderRuntime {
lazy var paginationCoordinator = RDEPUBReaderPaginationCoordinator(context: context)
lazy var textStatisticsCoordinator = RDEPUBTextStatisticsCoordinator(context: context)
lazy var locationCoordinator = RDEPUBReaderLocationCoordinator(context: context)
lazy var searchCoordinator = RDEPUBReaderSearchCoordinator(context: context)
@@ -105,6 +107,7 @@ final class RDEPUBReaderRuntime {
context.readingSession = nil
context.textBook = nil
context.bookPageMap = nil
textStatisticsCoordinator.cancel()
context.pendingPageMapUpdates.removeAll()
context.activeBookmarks = []
context.activeHighlights = []
@@ -324,6 +327,14 @@ final class RDEPUBReaderRuntime {
paginationCoordinator.paginatePublication(restoreLocation: restoreLocation)
}
func startTextStatistics(parser: RDEPUBParser, publication: RDEPUBPublication) {
textStatisticsCoordinator.start(parser: parser, publication: publication)
}
func cancelTextStatistics() {
textStatisticsCoordinator.cancel()
}
func applyTextBook(_ textBook: RDEPUBTextBook, restoreLocation: RDEPUBLocation?) {
paginationCoordinator.applyTextBook(textBook, restoreLocation: restoreLocation)
}
@@ -361,6 +372,7 @@ final class RDEPUBReaderRuntime {
if isSettingsPanelOpen {
needsFullRepaginationAfterSettingsClose = true
paginationCoordinator.cancelActiveMetadataParseWork()
cancelTextStatistics()
scheduleSettingsPreviewRepagination()
} else {
paginationCoordinator.repaginatePreservingCurrentLocation()
@@ -16,6 +16,8 @@ final class RDEPUBReaderState {
var bookPageMap: RDEPUBBookPageMap?
var textStatistics: RDEPUBBookTextStatistics?
var activeBookmarks: [RDEPUBBookmark] = []
var activeHighlights: [RDEPUBHighlight] = []
@@ -24,6 +26,8 @@ final class RDEPUBReaderState {
var paginationToken = UUID()
var textStatisticsToken = UUID()
var searchState: RDEPUBSearchState?
var pendingPageMapUpdates: [RDEPUBPendingPageMapUpdate] = []
@@ -34,6 +38,8 @@ final class RDEPUBReaderState {
var lastMetadataParseConcurrency: Int = 0
var lastTextStatisticsWallClockMs: Int = 0
var selectionState: RDEPUBSelectionState = .idle
var isRepaginating: Bool = false
@@ -0,0 +1,431 @@
import UIKit
struct RDEPUBChapterTextStatistics {
let spineIndex: Int
let href: String
let characterCount: Int
let absoluteCharacterStart: Int
}
struct RDEPUBBookTextStatistics {
let chapters: [RDEPUBChapterTextStatistics]
let totalCharacters: Int
private let indexBySpine: [Int: Int]
init(chapters: [RDEPUBChapterTextStatistics]) {
self.chapters = chapters
self.totalCharacters = chapters.reduce(0) { $0 + max($1.characterCount, 0) }
self.indexBySpine = Dictionary(
uniqueKeysWithValues: chapters.enumerated().map { ($0.element.spineIndex, $0.offset) }
)
}
func progress(spineIndex: Int, chapterOffset: Int) -> Double? {
guard totalCharacters > 0,
let chapterIndex = indexBySpine[spineIndex] else {
return nil
}
let chapter = chapters[chapterIndex]
let safeChapterOffset = min(max(chapterOffset, 0), chapter.characterCount)
let absoluteOffset = chapter.absoluteCharacterStart + safeChapterOffset
return min(max(Double(absoluteOffset) / Double(totalCharacters), 0), 1)
}
func percent(spineIndex: Int, chapterOffset: Int) -> Double? {
guard let progress = progress(spineIndex: spineIndex, chapterOffset: chapterOffset) else {
return nil
}
return min(max(progress * 100, 0), 100)
}
}
final class RDEPUBTextStatisticsWorker {
private struct ChapterCharacterCount {
let spineIndex: Int
let href: String
let characterCount: Int
}
func calculate(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
renderSignature: String,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig,
rendererFactory: @escaping () -> RDEPUBTextRenderer,
diskCache: RDEPUBTextStatisticsDiskCache?,
maxConcurrency: Int,
cancellationController: RDEPUBTextStatisticsCancellationController
) -> RDEPUBBookTextStatistics? {
let buildableItems = publication.spine.enumerated().filter { _, item in
isBuildableTextSpine(item)
}
let queue = OperationQueue()
queue.name = "com.readview.epub.text-statistics"
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = max(1, maxConcurrency)
cancellationController.attach(queue: queue)
let lock = NSLock()
var counts: [ChapterCharacterCount] = []
for (spineIndex, item) in buildableItems {
let operation = BlockOperation()
operation.addExecutionBlock { [weak operation] in
guard !cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
let characterCount = self.characterCount(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
renderSignature: renderSignature,
item: item,
spineIndex: spineIndex,
style: style,
layoutConfig: layoutConfig,
rendererFactory: rendererFactory,
diskCache: diskCache,
cancellationController: cancellationController
)
guard !cancellationController.isCancelled,
operation?.isCancelled != true else {
return
}
lock.lock()
counts.append(
ChapterCharacterCount(
spineIndex: spineIndex,
href: item.href,
characterCount: characterCount
)
)
lock.unlock()
}
queue.addOperation(operation)
}
queue.waitUntilAllOperationsAreFinished()
guard !cancellationController.isCancelled else {
return nil
}
var entries: [RDEPUBChapterTextStatistics] = []
var absoluteCharacterStart = 0
for count in counts.sorted(by: { $0.spineIndex < $1.spineIndex }) {
entries.append(
RDEPUBChapterTextStatistics(
spineIndex: count.spineIndex,
href: count.href,
characterCount: count.characterCount,
absoluteCharacterStart: absoluteCharacterStart
)
)
absoluteCharacterStart += max(count.characterCount, 0)
}
let statistics = RDEPUBBookTextStatistics(chapters: entries)
return statistics.totalCharacters > 0 ? statistics : nil
}
private func characterCount(
parser: RDEPUBParser,
publication: RDEPUBPublication,
bookIdentifier: String,
renderSignature: String,
item: RDEPUBSpineItem,
spineIndex: Int,
style: RDEPUBTextRenderStyle,
layoutConfig: RDEPUBTextLayoutConfig,
rendererFactory: () -> RDEPUBTextRenderer,
diskCache: RDEPUBTextStatisticsDiskCache?,
cancellationController: RDEPUBTextStatisticsCancellationController
) -> Int {
autoreleasepool { () -> Int in
guard let html = parser.htmlString(forRelativePath: item.href) else {
return 0
}
let cacheKey = RDEPUBChapterCacheKey(
bookID: bookIdentifier,
spineIndex: spineIndex,
renderSignature: renderSignature,
chapterContentHash: html.rd_sha256Hex
)
if let cached = diskCache?.read(for: cacheKey) {
return cached.characterCount
}
guard !cancellationController.isCancelled else {
return 0
}
let renderer = rendererFactory()
let baseURL = parser.fileURL(forRelativePath: item.href)?.deletingLastPathComponent()
let request = RDEPUBTextRendererSupport.makeChapterRenderRequest(
href: item.href,
title: item.title,
rawHTML: html,
baseURL: baseURL,
style: style,
resourceResolver: publication.resourceResolver,
contentLanguageCode: publication.metadata.language,
pageSize: nil,
layoutConfig: layoutConfig
)
do {
let rendered = try renderer.renderChapter(request: request)
let count = rendered.attributedString.length
diskCache?.write(.init(characterCount: count), for: cacheKey)
return count
} catch {
let fallback = RDEPUBTextRendererSupport.fallbackAttributedString(for: html, style: style)
let count = fallback.length
diskCache?.write(.init(characterCount: count), for: cacheKey)
return count
}
}
}
private func isBuildableTextSpine(_ item: RDEPUBSpineItem) -> Bool {
let mediaType = item.mediaType.lowercased()
return item.linear && (mediaType.contains("html") || mediaType.contains("xhtml"))
}
}
struct RDEPUBTextStatisticsCacheEntry: Codable {
let schemaVersion: Int
let characterCount: Int
init(characterCount: Int, schemaVersion: Int = Self.currentSchemaVersion) {
self.schemaVersion = schemaVersion
self.characterCount = characterCount
}
static let currentSchemaVersion = 1
}
final class RDEPUBTextStatisticsDiskCache {
private let cacheDirectory: URL
private let fileManager = FileManager.default
private let queue = DispatchQueue(label: "com.rdreader.textstatisticscache", qos: .utility)
init(bookIdentifier: String?) {
let cachesDirectory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
?? FileManager.default.temporaryDirectory
let bookID = (bookIdentifier ?? "default").rd_sha256Hex
self.cacheDirectory = cachesDirectory
.appendingPathComponent("RDEPUBTextStatisticsCache", isDirectory: true)
.appendingPathComponent(bookID, isDirectory: true)
try? fileManager.createDirectory(at: cacheDirectory, withIntermediateDirectories: true)
}
func read(for key: RDEPUBChapterCacheKey) -> RDEPUBTextStatisticsCacheEntry? {
let fileURL = fileURL(for: key)
guard let data = try? Data(contentsOf: fileURL),
let entry = try? JSONDecoder().decode(RDEPUBTextStatisticsCacheEntry.self, from: data),
entry.schemaVersion == RDEPUBTextStatisticsCacheEntry.currentSchemaVersion else {
return nil
}
return entry
}
func write(_ entry: RDEPUBTextStatisticsCacheEntry, for key: RDEPUBChapterCacheKey) {
queue.async {
self.writeImmediately(entry, for: key)
}
}
func flushPendingWrites() {
queue.sync { }
}
private func fileURL(for key: RDEPUBChapterCacheKey) -> URL {
let bookPrefix = Self.cacheNamespacePrefix(for: key.bookID)
let renderPrefix = Self.cacheNamespacePrefix(for: key.renderSignature)
let rawKey = "\(key.bookID)_\(key.spineIndex)_\(key.renderSignature)_\(key.chapterContentHash)"
let digest = rawKey.rd_sha256Hex
return cacheDirectory.appendingPathComponent("\(bookPrefix)__\(renderPrefix)__\(digest).json")
}
private func writeImmediately(_ entry: RDEPUBTextStatisticsCacheEntry, for key: RDEPUBChapterCacheKey) {
let fileURL = fileURL(for: key)
let tmpURL = fileURL.appendingPathExtension("tmp")
do {
let data = try JSONEncoder().encode(entry)
try data.write(to: tmpURL)
if fileManager.fileExists(atPath: fileURL.path) {
_ = try fileManager.replaceItemAt(fileURL, withItemAt: tmpURL)
} else {
try fileManager.moveItem(at: tmpURL, to: fileURL)
}
} catch {
try? fileManager.removeItem(at: tmpURL)
}
}
private static func cacheNamespacePrefix(for rawValue: String) -> String {
rawValue.rd_sha256Hex.prefix(12).lowercased()
}
}
final class RDEPUBTextStatisticsCancellationController {
private let lock = NSLock()
private var queue: OperationQueue?
private var cancelled = false
var isCancelled: Bool {
lock.lock()
defer { lock.unlock() }
return cancelled
}
func attach(queue: OperationQueue) {
lock.lock()
if cancelled {
queue.cancelAllOperations()
} else {
self.queue = queue
}
lock.unlock()
}
func cancel() {
lock.lock()
cancelled = true
let queue = self.queue
self.queue = nil
lock.unlock()
queue?.cancelAllOperations()
}
}
final class RDEPUBTextStatisticsCoordinator {
private weak var context: RDEPUBReaderContext?
private let worker = RDEPUBTextStatisticsWorker()
private let cancellationLock = NSLock()
private var activeCancellationController: RDEPUBTextStatisticsCancellationController?
init(context: RDEPUBReaderContext) {
self.context = context
}
func start(parser: RDEPUBParser, publication: RDEPUBPublication) {
guard let context else { return }
guard publication.readingProfile == .textReflowable else {
cancel()
return
}
let token = UUID()
cancelActiveWork()
context.textStatisticsToken = token
context.textStatistics = nil
context.lastTextStatisticsWallClockMs = 0
let snapshot = context.makeLayoutSnapshot()
let dependencies = context.dependencies
let renderingEngine = context.configuration.textRenderingEngine
let workerCount = 1
let bookIdentifier = context.currentBookIdentifier ?? ""
let diskCache = RDEPUBTextStatisticsDiskCache(bookIdentifier: context.currentBookIdentifier)
let cancellationController = RDEPUBTextStatisticsCancellationController()
setActiveCancellationController(cancellationController)
DispatchQueue.global(qos: .utility).async { [weak self, weak context] in
guard let self else { return }
let wallClockStart = CFAbsoluteTimeGetCurrent()
let statistics = self.worker.calculate(
parser: parser,
publication: publication,
bookIdentifier: bookIdentifier,
renderSignature: snapshot.renderSignature,
style: snapshot.style,
layoutConfig: snapshot.layoutConfig,
rendererFactory: { dependencies.makeTextRenderer(renderingEngine) },
diskCache: diskCache,
maxConcurrency: workerCount,
cancellationController: cancellationController
)
diskCache.flushPendingWrites()
let wallClockMs = Int((CFAbsoluteTimeGetCurrent() - wallClockStart) * 1000)
DispatchQueue.main.async {
guard let context,
context.textStatisticsToken == token,
!cancellationController.isCancelled else {
return
}
self.finishActiveCancellationController(cancellationController)
context.textStatistics = statistics
context.lastTextStatisticsWallClockMs = wallClockMs
print("[TextStatistics] chapters=\(statistics?.chapters.count ?? 0) totalCharacters=\(statistics?.totalCharacters ?? 0) concurrency=\(workerCount) elapsed=\(wallClockMs)ms")
context.readerView?.reloadPageCountOnly()
}
}
}
func cancel() {
guard let context else { return }
cancelActiveWork()
context.textStatisticsToken = UUID()
context.textStatistics = nil
context.lastTextStatisticsWallClockMs = 0
}
private func cancelActiveWork() {
cancellationLock.lock()
let controller = activeCancellationController
activeCancellationController = nil
cancellationLock.unlock()
controller?.cancel()
}
private func setActiveCancellationController(_ controller: RDEPUBTextStatisticsCancellationController) {
cancellationLock.lock()
activeCancellationController = controller
cancellationLock.unlock()
}
private func finishActiveCancellationController(_ controller: RDEPUBTextStatisticsCancellationController) {
cancellationLock.lock()
if activeCancellationController === controller {
activeCancellationController = nil
}
cancellationLock.unlock()
}
}