修复 PDF 仿真翻页模式下翻页后页面全白

打开普通 PDF 并使用仿真翻页阅读时,除首页外每翻一页都是纯白:没有页面
内容,也没有页面四周的衬底色。横向滚动与竖滑模式不受影响。样张
英汉大词典App需求说明.pdf 上第 2 到第 9 页无一例外。

根因是页面数据回来时找不到对应的视图。UIPageViewController 会通过
viewControllerBefore/After 提前构建相邻页的内容视图,构建时页面描述尚未
就绪,configure 发起异步 readerPage 请求后先返回一张空白页。描述返回后
acceptPageDescriptor 调用 refreshVisiblePage,而它经由
RDPDFReaderView.pageContentView(pageNum:) 定位视图,该方法在仿真翻页下
只检索 nativeCurlPageController.viewControllers,也就是已经上屏的 child。
预建的 child 此时还不在其中,图片因此从未被装配上去;翻页动画结束后又
没有任何路径重新 configure 该视图,白页就固定下来。此前偶尔能看到内容,
是 OCR 完成回调顺带触发了一次刷新,并非稳定行为。

改为按页码刷新该页的全部活动视图:控制器用弱引用表记录已创建的页面视图,
refreshVisiblePage 改名为 refreshPageViews(at:),除已上屏视图外一并装配
尚未上屏的预建视图,pageContentView 创建时即写入 tag 以便按页码匹配。
翻页落定时在 pageNum 回调中补一次装配,使内存告警清掉页面描述的场景也能
重新发起请求并恢复。另外把 image 为空的页面描述改按无效结果处理,走既有
的重试与失败重载入口,不再缓存出一张永远不会重试的白页。

新增 PDFPageTurnBlankPageTests 作为回归:翻页稳定后截屏统计画面中部深色
像素占比,白页为 0,扉页约 0.002,正文页在 0.01 以上,以 0.0005 为判定
阈值,覆盖仿真翻页正翻、仿真回翻和横向滚动三种路径。

