本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。 主要改动: 1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。 2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。 3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。 4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
414 lines
17 KiB
Swift
414 lines
17 KiB
Swift
import Foundation
|
|
|
|
final class RDEPUBReaderSearchCoordinator {
|
|
|
|
private unowned let context: RDEPUBReaderContext
|
|
|
|
private let searchQueue = DispatchQueue(label: "com.ssreaderview.epub.search", qos: .userInitiated)
|
|
|
|
private let tokenLock = NSLock()
|
|
private var _currentSearchToken: UUID = UUID()
|
|
|
|
private var currentSearchToken: UUID {
|
|
get {
|
|
tokenLock.lock()
|
|
defer { tokenLock.unlock() }
|
|
return _currentSearchToken
|
|
}
|
|
set {
|
|
tokenLock.lock()
|
|
_currentSearchToken = newValue
|
|
tokenLock.unlock()
|
|
}
|
|
}
|
|
|
|
init(context: RDEPUBReaderContext) {
|
|
self.context = context
|
|
}
|
|
|
|
private var controller: RDEPUBReaderController? {
|
|
context.controller
|
|
}
|
|
|
|
func search(keyword: String) {
|
|
guard let controller else { return }
|
|
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !normalizedKeyword.isEmpty else {
|
|
clearSearch()
|
|
return
|
|
}
|
|
|
|
let token = UUID()
|
|
currentSearchToken = token
|
|
|
|
let searchEngine = makeSearchEngine()
|
|
let layoutSnapshot = context.makeLayoutSnapshot()
|
|
|
|
searchQueue.async { [weak self] in
|
|
guard let self, self.currentSearchToken == token else { return }
|
|
let matches = self.performSearch(using: searchEngine, keyword: normalizedKeyword, layoutSnapshot: layoutSnapshot)
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self, self.currentSearchToken == token else { return }
|
|
guard let controller = self.context.controller else { return }
|
|
controller.searchState = RDEPUBSearchState(
|
|
keyword: normalizedKeyword,
|
|
matches: matches,
|
|
currentMatchIndex: matches.isEmpty ? nil : 0
|
|
)
|
|
self.notifySearchStateChanged()
|
|
if matches.isEmpty {
|
|
controller.refreshVisibleContentPreservingLocation()
|
|
} else {
|
|
_ = self.navigateToCurrentSearchMatch(animated: false)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
func searchNext() -> Bool {
|
|
advanceSearch(by: 1)
|
|
}
|
|
|
|
@discardableResult
|
|
func searchPrevious() -> Bool {
|
|
advanceSearch(by: -1)
|
|
}
|
|
|
|
@discardableResult
|
|
func selectSearchMatch(at index: Int) -> Bool {
|
|
guard let controller else { return false }
|
|
guard var searchState = controller.searchState,
|
|
searchState.matches.indices.contains(index) else {
|
|
return false
|
|
}
|
|
searchState.currentMatchIndex = index
|
|
controller.searchState = searchState
|
|
notifySearchStateChanged()
|
|
return navigateToCurrentSearchMatch(animated: true)
|
|
}
|
|
|
|
func clearSearch() {
|
|
currentSearchToken = UUID()
|
|
guard let controller else { return }
|
|
controller.searchState = nil
|
|
notifySearchStateChanged()
|
|
controller.refreshVisibleContentPreservingLocation()
|
|
}
|
|
|
|
func searchPresentation(for page: EPUBPage) -> RDEPUBSearchPresentation? {
|
|
guard let controller else { return nil }
|
|
guard let searchState = controller.searchState,
|
|
let publication = controller.publication else {
|
|
return nil
|
|
}
|
|
|
|
let pageHrefs: [String]
|
|
if let fixedSpread = page.fixedSpread {
|
|
pageHrefs = fixedSpread.resources.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
|
} else if publication.spine.indices.contains(page.spineIndex) {
|
|
pageHrefs = [
|
|
publication.resourceResolver.normalizedHref(publication.spine[page.spineIndex].href)
|
|
?? publication.spine[page.spineIndex].href
|
|
]
|
|
} else {
|
|
pageHrefs = []
|
|
}
|
|
|
|
guard !pageHrefs.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
let currentMatch = searchState.currentMatch
|
|
let normalizedCurrentHref = currentMatch.map { publication.resourceResolver.normalizedHref($0.href) ?? $0.href }
|
|
let resources = pageHrefs.map { href in
|
|
let matchCount = searchState.matches.filter {
|
|
(publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == href
|
|
}.count
|
|
let activeLocalMatchIndex = normalizedCurrentHref == href ? currentMatch?.localMatchIndex : nil
|
|
return RDEPUBSearchPresentationResource(
|
|
href: href,
|
|
matchCount: matchCount,
|
|
activeLocalMatchIndex: activeLocalMatchIndex
|
|
)
|
|
}
|
|
return RDEPUBSearchPresentation(keyword: searchState.keyword, resources: resources)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func makeSearchEngine() -> SearchEngineSnapshot? {
|
|
guard let controller else { return nil }
|
|
if let textBook = controller.textBook, let publication = controller.publication {
|
|
return .textBook(textBook, publication)
|
|
}
|
|
// External text books (e.g. plain .txt) carry a textBook but no
|
|
// publication/parser/bookPageMap; search over the chapter text
|
|
// directly using hrefs from the textBook itself.
|
|
if let textBook = controller.textBook {
|
|
return .externalTextBook(textBook)
|
|
}
|
|
if controller.readerContext.bookPageMap != nil, let publication = controller.publication {
|
|
return .onDemand(publication)
|
|
}
|
|
if let parser = controller.parser, let publication = controller.publication {
|
|
return .html(parser, publication)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func performSearch(using engine: SearchEngineSnapshot?, keyword: String, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
|
|
guard let engine else { return [] }
|
|
switch engine {
|
|
case .textBook(let textBook, let publication):
|
|
return RDEPUBTextSearchEngine(textBook: textBook, publication: publication).search(keyword: keyword)
|
|
case .externalTextBook(let textBook):
|
|
return RDEPUBTextSearchEngine.searchWithoutPublication(textBook: textBook, keyword: keyword)
|
|
case .onDemand(let publication):
|
|
return resolvedOnDemandSearchMatches(for: keyword, publication: publication, layoutSnapshot: layoutSnapshot)
|
|
case .html(let parser, let publication):
|
|
return RDEPUBHTMLSearchEngine(parser: parser, publication: publication).search(keyword: keyword)
|
|
}
|
|
}
|
|
|
|
private enum SearchEngineSnapshot {
|
|
case textBook(RDEPUBTextBook, RDEPUBPublication)
|
|
case externalTextBook(RDEPUBTextBook)
|
|
case onDemand(RDEPUBPublication)
|
|
case html(RDEPUBParser, RDEPUBPublication)
|
|
}
|
|
|
|
// H-09: Each chapter iteration is wrapped in autoreleasepool to release
|
|
// the chapter's typesetAttributedString memory between iterations.
|
|
private func resolvedOnDemandSearchMatches(for keyword: String, publication: RDEPUBPublication, layoutSnapshot: RDEPUBLayoutSnapshot?) -> [RDEPUBSearchMatch] {
|
|
let normalizedKeyword = keyword.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !normalizedKeyword.isEmpty else {
|
|
return []
|
|
}
|
|
|
|
let buildableSpineIndices = publication.spine.indices.filter { index in
|
|
let item = publication.spine[index]
|
|
return item.linear && (item.mediaType.contains("html") || item.mediaType.contains("xhtml"))
|
|
}
|
|
|
|
var matches: [RDEPUBSearchMatch] = []
|
|
for spineIndex in buildableSpineIndices {
|
|
let chapterMatches: [RDEPUBSearchMatch] = autoreleasepool {
|
|
guard let chapter = try? context.runtime?.chapterLoader.loadChapterSynchronouslyForMigration(
|
|
spineIndex: spineIndex,
|
|
store: context.runtime?.chapterRuntimeStore ?? RDEPUBChapterRuntimeStore(),
|
|
layoutSnapshot: layoutSnapshot
|
|
) else {
|
|
return []
|
|
}
|
|
|
|
let chapterData = makeChapterData(from: chapter, chapterIndex: chapter.pages.first?.chapterIndex ?? 0)
|
|
let source = chapter.typesetAttributedString.string as NSString
|
|
let fullLength = source.length
|
|
guard fullLength > 0 else { return [] }
|
|
|
|
let normalizedHref = publication.resourceResolver.normalizedHref(chapter.href) ?? chapter.href
|
|
var localMatches: [RDEPUBSearchMatch] = []
|
|
var localMatchIndex = 0
|
|
var searchRange = NSRange(location: 0, length: fullLength)
|
|
|
|
while searchRange.length > 0 {
|
|
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
|
guard foundRange.location != NSNotFound else {
|
|
break
|
|
}
|
|
|
|
let progressionDenominator = max(fullLength - 1, 1)
|
|
let progression = Double(foundRange.location) / Double(progressionDenominator)
|
|
let rangeAnchor = chapterData.rangeAnchor(for: foundRange)
|
|
localMatches.append(
|
|
RDEPUBSearchMatch(
|
|
href: normalizedHref,
|
|
progression: progression,
|
|
previewText: previewText(in: source, matchRange: foundRange),
|
|
localMatchIndex: localMatchIndex,
|
|
rangeLocation: foundRange.location,
|
|
rangeLength: max(foundRange.length, 1),
|
|
rangeAnchor: rangeAnchor,
|
|
cfi: chapterData.indexTable.cfi(for: rangeAnchor.start)?.rawValue,
|
|
rangeCFI: chapterData.indexTable.cfiRange(for: rangeAnchor)?.rawValue
|
|
)
|
|
)
|
|
|
|
localMatchIndex += 1
|
|
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
|
if nextLocation >= fullLength {
|
|
break
|
|
}
|
|
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
|
}
|
|
return localMatches
|
|
} // end autoreleasepool
|
|
matches.append(contentsOf: chapterMatches)
|
|
}
|
|
|
|
return matches
|
|
}
|
|
|
|
private func makeChapterData(
|
|
from runtimeChapter: RDEPUBRuntimeChapter,
|
|
chapterIndex: Int
|
|
) -> RDEPUBChapterData {
|
|
let textChapter = RDEPUBTextChapter(
|
|
chapterIndex: chapterIndex,
|
|
spineIndex: runtimeChapter.spineIndex,
|
|
href: runtimeChapter.href,
|
|
title: runtimeChapter.title,
|
|
attributedContent: runtimeChapter.typesetAttributedString,
|
|
fragmentOffsets: runtimeChapter.chapterOffsetMap.fragmentOffsets,
|
|
cfiMap: runtimeChapter.chapterOffsetMap.cfiMap,
|
|
pageBreakReasons: runtimeChapter.pages.map(\.metadata.breakReason),
|
|
pages: runtimeChapter.pages
|
|
)
|
|
return RDEPUBChapterData(
|
|
chapter: textChapter,
|
|
indexTable: RDEPUBTextIndexTable(chapters: [textChapter])
|
|
)
|
|
}
|
|
|
|
private func previewText(in text: NSString, matchRange: NSRange) -> String {
|
|
let previewRadius = 12
|
|
let start = max(matchRange.location - previewRadius, 0)
|
|
let end = min(matchRange.location + matchRange.length + previewRadius, text.length)
|
|
let range = NSRange(location: start, length: max(end - start, 0))
|
|
return text.substring(with: range).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
}
|
|
|
|
private func advanceSearch(by delta: Int) -> Bool {
|
|
guard let controller else { return false }
|
|
guard var searchState = controller.searchState, !searchState.matches.isEmpty else {
|
|
return false
|
|
}
|
|
|
|
let currentIndex = searchState.currentMatchIndex ?? 0
|
|
let nextIndex = (currentIndex + delta + searchState.matches.count) % searchState.matches.count
|
|
searchState.currentMatchIndex = nextIndex
|
|
controller.searchState = searchState
|
|
notifySearchStateChanged()
|
|
return navigateToCurrentSearchMatch(animated: true)
|
|
}
|
|
|
|
private func notifySearchStateChanged() {
|
|
guard let controller else { return }
|
|
let state = controller.searchState
|
|
controller.delegate?.epubReader(controller, didUpdateSearchResult: state?.result)
|
|
controller.delegate?.epubReader(controller, didChangeCurrentSearchMatch: state?.currentMatch)
|
|
}
|
|
|
|
private func navigateToCurrentSearchMatch(animated: Bool) -> Bool {
|
|
guard let controller else { return false }
|
|
guard let searchMatch = controller.searchState?.currentMatch else {
|
|
controller.refreshVisibleContentPreservingLocation()
|
|
return false
|
|
}
|
|
|
|
if let targetPageNumber = pageNumber(for: searchMatch),
|
|
controller.readerView.currentPage == targetPageNumber - 1 {
|
|
controller.refreshVisibleContentPreservingLocation()
|
|
return true
|
|
}
|
|
|
|
let location = RDEPUBLocation(
|
|
bookIdentifier: controller.currentBookIdentifier,
|
|
href: searchMatch.href,
|
|
progression: searchMatch.progression,
|
|
lastProgression: searchMatch.progression,
|
|
fragment: nil,
|
|
rangeAnchor: searchMatch.rangeAnchor,
|
|
cfi: searchMatch.cfi,
|
|
rangeCFI: searchMatch.rangeCFI
|
|
)
|
|
return controller.restoreReadingLocation(location, animated: animated)
|
|
}
|
|
|
|
private func pageNumber(for searchMatch: RDEPUBSearchMatch) -> Int? {
|
|
guard let controller else { return nil }
|
|
if let chapterData = controller.textChapterData(forNormalizedHref: searchMatch.href) {
|
|
if let exactPageNumber = exactPageNumber(
|
|
for: searchMatch,
|
|
in: chapterData,
|
|
keyword: controller.searchState?.keyword
|
|
) {
|
|
return exactPageNumber
|
|
}
|
|
|
|
if let pageNumber = chapterData.pageNumber(for: searchMatch) {
|
|
return pageNumber
|
|
}
|
|
|
|
if let rangeLocation = searchMatch.rangeLocation,
|
|
let page = chapterData.page(containing: rangeLocation) {
|
|
return page.absolutePageIndex + 1
|
|
}
|
|
}
|
|
|
|
let location = RDEPUBLocation(
|
|
bookIdentifier: controller.currentBookIdentifier,
|
|
href: searchMatch.href,
|
|
progression: searchMatch.progression,
|
|
lastProgression: searchMatch.progression,
|
|
fragment: nil,
|
|
rangeAnchor: searchMatch.rangeAnchor,
|
|
cfi: searchMatch.cfi,
|
|
rangeCFI: searchMatch.rangeCFI
|
|
)
|
|
|
|
if let textBook = controller.textBook {
|
|
if let publication = controller.publication {
|
|
return textBook.pageNumber(
|
|
for: location,
|
|
resolver: publication.resourceResolver,
|
|
bookIdentifier: controller.currentBookIdentifier
|
|
)
|
|
}
|
|
// External text books have no resolver; hrefs match verbatim.
|
|
return textBook.chapterData(for: location.href)?.pageNumber(for: location)
|
|
}
|
|
|
|
return controller.readingSession?.pageIndex(
|
|
for: location,
|
|
bookIdentifier: controller.currentBookIdentifier
|
|
).map { $0 + 1 }
|
|
}
|
|
|
|
private func exactPageNumber(
|
|
for searchMatch: RDEPUBSearchMatch,
|
|
in chapterData: RDEPUBChapterData,
|
|
keyword: String?
|
|
) -> Int? {
|
|
let normalizedKeyword = keyword?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
guard !normalizedKeyword.isEmpty else { return nil }
|
|
|
|
let source = chapterData.attributedContent.string as NSString
|
|
let fullLength = source.length
|
|
guard fullLength > 0 else { return nil }
|
|
|
|
var localMatchIndex = 0
|
|
var searchRange = NSRange(location: 0, length: fullLength)
|
|
|
|
while searchRange.length > 0 {
|
|
let foundRange = source.range(of: normalizedKeyword, options: [.caseInsensitive], range: searchRange)
|
|
guard foundRange.location != NSNotFound else { break }
|
|
|
|
if localMatchIndex == searchMatch.localMatchIndex,
|
|
let page = chapterData.page(containing: foundRange.location) {
|
|
return page.absolutePageIndex + 1
|
|
}
|
|
|
|
localMatchIndex += 1
|
|
let nextLocation = foundRange.location + max(foundRange.length, 1)
|
|
if nextLocation >= fullLength {
|
|
break
|
|
}
|
|
searchRange = NSRange(location: nextLocation, length: fullLength - nextLocation)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
} |