完善搜索定位与内存监测回归验证
本次提交围绕搜索链路的稳定性、定位恢复体验以及长章节内存优化的验证能力进行了补强。 主要改动: 1. 调整阅读器搜索栏、搜索协调器与定位恢复逻辑,改善搜索结果跳转、状态同步与相关 UI 行为。 2. 补充内存探针接入点与上下文记录,便于跟踪阅读过程中的内存占用变化。 3. 更新 RDURLReaderController、ReaderContext 与工具视图相关实现,使调试与观测链路更完整。 4. 新增 MemoryFootprintTests,并同步更新 DemoReaderState 与 SearchTests,用 UI 测试覆盖搜索与内存相关回归场景。
This commit is contained in:
parent
c4be426299
commit
83dfa40299
@ -20,6 +20,7 @@
|
||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */; };
|
||||
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */; };
|
||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */; };
|
||||
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */; };
|
||||
3BC5C96D7A0ACF35F2192CC7 /* XCUIApplication+Launch.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74B4C44287820D68ED6570F8 /* XCUIApplication+Launch.swift */; };
|
||||
4509ED928F228F43888E063D /* ReaderToolbarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */; };
|
||||
52A6A0C9941A4B5F9B88C6B0 /* ReaderAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20BD15E5D8F04E7E9A239E14 /* ReaderAnnotationTests.swift */; };
|
||||
@ -64,6 +65,7 @@
|
||||
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
|
||||
BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = "<group>"; };
|
||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = "<group>"; };
|
||||
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = "<group>"; };
|
||||
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = DisplayTypeTests.swift; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
@ -192,6 +194,7 @@
|
||||
1A2B3C4D00000010AABBCC01 /* MetadataParseBenchmarkTests.swift */,
|
||||
1A2B3C4D00000013AABBCC01 /* FanrenParseTimeTest.swift */,
|
||||
CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */,
|
||||
1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */,
|
||||
1A2B3C4D00000016AABBCC01 /* AsyncChapterLoadingTests.swift */,
|
||||
);
|
||||
path = ReaderUITests;
|
||||
@ -357,6 +360,7 @@
|
||||
1A2B3C4D0000000FAABBCC01 /* MetadataParseBenchmarkTests.swift in Sources */,
|
||||
1A2B3C4D00000014AABBCC01 /* FanrenParseTimeTest.swift in Sources */,
|
||||
369C9658D870DCAFC17EB7F7 /* SearchTests.swift in Sources */,
|
||||
1A2B3C4D00000021AABBCC01 /* MemoryFootprintTests.swift in Sources */,
|
||||
1A2B3C4D00000015AABBCC01 /* AsyncChapterLoadingTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
||||
@ -58,6 +58,7 @@ struct DemoReaderState {
|
||||
var lastExternalURL: String? { decodedField("lastExternalURL") }
|
||||
var lastError: String? { decodedField("lastError") }
|
||||
var searchMatchText: String? { decodedField("searchMatchText") }
|
||||
var footprintMB: Double? { fields["footprintMB"].flatMap(Double.init) }
|
||||
}
|
||||
|
||||
extension XCUIApplication {
|
||||
|
||||
@ -0,0 +1,147 @@
|
||||
import XCTest
|
||||
|
||||
/// Quantitative memory-footprint harness for the long-chapter optimization
|
||||
/// (Doc/LONG_CHAPTER_MEMORY_OPTIMIZATION_PLAN.md, P0-2 / P1-4 / P2). Reads
|
||||
/// phys_footprint back through the demo state `footprintMB` field, so these
|
||||
/// tests double as a decision tool: the recorded numbers are attached to the
|
||||
/// result, and the assertions are deliberately loose upper bounds that only
|
||||
/// catch gross regressions (e.g. each visible page pinning a full-chapter
|
||||
/// copy), not few-MB noise.
|
||||
final class MemoryFootprintTests: XCTestCase {
|
||||
private let app = XCUIApplication()
|
||||
private let largeBookQuery = "凡人修仙传"
|
||||
|
||||
/// Turning 20 pages must not grow footprint beyond this over the post-open
|
||||
/// baseline. Sized to flag "per-page full-chapter copy" class leaks while
|
||||
/// tolerating cache warmup and simulator noise.
|
||||
private let pageTurnGrowthBudgetMB = 180.0
|
||||
|
||||
/// A settings change rebuilds display objects; peak may spike transiently
|
||||
/// but must settle back within this of the pre-change reading.
|
||||
private let settingsChangeGrowthBudgetMB = 220.0
|
||||
|
||||
override func setUpWithError() throws {
|
||||
continueAfterFailure = false
|
||||
}
|
||||
|
||||
func testFootprintStableAcrossTwentyPageTurns() throws {
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
displayType: "scroll",
|
||||
resetsReaderState: true
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
app.waitForDemoReaderState(timeout: 8, description: "display=horizontalScroll") {
|
||||
$0.display == "horizontalScroll"
|
||||
}
|
||||
|
||||
let baseline = try requireFootprint(description: "打开长书后基线")
|
||||
let paging = app.collectionViews[IDs.readerPaging]
|
||||
XCTAssertTrue(paging.waitForExistence(timeout: 5), "分页滚动视图未出现")
|
||||
|
||||
var readings: [Double] = [baseline]
|
||||
var lastPage = app.currentDemoReaderState()?.page ?? 0
|
||||
var turnsMade = 0
|
||||
|
||||
for _ in 0..<20 {
|
||||
paging.swipeLeft()
|
||||
let state = app.waitForDemoReaderState(timeout: 8, description: "翻页后页码变化") { state in
|
||||
guard let page = state.page else { return false }
|
||||
return page != lastPage
|
||||
}
|
||||
guard let page = state.page, page != lastPage else { break }
|
||||
lastPage = page
|
||||
turnsMade += 1
|
||||
if let footprint = state.footprintMB {
|
||||
readings.append(footprint)
|
||||
}
|
||||
}
|
||||
|
||||
let peak = readings.max() ?? baseline
|
||||
let final = readings.last ?? baseline
|
||||
let growth = final - baseline
|
||||
attachReport(
|
||||
name: "page-turns",
|
||||
lines: [
|
||||
"turnsMade=\(turnsMade)",
|
||||
"baselineMB=\(fmt(baseline))",
|
||||
"peakMB=\(fmt(peak))",
|
||||
"finalMB=\(fmt(final))",
|
||||
"growthMB=\(fmt(growth))",
|
||||
"budgetMB=\(fmt(pageTurnGrowthBudgetMB))",
|
||||
"series=\(readings.map(fmt).joined(separator: ","))"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertGreaterThan(turnsMade, 0, "未能翻动任何页,无法测量翻页内存")
|
||||
XCTAssertLessThan(
|
||||
growth, pageTurnGrowthBudgetMB,
|
||||
"连续翻页后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(pageTurnGrowthBudgetMB))MB(疑似每页驻留整章副本)"
|
||||
)
|
||||
}
|
||||
|
||||
func testFootprintSettlesAfterFontSizeChange() throws {
|
||||
app.launchAndOpenSampleBook(
|
||||
bookTitleQuery: largeBookQuery,
|
||||
resetsReaderState: true
|
||||
)
|
||||
app.waitForReader(timeout: 20)
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let before = try requireFootprint(description: "改字号前")
|
||||
|
||||
XCTAssertTrue(app.buttons[IDs.readerSettings].waitForExistence(timeout: 5), "设置按钮不存在")
|
||||
app.buttons[IDs.readerSettings].tap()
|
||||
XCTAssertTrue(app.scrollViews[IDs.settingsScroll].waitForExistence(timeout: 5), "设置面板未出现")
|
||||
XCTAssertTrue(app.buttons[IDs.settingsFontIncrease].waitForExistence(timeout: 3), "字号增大按钮不存在")
|
||||
app.buttons[IDs.settingsFontIncrease].tap()
|
||||
app.buttons[IDs.settingsFontIncrease].tap()
|
||||
XCTAssertTrue(app.buttons[IDs.settingsDone].waitForExistence(timeout: 3), "完成按钮不存在")
|
||||
app.buttons[IDs.settingsDone].tap()
|
||||
|
||||
// Allow repagination + display-cache rebuild to settle.
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
|
||||
let after = try requireFootprint(description: "改字号并重排后")
|
||||
|
||||
let growth = after - before
|
||||
attachReport(
|
||||
name: "font-size-change",
|
||||
lines: [
|
||||
"beforeMB=\(fmt(before))",
|
||||
"afterMB=\(fmt(after))",
|
||||
"growthMB=\(fmt(growth))",
|
||||
"budgetMB=\(fmt(settingsChangeGrowthBudgetMB))"
|
||||
]
|
||||
)
|
||||
|
||||
XCTAssertLessThan(
|
||||
growth, settingsChangeGrowthBudgetMB,
|
||||
"改字号重排后常驻内存增长 \(fmt(growth))MB 超出预算 \(fmt(settingsChangeGrowthBudgetMB))MB(疑似旧显示对象未释放)"
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func requireFootprint(description: String) throws -> Double {
|
||||
let state = app.waitForDemoReaderState(timeout: 8, description: description) {
|
||||
($0.footprintMB ?? 0) > 0
|
||||
}
|
||||
guard let footprint = state.footprintMB, footprint > 0 else {
|
||||
throw XCTSkip("demo 状态未暴露 footprintMB,跳过内存测量:\(state.rawValue)")
|
||||
}
|
||||
return footprint
|
||||
}
|
||||
|
||||
private func fmt(_ value: Double) -> String {
|
||||
String(format: "%.1f", value)
|
||||
}
|
||||
|
||||
private func attachReport(name: String, lines: [String]) {
|
||||
let text = lines.joined(separator: "\n")
|
||||
let attachment = XCTAttachment(string: text)
|
||||
attachment.name = "memory-\(name)"
|
||||
attachment.lifetime = .keepAlways
|
||||
add(attachment)
|
||||
print("[MemoryFootprintTests] \(name)\n\(text)")
|
||||
}
|
||||
}
|
||||
@ -209,7 +209,9 @@ final class SearchTests: XCTestCase {
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "关闭后搜索栏应消失")
|
||||
}
|
||||
|
||||
// MARK: - 搜索栏与工具栏联动
|
||||
// MARK: - 搜索面板与工具栏联动(全屏面板模型)
|
||||
// 全屏搜索面板覆盖内容区,面板打开时无法点击内容切换 chrome;
|
||||
// 选中结果后面板关闭(搜索状态保留),chrome 隐藏/恢复联动面板。
|
||||
|
||||
func testSearchBarHidesWhenToolbarsHide() {
|
||||
app.launchAndOpenSampleBook(searchKeyword: "的")
|
||||
@ -217,12 +219,15 @@ final class SearchTests: XCTestCase {
|
||||
app.showReaderChromeIfNeeded()
|
||||
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索栏应可见")
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10), "搜索面板应可见")
|
||||
|
||||
// 隐藏工具栏
|
||||
// 选中结果后面板关闭并跳转,搜索状态保留
|
||||
selectSearchResult(at: 0)
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "选中结果后搜索面板应关闭")
|
||||
|
||||
// 隐藏工具栏,面板应保持关闭
|
||||
app.hideReaderChromeIfNeeded()
|
||||
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索栏也应隐藏")
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2), "工具栏隐藏时搜索面板应保持关闭")
|
||||
}
|
||||
|
||||
func testSearchBarRestoresWhenToolbarsShow() {
|
||||
@ -233,13 +238,14 @@ final class SearchTests: XCTestCase {
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 10))
|
||||
|
||||
// 隐藏工具栏
|
||||
// 选中结果关闭面板,再隐藏工具栏
|
||||
selectSearchResult(at: 0)
|
||||
app.hideReaderChromeIfNeeded()
|
||||
XCTAssertFalse(searchBar.waitForExistence(timeout: 2))
|
||||
|
||||
// 重新显示工具栏
|
||||
// 重新显示工具栏:搜索状态存在时面板应自动恢复
|
||||
app.showReaderChromeIfNeeded()
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索栏也应恢复")
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "工具栏恢复时搜索面板也应恢复")
|
||||
|
||||
// 搜索状态应保留
|
||||
let countLabel = app.staticTexts[IDs.searchCount]
|
||||
@ -286,6 +292,9 @@ final class SearchTests: XCTestCase {
|
||||
}
|
||||
let rangeCFIBefore = stateBefore.rangeCFI
|
||||
|
||||
// 全屏面板挡住工具栏,先选中当前匹配关闭面板(保留搜索状态)
|
||||
selectSearchResult(at: 0)
|
||||
|
||||
// 改变字体触发重排
|
||||
app.showReaderChromeIfNeeded()
|
||||
let settingsButton = app.buttons[IDs.readerSettings]
|
||||
@ -304,8 +313,8 @@ final class SearchTests: XCTestCase {
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
// 重新打开搜索并导航到匹配
|
||||
app.showReaderChromeIfNeeded()
|
||||
// 重新打开搜索面板(搜索状态保留),前后导航强制重解析当前命中
|
||||
reopenSearchPanel()
|
||||
let prevButton = app.buttons[IDs.searchPrevious]
|
||||
let nextButton = app.buttons[IDs.searchNext]
|
||||
if prevButton.waitForExistence(timeout: 3), prevButton.isEnabled {
|
||||
@ -349,6 +358,9 @@ final class SearchTests: XCTestCase {
|
||||
return
|
||||
}
|
||||
|
||||
// 全屏面板挡住工具栏,先选中当前匹配关闭面板(保留搜索状态)
|
||||
selectSearchResult(at: 0)
|
||||
|
||||
// 改变字体大小触发重新排版
|
||||
app.showReaderChromeIfNeeded()
|
||||
let settingsButton = app.buttons[IDs.readerSettings]
|
||||
@ -367,15 +379,9 @@ final class SearchTests: XCTestCase {
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 1)
|
||||
|
||||
// 测试多个匹配位置的跳转正确性
|
||||
let nextButton = app.buttons[IDs.searchNext]
|
||||
let prevButton = app.buttons[IDs.searchPrevious]
|
||||
XCTAssertTrue(nextButton.waitForExistence(timeout: 3), "下一个按钮应存在")
|
||||
XCTAssertTrue(prevButton.waitForExistence(timeout: 3), "上一个按钮应存在")
|
||||
|
||||
// 导航到第 2 个匹配,记录页码
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
// 跳到第 2 个匹配,记录页码
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 1)
|
||||
let stateMatch2 = app.waitForDemoReaderState(timeout: 5, description: "match 2 page") {
|
||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
@ -383,18 +389,11 @@ final class SearchTests: XCTestCase {
|
||||
let rangeCFIAtMatch2 = stateMatch2.rangeCFI
|
||||
|
||||
// 翻到其他页
|
||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
swipeAwayFromCurrentPage()
|
||||
|
||||
// 跳转到第 3 个匹配
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
// 跳到第 3 个匹配
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 2)
|
||||
let stateMatch3 = app.waitForDemoReaderState(timeout: 5, description: "match 3 page") {
|
||||
($0.page ?? 0) > 0 && $0.rangeCFI?.isEmpty == false
|
||||
}
|
||||
@ -402,17 +401,11 @@ final class SearchTests: XCTestCase {
|
||||
let rangeCFIAtMatch3 = stateMatch3.rangeCFI
|
||||
|
||||
// 再翻到其他页
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
swipeAwayFromCurrentPage()
|
||||
|
||||
// 跳回第 2 个匹配,验证页码不变
|
||||
prevButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 1)
|
||||
let stateBack2 = app.waitForDemoReaderState(timeout: 5, description: "back to match 2") {
|
||||
$0.page == pageAtMatch2 && $0.rangeCFI == rangeCFIAtMatch2
|
||||
}
|
||||
@ -422,17 +415,11 @@ final class SearchTests: XCTestCase {
|
||||
"重排后搜索跳回第 2 个匹配应命中同一段文本")
|
||||
|
||||
// 再翻到其他页
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
swipeAwayFromCurrentPage()
|
||||
|
||||
// 跳到第 3 个匹配,验证页码不变
|
||||
nextButton.tap()
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
reopenSearchPanel()
|
||||
selectSearchResult(at: 2)
|
||||
let stateBack3 = app.waitForDemoReaderState(timeout: 5, description: "back to match 3") {
|
||||
$0.page == pageAtMatch3 && $0.rangeCFI == rangeCFIAtMatch3
|
||||
}
|
||||
@ -452,4 +439,39 @@ final class SearchTests: XCTestCase {
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索栏应出现")
|
||||
}
|
||||
|
||||
/// 点击全屏搜索面板中的结果行:跳转到对应匹配并关闭面板(搜索状态保留)
|
||||
private func selectSearchResult(at matchIndex: Int) {
|
||||
let cell = app.cells["epub.reader.search.result.\(matchIndex)"].firstMatch
|
||||
XCTAssertTrue(cell.waitForExistence(timeout: 10), "搜索结果第 \(matchIndex) 项应存在")
|
||||
cell.tap()
|
||||
RunLoop.current.run(until: Date().addingTimeInterval(0.5))
|
||||
}
|
||||
|
||||
/// 重新打开搜索面板。chrome 恢复时若有搜索状态面板会自动恢复;
|
||||
/// chrome 本就可见时需点击工具栏搜索按钮。
|
||||
private func reopenSearchPanel() {
|
||||
app.showReaderChromeIfNeeded()
|
||||
let searchBar = app.otherElements[IDs.searchBar]
|
||||
if searchBar.waitForExistence(timeout: 2) {
|
||||
return
|
||||
}
|
||||
let searchButton = app.buttons[IDs.readerSearch]
|
||||
XCTAssertTrue(searchButton.waitForExistence(timeout: 3), "搜索按钮应存在")
|
||||
searchButton.tap()
|
||||
XCTAssertTrue(searchBar.waitForExistence(timeout: 3), "搜索面板应重新打开")
|
||||
}
|
||||
|
||||
/// 隐藏 chrome 后向后翻若干页,使当前页离开搜索命中页
|
||||
private func swipeAwayFromCurrentPage() {
|
||||
app.hideReaderChromeIfNeeded()
|
||||
let content = app.otherElements[IDs.readerContent].firstMatch
|
||||
if content.waitForExistence(timeout: 3) {
|
||||
for _ in 0..<4 {
|
||||
content.swipeLeft()
|
||||
Thread.sleep(forTimeInterval: 0.3)
|
||||
}
|
||||
Thread.sleep(forTimeInterval: 0.5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.01:CALayer.opacity 为 Float32,0.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(
|
||||
|
||||
@ -57,12 +57,27 @@ extension RDReaderView {
|
||||
}
|
||||
|
||||
func isHitView(_ hitView: UIView?, inside toolView: UIView, point: CGPoint) -> Bool {
|
||||
if toolView.frame.contains(point) {
|
||||
if let hitView, hitView === toolView || hitView.isDescendant(of: toolView) {
|
||||
return true
|
||||
}
|
||||
|
||||
guard let hitView else { return false }
|
||||
return hitView === toolView || hitView.isDescendant(of: toolView)
|
||||
if toolView.frame.contains(point) {
|
||||
// For search bar views, only consider the hit inside the visible
|
||||
// panel area. Taps on the dimmed scrim should pass through to
|
||||
// the reader content to allow chrome toggling. Touches that
|
||||
// resolved to a search bar subview (e.g. the accessibility-only
|
||||
// navigation buttons outside the panel) are already claimed by
|
||||
// the descendant check above.
|
||||
if let searchBarView = toolView as? RDEPUBReaderSearchBarView {
|
||||
let panelPoint = convert(point, to: searchBarView.panelView)
|
||||
if !searchBarView.panelView.point(inside: panelPoint, with: nil) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
enum ToolViewPosition {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user