验证:iPhone 17 Pro / iOS 26.4.1 模拟器执行 PDFPageTurnBlankPageTests、
PDFAnnotationTests、PDFDrawingModeTests、PageNavigationTests、
DisplayTypeTests 共 23 条用例,22 条通过。修复前仿真翻页第 2 到 9 页墨迹
占比全为 0.0000,修复后与横向滚动模式逐页吻合。唯一失败的
DisplayTypeTests.testDisplayTypeSwitchMidSession 属 EPUB 设置面板既有问题,
将本次改动还原后同样失败。文档补充了 Demo 因链接 FoundationModels 而必须
使用 iOS 26 及以上模拟器的说明。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
shenlei
2026-07-30 17:52:29 +09:00
co-authored by Claude Opus 5
parent 735c14c824
commit d830311894
4 changed files with 201 additions and 18 deletions
@@ -41,6 +41,7 @@
63B2852E59996922386A3111 /* AccessibilityIdentifiers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 00F495E17B90CCF1C7FE8C27 /* AccessibilityIdentifiers.swift */; };
9AAFB855EC7AC09D1E0B6742 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 697A5BCF0D93DBEC18E21A09 /* Foundation.framework */; };
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */; };
C0FFEE0100000001AABBCC01 /* PDFPageTurnBlankPageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */; };
C08FF8D030048DC5147729E9 /* ReaderOpenCloseTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */; };
DE1437A969DA1C5F0CBB047D /* Pods_ReadViewDemo.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 792DF85CE4A3DD80D67843C7 /* Pods_ReadViewDemo.framework */; };
FEDB5937CEB858CB06E38E2D /* SettingsPanelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 201C2B482287866487EFAE66 /* SettingsPanelTests.swift */; };
@@ -94,6 +95,7 @@
8FFD606A5A1CBDCC3CA87F1C /* ReadViewDemoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ReadViewDemoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
9EDF066BA12974E6CFBE519F /* ReaderOpenCloseTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ReaderOpenCloseTests.swift; sourceTree = "<group>"; };
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFAnnotationTests.swift; sourceTree = "<group>"; };
C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PDFPageTurnBlankPageTests.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>"; };
DE070C1D2FBF0CC900ED065F /* ReadViewDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ReadViewDemo.app; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -213,6 +215,7 @@
FB49AFCCBC2BE04C82B8F286 /* DisplayTypeTests.swift */,
1A2B3C4D00000008AABBCC01 /* PageNavigationTests.swift */,
A1C5E34F6B7192D400A1B235 /* PDFAnnotationTests.swift */,
C0FFEE0100000001AABBCC02 /* PDFPageTurnBlankPageTests.swift */,
2B3C4D5E00000001AABBCC02 /* BookmarkChromeStateTests.swift */,
2B3C4D5E00000002AABBCC02 /* BookmarkManagementTests.swift */,
2B3C4D5E00000003AABBCC02 /* ErrorAndEdgeCaseTests.swift */,
@@ -394,6 +397,7 @@
23BB1155EA379786DAA10A89 /* DisplayTypeTests.swift in Sources */,
1A2B3C4D00000007AABBCC01 /* PageNavigationTests.swift in Sources */,
A1C5E34F6B7192D400A1B234 /* PDFAnnotationTests.swift in Sources */,
C0FFEE0100000001AABBCC01 /* PDFPageTurnBlankPageTests.swift in Sources */,
2B3C4D5E00000001AABBCC01 /* BookmarkChromeStateTests.swift in Sources */,
2B3C4D5E00000002AABBCC01 /* BookmarkManagementTests.swift in Sources */,
2B3C4D5E00000003AABBCC01 /* ErrorAndEdgeCaseTests.swift in Sources */,
@@ -0,0 +1,147 @@
import XCTest
import UIKit
/// PDF
///
/// PDF
/// 线
final class PDFPageTurnBlankPageTests: XCTestCase {
private let app = XCUIApplication()
/// 0.0000
/// 0.0020 0.0100
private let blankInkRatioThreshold = 0.0005
override func setUpWithError() throws {
continueAfterFailure = false
}
func testPageCurlForwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "pagecurl")
assertForwardTurnsRenderContent(turns: 8, mode: "仿真翻页")
}
func testHorizontalScrollForwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "horizontalscroll")
assertForwardTurnsRenderContent(turns: 8, mode: "横向滚动")
}
/// `viewControllerBefore`
func testPageCurlBackwardTurnsRenderPageContent() {
openDictionaryPDF(displayType: "pagecurl")
assertForwardTurnsRenderContent(turns: 5, mode: "仿真翻页")
assertBackwardTurnsRenderContent(from: 6, turns: 5, mode: "仿真回翻")
}
// MARK: -
private func openDictionaryPDF(displayType: String) {
app.launchAndOpenSampleBook(
bookTitleQuery: "英汉大词典",
displayType: displayType,
resetsReaderState: true
)
app.waitForReader(timeout: 30)
app.waitForReaderPage(1, timeout: 30)
//
RunLoop.current.run(until: Date().addingTimeInterval(2.5))
}
private func assertForwardTurnsRenderContent(turns: Int, mode: String) {
var blankPages: [Int] = []
recordInk(page: 1, mode: mode, blankPages: &blankPages)
for step in 1...turns {
let expectedPage = step + 1
turnToNextPage()
app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") {
$0.page == expectedPage
}
//
RunLoop.current.run(until: Date().addingTimeInterval(1.2))
recordInk(page: expectedPage, mode: mode, blankPages: &blankPages)
}
XCTAssertTrue(
blankPages.isEmpty,
"\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))"
)
}
private func assertBackwardTurnsRenderContent(from startPage: Int, turns: Int, mode: String) {
var blankPages: [Int] = []
for step in 1...turns {
let expectedPage = startPage - step
guard expectedPage >= 1 else { break }
turnPage(forward: false)
app.waitForDemoReaderState(timeout: 15, description: "\(mode) page=\(expectedPage)") {
$0.page == expectedPage
}
RunLoop.current.run(until: Date().addingTimeInterval(1.2))
recordInk(page: expectedPage, mode: mode, blankPages: &blankPages)
}
XCTAssertTrue(
blankPages.isEmpty,
"\(mode):翻页后出现白页,页码 \(blankPages.map(String.init).joined(separator: ", "))"
)
}
private func turnToNextPage() { turnPage(forward: true) }
private func turnPage(forward: Bool) {
let content = app.otherElements[IDs.readerContent].firstMatch
let target: XCUIElement = content.exists ? content : app.windows.firstMatch
let leading = CGVector(dx: 0.08, dy: 0.5)
let trailing = CGVector(dx: 0.88, dy: 0.5)
let start = target.coordinate(withNormalizedOffset: forward ? trailing : leading)
let end = target.coordinate(withNormalizedOffset: forward ? leading : trailing)
start.press(forDuration: 0.05, thenDragTo: end)
}
private func recordInk(page: Int, mode: String, blankPages: inout [Int]) {
let screenshot = XCUIScreen.main.screenshot()
let ratio = inkRatio(of: screenshot.image)
let attachment = XCTAttachment(screenshot: screenshot)
attachment.name = "\(mode)-page-\(page)-ink-\(String(format: "%.4f", ratio))"
attachment.lifetime = .keepAlways
add(attachment)
if ratio < blankInkRatioThreshold {
blankPages.append(page)
}
}
///
private func inkRatio(of image: UIImage) -> Double {
guard let cgImage = image.cgImage else { return 0 }
let cropRect = CGRect(
x: CGFloat(cgImage.width) * 0.12,
y: CGFloat(cgImage.height) * 0.16,
width: CGFloat(cgImage.width) * 0.76,
height: CGFloat(cgImage.height) * 0.68
).integral
guard let cropped = cgImage.cropping(to: cropRect) else { return 0 }
let side = 400
var pixels = [UInt8](repeating: 255, count: side * side)
guard let context = CGContext(
data: &pixels,
width: side,
height: side,
bitsPerComponent: 8,
bytesPerRow: side,
space: CGColorSpaceCreateDeviceGray(),
bitmapInfo: CGImageAlphaInfo.none.rawValue
) else { return 0 }
context.draw(cropped, in: CGRect(x: 0, y: 0, width: side, height: side))
let darkCount = pixels.reduce(into: 0) { partial, value in
if value < 150 { partial += 1 }
}
return Double(darkCount) / Double(side * side)
}
}