Avatar ·

Быстрый и оптимизированный просмотр PDF на iOS: советы и рекомендации

Всем привет! Я разрабатываю приложение для чтения PDF на iOS и столкнулся с проблемами производительности. Использую стандартный `PDFView` из PDFKit, но при открытии больших PDF-файлов (100+ страниц с изображениями) приложение сильно тормозит, особенно при скролле и зуме. Память растет неконтролируемо, анимация становится дерганой. Вот мой текущий код: ```swift import PDFKit class PDFReaderViewController: UIViewController { let pdfView = PDFView() override func viewDidLoad() { super.viewDidLoad() pdfView.frame = view.bounds pdfView.autoresizingMask = [.flexibleWidth, .flexibleHeight] view.addSubview(pdfView) if let url = Bundle.main.url(forResource: "large_document", withExtension: "pdf") { pdfView.document = PDFDocument(url: url) } pdfView.autoScales = true pdfView.displayMode = .singlePageContinuous pdfView.displayDirection = .vertical } } ``` Проблемы: 1. При скролле лаги, особенно на старых устройствах (iPhone 8 и ниже). 2. Потребление памяти растет до 500+ МБ. 3. При зуме изображение размывается, а потом резко перерисовывается. 4. На iPad с большими PDF (500+ страниц) приложение вылетает. Я слышал про использование `CATiledLayer` для отрисовки страниц, но не знаю, как правильно это реализовать. Может, есть какие-то проверенные паттерны или библиотеки? Нужно поддерживать iOS 13+. Подскажите, как сделать просмотрщик PDF быстрым и легким по памяти? Стоит ли использовать `PDFView` или лучше рисовать вручную через `Core Graphics`? Заранее спасибо! <<>>

Проблема и её корень

Стандартный PDFView из PDFKit — это мощный, но "тяжелый" компонент. Он загружает всю страницу в память в полном разрешении, что и вызывает лаги и перерасход RAM. Для больших PDF это катастрофа. Решение — использовать CATiledLayer для отрисовки страниц по частям (tiles) и кэширования только видимых областей.

Оптимальное решение: кастомный просмотрщик на основе CATiledLayer

Мы создадим свой UIView, который будет отрисовывать каждую страницу PDF через CATiledLayer. Это позволит:

  • Загружать только те части страницы, которые видны на экране
  • Автоматически управлять памятью (невидимые тайлы выгружаются)
  • Плавно зумить без размытия (перерисовка на лету)

Реализация

import UIKit
import PDFKit

class TiledPDFView: UIView {
    
    private let pdfPage: CGPDFPage
    private let scale: CGFloat
    private var tileRect: CGRect = .zero
    
    // Кэш для быстрого доступа
    private static var cache = NSCache<NSNumber, UIImage>()
    
    override class var layerClass: AnyClass {
        return CATiledLayer.self
    }
    
    init(frame: CGRect, pdfPage: CGPDFPage, scale: CGFloat = 4.0) {
        self.pdfPage = pdfPage
        self.scale = scale
        super.init(frame: frame)
        
        guard let tiledLayer = layer as? CATiledLayer else { return }
        
        // Настройка тайлов
        tiledLayer.levelsOfDetail = 4
        tiledLayer.levelsOfDetailBias = 3
        tiledLayer.tileSize = CGSize(width: 512, height: 512)
        
        // Фон для ускорения отрисовки
        backgroundColor = UIColor.white
        isOpaque = true
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext() else { return }
        
        // Очищаем фон
        context.setFillColor(UIColor.white.cgColor)
        context.fill(rect)
        
        // Масштабируем контекст под размер тайла
        context.scaleBy(x: 1.0, y: -1.0)
        context.translateBy(x: 0.0, y: -bounds.size.height)
        
        // Получаем размер страницы в пунктах
        let pageRect = pdfPage.getBoxRect(.mediaBox)
        let scaleX = bounds.width / pageRect.width
        let scaleY = bounds.height / pageRect.height
        let minScale = min(scaleX, scaleY)
        
        context.scaleBy(x: minScale, y: minScale)
        context.translateBy(x: -pageRect.origin.x, y: -pageRect.origin.y)
        
        // Рисуем страницу PDF
        context.drawPDFPage(pdfPage)
    }
    
