修复 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
@@ -48,6 +48,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
private var book: RDPDFReaderBookDescriptor
private var currentTheme: RDPDFReaderThemeOption
private var pageDescriptors: [Int: RDPDFReaderPageDescriptor] = [:]
/// UIPageViewController UICollectionView
///
/// `visiblePageContentViews()`
private let livePageViews = NSHashTable<RDPDFReaderPageView>.weakObjects()
/// Provider cell
private var pageDescriptorRequestTokens: [Int: UUID] = [:]
private var pageDescriptorRequestTimeouts: [Int: DispatchWorkItem] = [:]
@@ -244,7 +248,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard !runs.isEmpty else { return [] }
ocrRuns[pageIndex] = runs
ocrDiskCache?.save(runs, pageIndex: pageIndex)
refreshVisiblePage(pageIndex)
refreshPageViews(at: pageIndex)
return runs
}
@@ -266,13 +270,13 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard pageIndex >= 0, pageIndex < book.totalPages else { return }
speechHighlight = normalizedRects.isEmpty ? nil : (pageIndex, normalizedRects)
goToPage(pageIndex, animated: animated)
refreshVisiblePage(pageIndex)
refreshPageViews(at: pageIndex)
}
public func clearSpeechHighlight() {
let highlightedPage = speechHighlight?.pageIndex
speechHighlight = nil
if let highlightedPage { refreshVisiblePage(highlightedPage) }
if let highlightedPage { refreshPageViews(at: highlightedPage) }
}
///
@@ -292,6 +296,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageContentView(readerView: RDPDFReaderView, pageNum: Int) -> UIView {
let page = RDPDFReaderPageView()
page.delegate = self
// ReaderView tag
//
page.tag = pageNum
livePageViews.add(page)
configure(page, at: pageNum)
return page
}
@@ -323,6 +331,10 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
guard pageNum >= 0 else { return }
// OCR
cancelOutstandingOCRRequests()
//
// configure
//
refreshPageViews(at: pageNum)
//
// cell
startOCRIfNeeded(pageNum)
@@ -394,8 +406,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
pageDescriptors[index] = descriptor
// cell
readerView.invalidateWidthFitLayoutIfNeeded(updatedPage: index)
// cell
refreshVisiblePage(index)
// cell 仿
//
refreshPageViews(at: index)
}
private func handleInvalidPageDescriptor(at index: Int, token: UUID, attempt: Int) {
@@ -404,7 +417,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
if attempt >= maximumPageDescriptorRequestAttempts {
pageLoadFailedPages.insert(index)
}
refreshVisiblePage(index)
refreshPageViews(at: index)
}
private func trimOCRCache(around pageIndex: Int, radius: Int) {
@@ -474,7 +487,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
self.pageLoadFailedPages.insert(index)
}
//
self.refreshVisiblePage(index)
self.refreshPageViews(at: index)
}
pageDescriptorRequestTimeouts[index] = timeout
let timeoutSeconds = pow(2.0, Double(attempt - 1)) * 8.0
@@ -482,7 +495,9 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
pageProvider.readerPage(at: index) { [weak self] descriptor in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
guard descriptor.index == index else {
//
//
guard descriptor.index == index, descriptor.image != nil else {
self.handleInvalidPageDescriptor(at: index, token: token, attempt: attempt)
return
}
@@ -550,8 +565,8 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
recognizingPages.remove(pageIndex)
ocrRuns[pageIndex] = runs
if shouldPersist { ocrDiskCache?.save(runs, pageIndex: pageIndex) }
// UI
refreshVisiblePage(pageIndex)
// UI
refreshPageViews(at: pageIndex)
}
private func cancelOutstandingOCRRequests() {
@@ -560,9 +575,19 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
ocrRequestTokens.removeAll()
}
private func refreshVisiblePage(_ index: Int) {
guard let page = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView else { return }
configure(page, at: index)
/// 仿 UIPageViewController
/// `viewControllers`
///
private func refreshPageViews(at index: Int) {
var targets: [RDPDFReaderPageView] = []
if let displayed = readerView.pageContentView(pageNum: index) as? RDPDFReaderPageView {
targets.append(displayed)
}
for page in livePageViews.allObjects
where page.tag == index && !targets.contains(where: { $0 === page }) {
targets.append(page)
}
targets.forEach { configure($0, at: index) }
}
/// `visiblePageContentViews` spread
@@ -821,7 +846,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
view.backgroundColor = theme.contentBackgroundColor
readerView.backgroundColor = pageSurroundingColor
applyChromeTheme()
if readerView.currentPage >= 0 { refreshVisiblePage(readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
if readerView.currentPage >= 0 { refreshPageViews(at: readerView.currentPage); readerView.refreshCurrentPageIfNeeded() }
}
private func applyChromeTheme() {
@@ -834,7 +859,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do {
_ = try annotationPersistence.addAnnotation(.init(pageIndex: page, selectedText: selection.text, normalizedRects: selection.normalizedRects, color: color, note: note, source: selection.source))
reloadAnnotationCache()
refreshVisiblePage(page)
refreshPageViews(at: page)
}
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
@@ -843,7 +868,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do {
_ = try annotationPersistence?.deleteAnnotation(id: annotation.id)
reloadAnnotationCache()
refreshVisiblePage(annotation.pageIndex)
refreshPageViews(at: annotation.pageIndex)
}
catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}
@@ -855,7 +880,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlight selection: RDPDFReaderImageTextSelection, color: String) { add(selection, page: pageView.pageIndex, color: color, note: nil) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestAnnotation selection: RDPDFReaderImageTextSelection) { presentEditor(selection: selection, page: pageView.pageIndex) }
public func pageView(_ pageView: RDPDFReaderPageView, didOpenAnnotation annotation: RDPDFReaderAnnotation) { presentEditor(annotation: annotation) }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshVisiblePage(item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } }
public func pageView(_ pageView: RDPDFReaderPageView, didRequestHighlightMenuAction action: RDPDFReaderExistingHighlightMenuAction, highlight: RDPDFReaderAnnotation) { if action == .deleteUnderline { delete(highlight) } else if action == .deleteAnnotation { var item = highlight; item.note = nil; do { _ = try annotationPersistence?.updateAnnotation(item); refreshPageViews(at: item.pageIndex) } catch { delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) } } else if action == .annotate { presentEditor(annotation: highlight) } }
private func presentEditor(selection: RDPDFReaderImageTextSelection, page: Int) {
let editor = RDPDFReaderAnnotationEditorViewController(quote: selection.text?.isEmpty == false ? selection.text! : "区域标注", theme: currentTheme, onSave: { [weak self] note in self?.add(selection, page: page, note: note) })
@@ -869,7 +894,7 @@ public final class RDPDFReaderViewController: UIViewController, RDPDFReaderDataS
do {
_ = try self?.annotationPersistence?.updateAnnotation(item)
self?.reloadAnnotationCache()
self?.refreshVisiblePage(item.pageIndex)
self?.refreshPageViews(at: item.pageIndex)
} catch {
if let self { self.delegate?.pdfReaderViewController(self, didFailAnnotationPersistence: error) }
}