新增全书字符统计与阅读百分比,并沉淀后台渲染流水线优化方案
- 新增 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:
co-authored by
Claude Fable 5
parent
71f4feb12f
commit
8b68975ee1
@@ -64,7 +64,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
chapterCFIMap: resolvedPage.chapter.chapterOffsetMap.cfiMap,
|
||||
chapterFragmentOffsets: resolvedPage.chapter.chapterOffsetMap.fragmentOffsets,
|
||||
highlights: textHighlights(for: resolvedPage.page),
|
||||
searchState: searchState(for: resolvedPage.page)
|
||||
searchState: searchState(for: resolvedPage.page),
|
||||
footerText: textProgressFooterText(for: resolvedPage.page)
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@@ -83,7 +84,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
contentView.configureLoading(
|
||||
pageNumber: pageNum + 1,
|
||||
totalPages: pageCountOfReaderView(readerView: readerView),
|
||||
configuration: configuration
|
||||
configuration: configuration,
|
||||
footerText: shouldShowTextStatisticsPlaceholder ? "计算中" : nil
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@@ -108,7 +110,8 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
chapterCFIMap: textBook.chapterData(for: page.href)?.chapter.cfiMap,
|
||||
chapterFragmentOffsets: textBook.chapterData(for: page.href)?.chapter.fragmentOffsets ?? [:],
|
||||
highlights: textHighlights(for: page),
|
||||
searchState: searchState(for: page)
|
||||
searchState: searchState(for: page),
|
||||
footerText: textProgressFooterText(for: page)
|
||||
)
|
||||
return contentView
|
||||
}
|
||||
@@ -136,6 +139,27 @@ extension RDEPUBReaderController: RDReaderDataSource, RDReaderPageProvider, RDRe
|
||||
: NSStringFromClass(RDEPUBTextContentView.self)
|
||||
}
|
||||
|
||||
private func textProgressFooterText(for page: RDEPUBTextPage) -> String? {
|
||||
guard let statistics = readerContext.textStatistics else {
|
||||
return shouldShowTextStatisticsPlaceholder ? "计算中" : nil
|
||||
}
|
||||
guard let percent = statistics.percent(
|
||||
spineIndex: page.spineIndex,
|
||||
chapterOffset: page.contentRange.location + page.contentRange.length
|
||||
) else {
|
||||
return "计算中"
|
||||
}
|
||||
return String(format: "%.2f%%", percent)
|
||||
}
|
||||
|
||||
private var shouldShowTextStatisticsPlaceholder: Bool {
|
||||
guard !readerContext.isExternalTextBook,
|
||||
let publication = readerContext.publication else {
|
||||
return false
|
||||
}
|
||||
return publication.readingProfile == .textReflowable
|
||||
}
|
||||
|
||||
private func textHighlights(for page: RDEPUBTextPage) -> [RDEPUBHighlight] {
|
||||
if let textBook,
|
||||
let chapterData = textBook.chapterData(for: page.href) {
|
||||
|
||||
@@ -357,6 +357,7 @@ public final class RDURLReaderController: UIViewController {
|
||||
"avoidOrphans=\(layoutConfig?.avoidOrphans == true ? 1 : 0)",
|
||||
"windowSize=\(readerController?.configuration.onDemandChapterWindowSize ?? epubConfiguration.onDemandChapterWindowSize)",
|
||||
"parseMs=\(readerController?.readerContext.lastMetadataParseWallClockMs ?? 0)",
|
||||
"textStatsMs=\(readerController?.readerContext.lastTextStatisticsWallClockMs ?? 0)",
|
||||
"parseConcurrency=\(readerController?.readerContext.lastMetadataParseConcurrency ?? 0)",
|
||||
"inspectable=\(inspectable ? 1 : 0)",
|
||||
"streamedResources=\(resourceMetrics.streamedResponses)",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
+39
-1
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -360,7 +360,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
chapterCFIMap: RDEPUBCFIMap? = nil,
|
||||
chapterFragmentOffsets: [String: Int] = [:],
|
||||
highlights: [RDEPUBHighlight] = [],
|
||||
searchState: RDEPUBSearchState? = nil
|
||||
searchState: RDEPUBSearchState? = nil,
|
||||
footerText: String? = nil
|
||||
) {
|
||||
loadingSpinner.stopAnimating()
|
||||
currentPage = page
|
||||
@@ -378,7 +379,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
pageNumberLabel.text = footerText ?? "\(pageNumber) / \(totalPages)"
|
||||
|
||||
if configureCoverIfNeeded(for: page) {
|
||||
#if canImport(DTCoreText)
|
||||
@@ -446,7 +447,8 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
func configureLoading(
|
||||
pageNumber: Int,
|
||||
totalPages: Int,
|
||||
configuration: RDEPUBReaderConfiguration
|
||||
configuration: RDEPUBReaderConfiguration,
|
||||
footerText: String? = nil
|
||||
) {
|
||||
currentPage = nil
|
||||
currentChapterCFIMap = nil
|
||||
@@ -463,7 +465,7 @@ final class RDEPUBTextContentView: UIView, UIGestureRecognizerDelegate, RDReader
|
||||
)
|
||||
backgroundColor = configuration.theme.contentBackgroundColor
|
||||
pageNumberLabel.textColor = configuration.theme.contentTextColor
|
||||
pageNumberLabel.text = "\(pageNumber) / \(totalPages)"
|
||||
pageNumberLabel.text = footerText ?? "\(pageNumber) / \(totalPages)"
|
||||
coverImageView.isHidden = true
|
||||
coverImageView.image = nil
|
||||
|
||||
|
||||
Reference in New Issue
Block a user