- 新增 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>
432 lines
15 KiB
Swift
432 lines
15 KiB
Swift
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()
|
|
}
|
|
}
|