修复 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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user