iOS 高性能 PDF 阅读器架构设计与实现
基于 PDFKit + UIPageViewController 的优化实践
关键词:对象池 / 复用 / 预渲染 / 翻页优化 / 架构设计
一、项目背景
在 iOS 中使用 PDFKit 实现阅读器时,通常会遇到几个典型问题:
- ❌ 翻页白屏(尤其是 pageCurl 动画)
- ❌ 滑动/翻页卡顿
- ❌ 大 PDF(图片多)加载慢
- ❌ 内存不可控(频繁创建 PDFView)
尤其是当 PDF 内包含:
JPXDecode(JPEG2000)- 大尺寸图片
- 多资源流
性能问题会被放大 3~5 倍
二、设计目标
本方案的核心目标:
- ✅ 流畅翻页(接近 iBooks / Kindle)
- ✅ 避免白屏
- ✅ 控制内存
- ✅ 支持标注 / 选择 / 评论
- ✅ 支持横竖屏切换
三、整体架构设计
PDFReaderViewController (业务层)
↓
PageContainerController (调度层 ⭐核心)
↓
PDFPageViewController (页面层)
↓
CustomPDFView (渲染层)
3.1 分层职责
1️⃣ PDFReaderViewController(入口层)
负责:
- PDF 下载 / 缓存
- 阅读器初始化
- 阅读进度管理
- UI(进度条 / loading)
典型业务控制层
2️⃣ PageContainerController(核心调度层 ⭐⭐⭐⭐⭐)
继承:
UIPageViewController
负责:
- 翻页控制(pageCurl)
- PDFView 对象池管理
- 页面创建 / 回收
- 可视窗口控制
本质:
“虚拟列表 + 对象池调度器”
3️⃣ PDFPageViewController(单页容器)
负责:
- 持有 PDFView
- 页面布局
- 手势处理
- 用户交互(复制 / 标注)
本质:
“轻量 VC 壳”
4️⃣ CustomPDFView(渲染层)
基于 PDFKit:
- 页面渲染
- 文本选择
- 标注处理
本质:
“渲染引擎”
四、核心设计思想(关键)
⭐ 核心原则:
VC 可重建,PDFView 必复用
4.1 ViewController:每次新建
private func makeVC(for index: Int) -> PDFPageViewController? {
guard let doc = document, index >= 0, index < doc.pageCount else { return nil }
let vc = PDFPageViewController()
// 从池中获取 PDFView
let pv = dequeue(for: index)
// 注入 PDFView(核心)
vc.attachPDFView(pv, pageIndex: index, document: doc)
return vc
}
特点:
- 每次翻页创建新 VC
- 不做 VC 复用
为什么?
因为:
-
UIPageViewController + pageCurl对 VC 状态敏感 -
复用 VC 会导致:
- 动画错乱
- 手势冲突
- 生命周期异常
4.2 PDFView:对象池复用(核心优化)
private var pdfViewPool: [CustomPDFView]
private var pageViewMap: [Int: CustomPDFView]
// PDFView对象池设计
private func setupPDFViewPool() {
guard let doc = document else { return }
pdfViewPool = (0 ..< poolSize).map { _ in
let v = CustomPDFView()
v.document = doc // 共享 document(关键)
v.autoScales = true
v.displayDirection = .horizontal
v.displaysAsBook = false
v.backgroundColor = .white
return v
}
}
// PDFView分配机制
private func dequeue(for index: Int) -> CustomPDFView {
if let existing = pageViewMap[index] {
return existing
}
let used = Set(pageViewMap.values.map { ObjectIdentifier($0) })
if let free = pdfViewPool.first(where: { !used.contains(ObjectIdentifier($0)) }) {
pageViewMap[index] = free
return free
}
// 池子不够时扩容
let extra = CustomPDFView()
extra.document = document
extra.autoScales = true
extra.displayDirection = .horizontal
extra.displaysAsBook = false
pdfViewPool.append(extra)
pageViewMap[index] = extra
return extra
}
设计:
- 固定数量 PDFView
- 按 pageIndex 分配
- 使用完回收
为什么必须复用?
PDFKit 的性能瓶颈在于:
- 页面渲染(tile cache)
- 图片解码(JPXDecode 极慢)
如果每次创建:
PDFView()
会导致:
- ❌ 缓存丢失
- ❌ 重新解码
- ❌ 白屏
4.3 共享 PDFDocument
v.document = doc
所有 PDFView 共享同一个 document
效果:
- ✅ 渲染缓存复用
- ✅ 避免重复解析
4.4 使用 goToPage 替代重建
pdfView.go(to: page)
不重新创建 PDFView,仅跳转页面
优势:
- 保留内部缓存
- 提升翻页速度
4.5 可视窗口 + 回收机制
// 回收机制
private func recycle(index: Int) {
guard pageViewMap[index] != nil else { return }
pageViewMap.removeValue(forKey: index)
}
// 只保留当前页附近内容
private func recycleOutOfRange(center: Int) {
let range = isTwoUp ? prefetchRange * 2 + 1 : prefetchRange + 1
let extraEnd = isTwoUp ? 1 : 0
let keepStart = max(0, center - range)
let keepEnd = min((document?.pageCount ?? 1) - 1, center + range + extraEnd)
for index in pageViewMap.keys where index < keepStart || index > keepEnd {
recycle(index: index)
}
}
策略:
- 只保留当前页附近 N 页
- 超出范围 → 回收
类似:
📱 UITableView / RecyclerView 复用机制
4.6 横竖屏适配
var isTwoUp: Bool
- 竖屏:单页
- 横屏:双页
影响:
- 翻页步长(±1 / ±2)
- 池大小
五、手势设计
单击 / 双击冲突处理
private func setupGestures(on targetView: CustomPDFView) {
targetView.gestureRecognizers?
.compactMap { $0 as? UITapGestureRecognizer }
.filter { $0.numberOfTapsRequired == 1 }
.forEach { targetView.removeGestureRecognizer($0) }
let existingDoubleTap = targetView.gestureRecognizers?
.compactMap { $0 as? UITapGestureRecognizer }
.first { $0.numberOfTapsRequired == 2 }
let singleTap = UITapGestureRecognizer(target: self,
action: #selector(handleSingleTap(_:)))
singleTap.numberOfTapsRequired = 1
if let doubleTap = existingDoubleTap {
singleTap.require(toFail: doubleTap)
}
targetView.addGestureRecognizer(singleTap)
}
@objc private func handleSingleTap(_ tap: UITapGestureRecognizer) {
guard let pdfView else { return }
let ratio = tap.location(in: pdfView).x / pdfView.bounds.width
if ratio < 0.25 {
pdfView.goToPreviousPage(nil)
} else if ratio > 0.75 {
pdfView.goToNextPage(nil)
} else {
onToggleToolbar?()
}
}
左侧 25% → 上一页
右侧 25% → 下一页
中间 → 显示工具栏
经典阅读器交互(类似 Kindle)
六、性能优化总结
✅ 已实现优化
- PDFView 对象池复用 ⭐⭐⭐⭐⭐
- PDFDocument 共享
- goToPage 跳转
- 页面回收机制
- 横竖屏适配
七、架构优缺点分析
优点
- 分层清晰
- 性能优化到位
- 可扩展性强(标注 / 评论)
- 复用机制优秀
已达到生产级水平
八、总结
核心一句话
通过“PDFView 对象池 + 页面虚拟化”,避免 PDFKit 重渲染,实现高性能 PDF 阅读体验
架构本质
轻量 VC(可销毁)
+
重型 View(复用)
+
缓存驱动(PDFKit)
最终效果DEMO
- ✅ 翻页流畅
- ✅ 无白屏
- ✅ 内存稳定
- ✅ 支持复杂交互