iOS 高性能 PDF 阅读器方案实践

10 阅读4分钟

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

  • ✅ 翻页流畅
  • ✅ 无白屏
  • ✅ 内存稳定
  • ✅ 支持复杂交互