feat: add in-reader search and restructure documentation
- Add RDEPUBReaderSearchBarView with animated show/hide, keyword navigation, and match counting integrated into the reader controller - Restructure docs: replace scattered design docs with consolidated BUSINESS_LOGIC.md and UML_CLASS_DIAGRAMS.md; update ARCHITECTURE.md - Add SearchTests and FanrenParseTimeTest; enhance LargeBookOnDemandTests - Add scripts/run_ui_regression.sh and summarize_ui_results.py for automated UI test execution and reporting Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
d20196ee34
commit
0e7c0577e3
+63
-11
@@ -10,6 +10,8 @@ import Foundation
|
||||
/// - 重建外部纯文本图书
|
||||
final class RDEPUBReaderPaginationCoordinator {
|
||||
private let backgroundInteractionCooldown: CFAbsoluteTime = 0.8
|
||||
/// 每 N 章刷新一次 pageMap,可通过修改此值实测调优。
|
||||
static var pageMapRefreshInterval: Int = 32
|
||||
|
||||
private unowned let context: RDEPUBReaderContext
|
||||
|
||||
@@ -80,6 +82,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard let controller = context.controller else { return }
|
||||
context.textBook = textBook
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
let snapshot = controller.nativeTextSnapshot(from: textBook)
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
@@ -99,6 +102,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
guard context.controller != nil else { return }
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.replaceActiveSnapshot(snapshot)
|
||||
|
||||
guard !snapshot.pages.isEmpty else {
|
||||
@@ -388,6 +392,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
let pageSize = context.currentTextPageSize()
|
||||
let layoutConfig = context.currentTextLayoutConfig(pageSize: pageSize)
|
||||
let style = context.currentTextRenderStyle()
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
let allBuildableIndices = allBuildableSpineIndices(in: publication)
|
||||
let summaryDiskCache = context.runtime?.summaryDiskCache
|
||||
let workerCount = max(1, context.configuration.metadataParsingConcurrency)
|
||||
@@ -397,10 +402,30 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
DispatchQueue.global(qos: .utility).async { [weak self] in
|
||||
guard let self else { return }
|
||||
guard context.controller != nil else { return }
|
||||
|
||||
// 预计算所有章节的 contentHash,避免后续重复读盘 + SHA-256
|
||||
let prewarmStart = CFAbsoluteTimeGetCurrent()
|
||||
var contentHashBySpineIndex: [Int: String] = [:]
|
||||
for spineIndex in allBuildableIndices {
|
||||
guard let href = publication.spine.indices.contains(spineIndex)
|
||||
? publication.spine[spineIndex].href : nil,
|
||||
let html = parser.htmlString(forRelativePath: href) else {
|
||||
contentHashBySpineIndex[spineIndex] = ""
|
||||
continue
|
||||
}
|
||||
contentHashBySpineIndex[spineIndex] = html.sha256Hex
|
||||
}
|
||||
let prewarmMs = Int((CFAbsoluteTimeGetCurrent() - prewarmStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log("MetadataParse", "prewarmHashMs=\(prewarmMs) chapters=\(allBuildableIndices.count)")
|
||||
|
||||
let catalog = allBuildableIndices.map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
key: context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHashBySpineIndex[spineIndex] ?? "",
|
||||
renderSignature: renderSignature
|
||||
),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
title: item.title
|
||||
@@ -438,6 +463,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
let wallClockStart = CFAbsoluteTimeGetCurrent()
|
||||
var totalRenderMs: Double = 0
|
||||
var totalWriteMs: Double = 0
|
||||
var totalMergeMs: Double = 0
|
||||
var completedChapters = 0
|
||||
var failedChapters = 0
|
||||
let timingLock = NSLock()
|
||||
@@ -447,6 +473,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
queue.qualityOfService = .utility
|
||||
queue.maxConcurrentOperationCount = workerCount
|
||||
|
||||
let refreshInterval = RDEPUBReaderPaginationCoordinator.pageMapRefreshInterval
|
||||
|
||||
for (offset, spineIndex) in uncachedSpineIndices.enumerated() {
|
||||
queue.addOperation {
|
||||
guard context.controller != nil,
|
||||
@@ -471,7 +499,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
let renderElapsed = (CFAbsoluteTimeGetCurrent() - renderStart) * 1000
|
||||
|
||||
let chapter = result.chapter
|
||||
let cacheKey = context.chapterCacheKey(forSpineIndex: spineIndex)
|
||||
let precomputedHash = contentHashBySpineIndex[spineIndex] ?? ""
|
||||
let cacheKey = context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedHash,
|
||||
renderSignature: renderSignature
|
||||
)
|
||||
let summary = RDEPUBChapterSummary(
|
||||
pageRanges: chapter.pages.map { .init(location: $0.contentRange.location, length: $0.contentRange.length) },
|
||||
pageCount: chapter.pages.count,
|
||||
@@ -500,17 +533,24 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
|
||||
guard let renderResult else { return }
|
||||
|
||||
var partialMap: RDEPUBBookPageMap?
|
||||
// 锁内只做写入和计数,快照数据后锁外构建 pageMap
|
||||
var snapshot: [Int: RDEPUBChapterSummary]?
|
||||
resultLock.lock()
|
||||
summariesBySpineIndex[spineIndex] = renderResult
|
||||
totalResolvedCount += 1
|
||||
if totalResolvedCount - lastAppliedCount >= 32 || totalResolvedCount == allBuildableIndices.count {
|
||||
if totalResolvedCount - lastAppliedCount >= refreshInterval || totalResolvedCount == allBuildableIndices.count {
|
||||
lastAppliedCount = totalResolvedCount
|
||||
partialMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||
snapshot = summariesBySpineIndex
|
||||
}
|
||||
resultLock.unlock()
|
||||
|
||||
if let partialMap {
|
||||
if let snapshot {
|
||||
let mergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let partialMap = self.buildPageMap(from: catalog, summaries: snapshot)
|
||||
let mergeElapsed = (CFAbsoluteTimeGetCurrent() - mergeStart) * 1000
|
||||
timingLock.lock()
|
||||
totalMergeMs += mergeElapsed
|
||||
timingLock.unlock()
|
||||
DispatchQueue.main.async {
|
||||
guard context.paginationToken == token,
|
||||
context.controller != nil else { return }
|
||||
@@ -532,6 +572,7 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
timingLock.lock()
|
||||
let renderTotal = Int(totalRenderMs)
|
||||
let writeTotal = Int(totalWriteMs)
|
||||
let mergeTotal = Int(totalMergeMs)
|
||||
let rendered = completedChapters
|
||||
let failed = failedChapters
|
||||
timingLock.unlock()
|
||||
@@ -539,7 +580,8 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"timing wallClockMs=\(wallClockMs) chapters=\(rendered) failed=\(failed) " +
|
||||
"renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||||
"prewarmHashMs=\(prewarmMs) renderTotalMs=\(renderTotal) writeTotalMs=\(writeTotal) " +
|
||||
"mergeTotalMs=\(mergeTotal) avgRenderMs=\(avgRenderMs) concurrency=\(workerCount)"
|
||||
)
|
||||
context.lastMetadataParseWallClockMs = wallClockMs
|
||||
context.lastMetadataParseConcurrency = workerCount
|
||||
@@ -550,10 +592,12 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
return
|
||||
}
|
||||
|
||||
let finalMergeStart = CFAbsoluteTimeGetCurrent()
|
||||
let pageMap = self.buildPageMap(from: catalog, summaries: summariesBySpineIndex)
|
||||
let finalMergeMs = Int((CFAbsoluteTimeGetCurrent() - finalMergeStart) * 1000)
|
||||
RDEPUBBackgroundTrace.log(
|
||||
"MetadataParse",
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages)"
|
||||
"complete chapters=\(pageMap.totalChapters) pages=\(pageMap.totalPages) finalMergeMs=\(finalMergeMs)"
|
||||
)
|
||||
|
||||
DispatchQueue.main.async {
|
||||
@@ -565,15 +609,23 @@ final class RDEPUBReaderPaginationCoordinator {
|
||||
}
|
||||
|
||||
private func restoreBookPageMapIfPossible(publication: RDEPUBPublication) -> RDEPUBBookPageMap? {
|
||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache else {
|
||||
guard let summaryDiskCache = context.runtime?.summaryDiskCache,
|
||||
let parser = context.parser else {
|
||||
return nil
|
||||
}
|
||||
let renderSignature = context.currentRenderSignature()
|
||||
let catalog = allBuildableSpineIndices(in: publication).map { spineIndex in
|
||||
let item = publication.spine[spineIndex]
|
||||
let href = item.href
|
||||
let contentHash = parser.htmlString(forRelativePath: href)?.sha256Hex ?? ""
|
||||
return (
|
||||
key: context.chapterCacheKey(forSpineIndex: spineIndex),
|
||||
key: context.chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHash,
|
||||
renderSignature: renderSignature
|
||||
),
|
||||
spineIndex: spineIndex,
|
||||
href: item.href,
|
||||
href: href,
|
||||
title: item.title
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user