完善搜索定位与内存监测回归验证

本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。

主要改动:

1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。

2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。

3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。

4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
This commit is contained in:
shenlei
2026-07-09 18:46:04 +09:00
parent c4be426299
commit 83dfa40299
11 changed files with 338 additions and 89 deletions
@@ -25,8 +25,8 @@ extension RDEPUBReaderController {
).map { $0 + 1 }
}
if let textBook, let publication {
let normalizedLocation = publication.resourceResolver.normalizedLocation(
if let textBook {
let normalizedLocation = publication?.resourceResolver.normalizedLocation(
location,
relativeToSpineIndex: nil,
bookIdentifier: currentBookIdentifier
@@ -42,11 +42,16 @@ extension RDEPUBReaderController {
}
}
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
if let publication {
return textBook.pageNumber(
for: normalizedLocation,
resolver: publication.resourceResolver,
bookIdentifier: currentBookIdentifier
)
}
// External text books have no resolver; hrefs match verbatim.
return textBook.chapterData(for: normalizedLocation.href)?
.pageNumber(for: normalizedLocation)
}
return readingSession?.queueNavigation(
@@ -47,7 +47,7 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
private let backgroundButton = UIButton(type: .custom)
private let panelView = UIView()
let panelView = UIView()
private let grabberView = UIView()
@@ -93,6 +93,24 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
fatalError("init(coder:) has not been implemented")
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
// Only claim touches that fall within the panelView or the
// accessibility-only navigation buttons. Taps on the dimmed scrim
// area pass through to the reader content below, allowing chrome
// toggle taps to work.
let panelPoint = convert(point, to: panelView)
if panelView.point(inside: panelPoint, with: event) {
return super.hitTest(point, with: event)
}
// Also claim touches on the accessibility-only navigation buttons
if previousButton.frame.contains(point)
|| nextButton.frame.contains(point)
|| countLabel.frame.contains(point) {
return super.hitTest(point, with: event)
}
return nil
}
override func lineFrame(in bounds: CGRect) -> CGRect {
.zero
}
@@ -277,9 +295,11 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
nextButton.accessibilityIdentifier = "epub.reader.search.next"
countLabel.accessibilityIdentifier = "epub.reader.search.count"
textField.accessibilityIdentifier = "epub.reader.search.field"
previousButton.alpha = 0.01
nextButton.alpha = 0.01
countLabel.alpha = 0.01
// 0.02 0.01CALayer.opacity Float320.01
// 0.01 UIKit
previousButton.alpha = 0.02
nextButton.alpha = 0.02
countLabel.alpha = 0.02
tableView.register(RDEPUBReaderSearchResultCell.self, forCellReuseIdentifier: RDEPUBReaderSearchResultCell.reuseIdentifier)
tableView.dataSource = self
@@ -342,17 +362,20 @@ final class RDEPUBReaderSearchBarView: RDEPUBReaderToolView {
emptyStateLabel.trailingAnchor.constraint(equalTo: panelView.trailingAnchor, constant: -32),
emptyStateLabel.topAnchor.constraint(equalTo: searchRowView.bottomAnchor, constant: 56),
previousButton.topAnchor.constraint(equalTo: topAnchor),
// /UI panel
//
//
previousButton.topAnchor.constraint(equalTo: panelView.topAnchor),
previousButton.leadingAnchor.constraint(equalTo: leadingAnchor),
previousButton.widthAnchor.constraint(equalToConstant: 1),
previousButton.heightAnchor.constraint(equalToConstant: 1),
nextButton.topAnchor.constraint(equalTo: topAnchor),
nextButton.topAnchor.constraint(equalTo: panelView.topAnchor),
nextButton.leadingAnchor.constraint(equalTo: previousButton.trailingAnchor),
nextButton.widthAnchor.constraint(equalToConstant: 1),
nextButton.heightAnchor.constraint(equalToConstant: 1),
countLabel.topAnchor.constraint(equalTo: topAnchor),
countLabel.topAnchor.constraint(equalTo: panelView.topAnchor),
countLabel.leadingAnchor.constraint(equalTo: nextButton.trailingAnchor),
countLabel.widthAnchor.constraint(equalToConstant: 1),
countLabel.heightAnchor.constraint(equalToConstant: 1)
@@ -113,7 +113,7 @@ public final class RDURLReaderController: UIViewController {
guard let readerController else { return }
readerController.showSearchBar()
if readerController.parser != nil || readerController.textBook != nil {
submitSearchAfterDelay(keyword: keyword, retries: 5)
submitSearchAfterDelay(keyword: keyword, retries: 10)
} else {
pendingSearchKeyword = keyword
}
@@ -125,9 +125,10 @@ public final class RDURLReaderController: UIViewController {
guard let self else { return }
readerController.search(keyword: keyword)
readerController.updateSearchCount()
// bookPageMap
// token
if retries > 0,
readerController.searchState?.matches.isEmpty != false,
readerController.parser == nil {
readerController.searchState?.matches.isEmpty != false {
self.submitSearchAfterDelay(keyword: keyword, retries: retries - 1)
}
}
@@ -139,7 +140,7 @@ public final class RDURLReaderController: UIViewController {
return
}
pendingSearchKeyword = nil
submitSearchAfterDelay(keyword: keyword, retries: 5)
submitSearchAfterDelay(keyword: keyword, retries: 10)
}
private func embedReaderController() {
@@ -367,7 +368,8 @@ public final class RDURLReaderController: UIViewController {
"externalLinks=\(externalLinkActivationCount)",
"lastExternalURL=\(encodedDemoLocationHref(lastActivatedExternalURL?.absoluteString))",
"lastError=\(encodedDemoField(lastReaderErrorDescription))",
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))"
"searchMatchText=\(encodedDemoField(currentSearchMatchText()))",
"footprintMB=\(String(format: "%.1f", RDEPUBMemoryProbe.footprintMB))"
].joined(separator: " ")
demoStateLabel.text = state
if let logPrefix {
@@ -388,17 +390,27 @@ public final class RDURLReaderController: UIViewController {
}
private func currentSearchMatchText() -> String {
guard let match = readerController?.searchState?.currentMatch,
let rangeLocation = match.rangeLocation,
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) else {
guard let match = readerController?.searchState?.currentMatch else {
return "none"
}
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
guard nsRange.location >= 0,
nsRange.location + nsRange.length <= chapterData.attributedContent.length else {
return "none"
// Primary path: extract the exact matched text from the chapter content
if let rangeLocation = match.rangeLocation,
let chapterData = readerController?.textChapterData(forNormalizedHref: match.href) {
let nsRange = NSRange(location: rangeLocation, length: match.rangeLength)
if nsRange.location >= 0,
nsRange.location + nsRange.length <= chapterData.attributedContent.length {
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
}
}
return chapterData.attributedContent.attributedSubstring(from: nsRange).string
// Fallback: chapter data may not be cached yet (on-demand loading).
// If the match length equals the keyword length, the keyword itself
// is the match text. Otherwise extract from previewText at the known offset.
if let keyword = readerController?.searchState?.keyword,
match.rangeLength == keyword.count {
return keyword
}
// Final fallback: return "none" to indicate data not yet available
return "none"
}
private func demoPaginationSnapshot() -> (mode: String, phase: String, knownPages: Int, knownChapters: Int, buildableChapters: Int) {
@@ -22,8 +22,13 @@ enum RDEPUBMemoryProbe {
static func log(_ event: String) {
guard isEnabled else { return }
let megabytes = Double(currentFootprint()) / 1_048_576
print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, megabytes))
print(String(format: "[EPUB][MemoryProbe] %@ footprint=%.1fMB", event, footprintMB))
}
/// Current phys_footprint in MB. Cheap enough (one task_info call) to
/// surface in the demo state snapshot for automated memory assertions.
static var footprintMB: Double {
Double(currentFootprint()) / 1_048_576
}
/// phys_footprint matches the value Xcode's memory gauge and Jetsam use.
@@ -377,10 +377,12 @@ final class RDEPUBReaderContext {
}
func textChapterData(forNormalizedHref href: String) -> RDEPUBChapterData? {
guard let textBook, let publication else { return nil }
let normalizedHref = publication.resourceResolver.normalizedHref(href) ?? href
guard let textBook else { return nil }
// External text books (e.g. plain .txt) have no publication; their
// chapter hrefs are matched verbatim.
let normalizedHref = publication?.resourceResolver.normalizedHref(href) ?? href
return textBook.chapters.lazy
.first(where: { (publication.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
.first(where: { (publication?.resourceResolver.normalizedHref($0.href) ?? $0.href) == normalizedHref })
.flatMap { textBook.chapterData(for: $0.href) }
}
@@ -143,6 +143,12 @@ final class RDEPUBReaderSearchCoordinator {
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)
}
@@ -157,6 +163,8 @@ final class RDEPUBReaderSearchCoordinator {
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):
@@ -166,6 +174,7 @@ final class RDEPUBReaderSearchCoordinator {
private enum SearchEngineSnapshot {
case textBook(RDEPUBTextBook, RDEPUBPublication)
case externalTextBook(RDEPUBTextBook)
case onDemand(RDEPUBPublication)
case html(RDEPUBParser, RDEPUBPublication)
}
@@ -350,12 +359,16 @@ final class RDEPUBReaderSearchCoordinator {
rangeCFI: searchMatch.rangeCFI
)
if let textBook = controller.textBook, let publication = controller.publication {
return textBook.pageNumber(
for: location,
resolver: publication.resourceResolver,
bookIdentifier: controller.currentBookIdentifier
)
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(