diff --git a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj index 321d88a..13be87f 100644 --- a/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj +++ b/ReadViewDemo/ReadViewDemo.xcodeproj/project.pbxproj @@ -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 = ""; }; BADF0A18AD034B74A482A4C1 /* ReaderToolbarTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderToolbarTests.swift; sourceTree = ""; }; CFE01DBDCB8D790832A4DE3F /* SearchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SearchTests.swift; sourceTree = ""; }; + 1A2B3C4D00000020AABBCC01 /* MemoryFootprintTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MemoryFootprintTests.swift; sourceTree = ""; }; 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 = ""; }; /* 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; diff --git a/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift b/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift index 041e216..9e7657c 100644 --- a/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift +++ b/ReadViewDemo/ReadViewDemoUITests/Helpers/DemoReaderState.swift @@ -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 { diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/MemoryFootprintTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/MemoryFootprintTests.swift new file mode 100644 index 0000000..83bd2aa --- /dev/null +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/MemoryFootprintTests.swift @@ -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)") + } +} diff --git a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift index 0c7aef6..82e3705 100644 --- a/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift +++ b/ReadViewDemo/ReadViewDemoUITests/ReaderUITests/SearchTests.swift @@ -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) + } + } } diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift index 28fcb44..6699afb 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderController+LocationResolution.swift @@ -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( diff --git a/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift index 55b18f2..3ac1c21 100644 --- a/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift +++ b/Sources/RDReaderView/EPUBUI/RDEPUBReaderSearchBarView.swift @@ -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) diff --git a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift index 44de4d0..4537ca8 100644 --- a/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift +++ b/Sources/RDReaderView/EPUBUI/RDURLReaderController.swift @@ -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) { diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift index 140b72a..40067fa 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBMemoryProbe.swift @@ -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. diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift index dea459a..ff0e489 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderContext.swift @@ -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) } } diff --git a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift index e489428..7e688a5 100644 --- a/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift +++ b/Sources/RDReaderView/EPUBUI/ReaderController/RDEPUBReaderSearchCoordinator.swift @@ -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( diff --git a/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift b/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift index e61d5b4..c12d0d8 100644 --- a/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift +++ b/Sources/RDReaderView/ReaderView/RDReaderView+ToolView.swift @@ -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 {