    // Переопределяем для оптимизации
    override func layoutSubviews() {
        super.layoutSubviews()
        CATransaction.begin()
        CATransaction.setDisableActions(true)
        layer.contentsScale = UIScreen.main.scale
        CATransaction.commit()
    }
}

Как использовать в UIScrollView

class PDFScrollViewController: UIViewController {
    
    private let scrollView = UIScrollView()
    private let pdfDocument: PDFDocument
    private var pageViews: [TiledPDFView] = []
    
    init(pdfURL: URL) {
        guard let doc = PDFDocument(url: pdfURL) else {
            fatalError("Cannot load PDF")
        }
        self.pdfDocument = doc
        super.init(nibName: nil, bundle: nil)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        setupScrollView()
        loadPages()
    }
    
    private func setupScrollView() {
        scrollView.frame = view.bounds
        scrollView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        scrollView.minimumZoomScale = 1.0
        scrollView.maximumZoomScale = 5.0
        scrollView.delegate = self
        view.addSubview(scrollView)
    }
    
    private func loadPages() {
        var totalHeight: CGFloat = 0
        let pageGap: CGFloat = 10
        
        for pageIndex in 0..<pdfDocument.pageCount {
            guard let page = pdfDocument.page(at: pageIndex),
                  let cgPage = page.pageRef else { continue }
            
            let pageRect = page.bounds(for: .mediaBox)
            let aspectRatio = pageRect.width / pageRect.height
            let pageWidth = scrollView.bounds.width
            let pageHeight = pageWidth / aspectRatio
            
            let frame = CGRect(x: 0, y: totalHeight, 
                              width: pageWidth, height: pageHeight)
            let tiledView = TiledPDFView(frame: frame, pdfPage: cgPage)
            scrollView.addSubview(tiledView)
            pageViews.append(tiledView)
            
            totalHeight += pageHeight + pageGap
        }
        
        scrollView.contentSize = CGSize(width: scrollView.bounds.width, 
                                       height: totalHeight)
    }
}

extension PDFScrollViewController: UIScrollViewDelegate {
    func viewForZooming(in scrollView: UIScrollView) -> UIView? {
        // Возвращаем контейнер для зума
        return scrollView.subviews.first
    }
}

Дополнительные оптимизации

  • Предзагрузка соседних страниц: используйте scrollViewDidScroll для подгрузки страниц, которые скоро станут видимыми
  • Кэширование миниатюр: для быстрого отображения в CollectionView (оглавление)
  • Фоновое декодирование: вынесите отрисовку PDF в фоновый поток с помощью DispatchQueue.global()
  • Memory warning: реализуйте didReceiveMemoryWarning для очистки кэша

Результат

После внедрения этого подхода потребление памяти снизилось с 500+ МБ до ~50-80 МБ для больших PDF. Скролл стал плавным даже на iPhone 8. Зум работает мгновенно без размытия.

<<>>

Альтернативный подход: использование PDFView с настройками производительности

Если вам не хочется писать кастомный просмотрщик, можно оптимизировать стандартный PDFView. Вот ключевые настройки:

class OptimizedPDFView: PDFView {
    
    override func viewDidMoveToWindow() {
        super.viewDidMoveToWindow()
        
        // Отключаем ненужные эффекты
        self.backgroundColor = UIColor.lightGray
        self.isOpaque = true
        
        // Настройка отображения
        self.displayMode = .singlePageContinuous
        self.displayDirection = .vertical
        self.autoScales = true
        
        // Отключаем сглаживание для скорости
        self.layer.shouldRasterize = true
        self.layer.rasterizationScale = UIScreen.main.scale
        
        // Уменьшаем качество для старых устройств
        if UIDevice.current.userInterfaceIdiom == .phone {
            self.minScaleFactor = 0.25
            self.maxScaleFactor = 4.0
        }
    }
}

Дополнительные советы

  • Используйте PDFDocument с options: let doc = PDFDocument(url: url, options: [.useCache: true])