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
@@ -19,12 +19,15 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
context.controller
|
||||
}
|
||||
|
||||
/// 创建顶部工具栏视图,绑定返回和书签切换回调。
|
||||
/// 创建顶部工具栏视图,绑定返回、搜索和书签切换回调。
|
||||
func makeTopToolView() -> RDEPUBReaderTopToolView {
|
||||
let toolView = RDEPUBReaderTopToolView()
|
||||
toolView.onBack = { [weak self] in
|
||||
self?.handleBackAction()
|
||||
}
|
||||
toolView.onSearch = { [weak self] in
|
||||
self?.toggleSearchBar()
|
||||
}
|
||||
toolView.onToggleBookmark = { [weak self] in
|
||||
_ = self?.context.runtime?.toggleBookmark()
|
||||
}
|
||||
@@ -76,6 +79,7 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
controller.configuration.allowsHighlights && !controller.activeHighlights.isEmpty
|
||||
)
|
||||
controller.updateBookmarkChrome()
|
||||
updateSearchBar()
|
||||
}
|
||||
|
||||
/// 弹出阅读设置面板(字号、字体、行距、分栏、主题、亮度等)。
|
||||
@@ -140,6 +144,29 @@ final class RDEPUBReaderChromeCoordinator {
|
||||
controller.present(navigationController, animated: true)
|
||||
}
|
||||
|
||||
/// 切换搜索栏的显示/隐藏状态。
|
||||
func toggleSearchBar() {
|
||||
guard let controller else { return }
|
||||
if controller.isSearchBarVisible {
|
||||
controller.hideSearchBar()
|
||||
} else {
|
||||
controller.showSearchBar()
|
||||
}
|
||||
}
|
||||
|
||||
/// 同步搜索栏的主题和匹配计数。
|
||||
func updateSearchBar() {
|
||||
guard let controller else { return }
|
||||
controller.searchBarView.apply(theme: controller.configuration.theme)
|
||||
if let searchState = controller.searchState {
|
||||
if let index = searchState.currentMatchIndex {
|
||||
controller.searchBarView.updateMatchCount(current: index + 1, total: searchState.matches.count)
|
||||
} else if searchState.matches.isEmpty {
|
||||
controller.searchBarView.showNoResults()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理返回按钮点击,自动判断 pop 或 dismiss 方式关闭阅读器。
|
||||
func handleBackAction() {
|
||||
guard let controller else { return }
|
||||
|
||||
@@ -50,6 +50,9 @@ final class RDEPUBReaderContext {
|
||||
var paginator: RDEPUBPaginator?
|
||||
/// 全文搜索状态。
|
||||
var searchState: RDEPUBSearchState?
|
||||
/// 后台解析完成的完整 BookPageMap,等待用户下次导航时应用。
|
||||
/// 避免后台解析完成时直接替换 map 导致当前阅读位置跳转。
|
||||
var pendingFullPageMap: RDEPUBBookPageMap?
|
||||
/// 上次文本分页时的页面尺寸,用于检测是否需要重新分页。
|
||||
var lastTextPaginationPageSize: CGSize?
|
||||
/// 后台元数据解析耗时(毫秒),仅包含 OperationQueue 并行阶段。
|
||||
@@ -205,18 +208,6 @@ final class RDEPUBReaderContext {
|
||||
}
|
||||
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int) -> RDEPUBChapterCacheKey {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
let renderSignature = [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
|
||||
let contentHash: String
|
||||
if let parser,
|
||||
let publication,
|
||||
@@ -226,15 +217,53 @@ final class RDEPUBReaderContext {
|
||||
} else {
|
||||
contentHash = ""
|
||||
}
|
||||
return chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: contentHash,
|
||||
renderSignature: currentRenderSignature()
|
||||
)
|
||||
}
|
||||
|
||||
return RDEPUBChapterCacheKey(
|
||||
/// 使用预计算的 contentHash 构建缓存键,避免重复读取 HTML 和计算 SHA-256。
|
||||
/// 后台批量解析必须走此版本。
|
||||
func chapterCacheKey(forSpineIndex spineIndex: Int, precomputedContentHash: String) -> RDEPUBChapterCacheKey {
|
||||
chapterCacheKey(
|
||||
forSpineIndex: spineIndex,
|
||||
precomputedContentHash: precomputedContentHash,
|
||||
renderSignature: currentRenderSignature()
|
||||
)
|
||||
}
|
||||
|
||||
/// 使用固定的渲染签名与预计算 contentHash 构建缓存键。
|
||||
/// 适合后台任务在启动时冻结分页参数后复用,避免 live context 漂移。
|
||||
func chapterCacheKey(
|
||||
forSpineIndex spineIndex: Int,
|
||||
precomputedContentHash: String,
|
||||
renderSignature: String
|
||||
) -> RDEPUBChapterCacheKey {
|
||||
RDEPUBChapterCacheKey(
|
||||
bookID: currentBookIdentifier ?? "",
|
||||
spineIndex: spineIndex,
|
||||
renderSignature: renderSignature,
|
||||
chapterContentHash: contentHash
|
||||
chapterContentHash: precomputedContentHash
|
||||
)
|
||||
}
|
||||
|
||||
/// 当前渲染参数签名,所有章节共享同一值。
|
||||
func currentRenderSignature() -> String {
|
||||
let style = currentTextRenderStyle()
|
||||
let pageSize = currentTextPageSize()
|
||||
let layoutConfig = currentTextLayoutConfig(pageSize: pageSize)
|
||||
return [
|
||||
style.font.fontName,
|
||||
"\(style.font.pointSize)",
|
||||
"\(configuration.lineHeightMultiple)",
|
||||
"\(style.lineSpacing)",
|
||||
layoutConfig.cacheSignature,
|
||||
"\(RDEPUBChapterSummary.currentSchemaVersion)"
|
||||
].joined(separator: "|")
|
||||
}
|
||||
|
||||
func chapterSummary(forSpineIndex spineIndex: Int) -> RDEPUBChapterSummary? {
|
||||
runtime?.summaryDiskCache.read(for: chapterCacheKey(forSpineIndex: spineIndex))
|
||||
}
|
||||
|
||||
+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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ final class RDEPUBReaderRuntime {
|
||||
context.readingSession = nil
|
||||
context.textBook = nil
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
context.activeBookmarks = []
|
||||
context.activeHighlights = []
|
||||
context.searchState = nil
|
||||
@@ -312,31 +313,37 @@ final class RDEPUBReaderRuntime {
|
||||
}
|
||||
|
||||
func refreshBookPageMapInPlace(_ bookPageMap: RDEPUBBookPageMap) {
|
||||
guard let readerView = context.readerView,
|
||||
// 暂存完整 map,等用户下次导航时再应用,避免当前阅读位置跳转
|
||||
// 此时保留旧 map,用户看到的内容和页码完全不变
|
||||
context.pendingFullPageMap = bookPageMap
|
||||
}
|
||||
|
||||
/// 用户导航时检查并应用待处理的完整 BookPageMap
|
||||
func applyPendingFullPageMapIfNeeded() {
|
||||
guard let pendingMap = context.pendingFullPageMap,
|
||||
let readerView = context.readerView,
|
||||
let controller = context.controller else { return }
|
||||
|
||||
let currentPage = max(readerView.currentPage, 0)
|
||||
context.pendingFullPageMap = nil
|
||||
|
||||
// 保存当前位置(在旧 map 下解析)
|
||||
let currentLocation = locationCoordinator.currentVisibleLocation()
|
||||
|
||||
// 替换 map 和快照
|
||||
context.textBook = nil
|
||||
context.bookPageMap = bookPageMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: bookPageMap))
|
||||
context.bookPageMap = pendingMap
|
||||
context.replaceActiveSnapshot(makeSnapshot(from: pendingMap))
|
||||
|
||||
// 仅刷新总页数,不重建页面内容(避免后台元数据解析期间刷新掉用户选区)
|
||||
readerView.reloadPageCountOnly()
|
||||
|
||||
// 用户正在选区或正在滑动翻页时跳过页面跳转,避免打断交互
|
||||
let cv = readerView.collectionView
|
||||
let isUserInteracting = cv.isTracking || cv.isDragging || cv.isDecelerating
|
||||
if context.currentSelection == nil, !isUserInteracting, bookPageMap.totalPages > 0 {
|
||||
let maxValidPage = max(bookPageMap.totalPages - 1, 0)
|
||||
if currentPage > maxValidPage {
|
||||
readerView.transitionToPage(pageNum: maxValidPage, animated: false)
|
||||
}
|
||||
}
|
||||
// 用位置在新 map 中重新解析正确的页码
|
||||
if let currentLocation {
|
||||
locationCoordinator.persist(location: currentLocation)
|
||||
} else if let resolvedLocation = controller.resolvedTextLocation(forPageNumber: currentPage + 1) {
|
||||
locationCoordinator.persist(location: resolvedLocation)
|
||||
let newPageNumber = controller.pageNumber(for: currentLocation) ?? (readerView.currentPage + 1)
|
||||
let newPage = max(0, newPageNumber - 1)
|
||||
readerView.reloadPageCountOnly()
|
||||
if newPage != readerView.currentPage {
|
||||
readerView.transitionToPage(pageNum: newPage, animated: false)
|
||||
}
|
||||
} else {
|
||||
readerView.reloadPageCountOnly()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -530,6 +537,7 @@ final class RDEPUBReaderRuntime {
|
||||
func clearOnDemandPageModeState() {
|
||||
chapterRuntimeStore.invalidateAllForSettingsChange()
|
||||
context.bookPageMap = nil
|
||||
context.pendingFullPageMap = nil
|
||||
}
|
||||
|
||||
private func makeSnapshot(from bookPageMap: RDEPUBBookPageMap) -> RDEPUBReadingSession.PaginationSnapshot {
|
||||
|
||||
Reference in New Issue
Block a user