You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

iOS Swift中使用PDFKit对比大页数PDF时应用因内存崩溃

PDF对比NativeModule内存崩溃问题优化方案

问题背景

我开发了一个NativeModule用于对比两个PDF文件,汇总差异后返回给React Native桥接层。当前代码在PDF页数少于100时能正常运行,但页数增加后会因内存问题崩溃。我已经尝试过autoreleasepool等方法,相关代码如下:

Native Module (PDF Manager)

import Foundation
import PDFKit
import SwiftDiff

@objc(PDFManager)
class PDFManager: NSObject {
  
  private var currentPageIndex: Int = 0
  private var lastProcessedIndex: Int = 0
  
  @objc
  func compareAndMerge(_ pdfPath1: String, pdfPath2: String, documentName: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
    do {
      guard let firstPdfURL = URL(string: pdfPath1),
            let secondPdfURL = URL(string: pdfPath2) else {
        reject("pdfInvalidPath", "Invalid PDF path", nil)
        return
      }
      
      guard let pdf1 = PDFDocument(url: firstPdfURL),
            let pdf2 = PDFDocument(url: secondPdfURL) else {
        reject("PDFLoadError", "Failed to load PDFs", nil)
        return
      }
      
      let summary = try compareAndSummarize(doc1: pdf1, doc2: pdf2)
      let json = summarizeToJSON(summary: summary)
      resolve(json)
    } catch {
      print("Error merging PDFs:", error)
      reject("pdfMergeError", "Error merging PDFs", nil)
    }
  }
  
  @objc
  static func requiresMainQueueSetup() -> Bool {
    return true
  }
  
  private func compareAndSummarize(doc1: PDFDocument, doc2: PDFDocument) throws -> (String, Int) {
    var changesSummary = ""
    var pagesChanged = 0
    // Iterate over pages of doc1 and doc2 simultaneously
    let pageCount = min(doc1.pageCount, doc2.pageCount)
    for pageIndex in 0..<pageCount {
      autoreleasepool {
        guard let page1 = doc1.page(at: pageIndex),
              let page2 = doc2.page(at: pageIndex) else { return }
        let page1Text = page1.string ?? ""
        let page2Text = page2.string ?? ""
        changesSummary += "Page \(pageIndex + 1):\n"
        
        // Calculate differences between the text of the two pages
        let diff = diff(text1: page1Text, text2: page2Text)
        var pageChanged = false
        // Process each change in the differences
        for change in diff {
          switch change {
          case .delete(let value):
            pageChanged = true
            changesSummary += "- Deleted: \(value)\n"
          case .insert(let value):
            pageChanged = true
            changesSummary += "+ Inserted: \(value)\n"
          case .equal( _):
            break
          }
        }
        if pageChanged {
          pagesChanged += 1
        }
      }
    }
    return (changesSummary, pagesChanged)
  }

  func summarizeToJSON(summary: (String, Int)) -> String {
    let dictionary: [String: Any] = ["changes": summary.0, "pagesChanged": summary.0]
    let data = try? JSONSerialization.data(withJSONObject: dictionary, options: [.prettyPrinted])
    return String(data: data!, encoding: .utf8) ?? ""
  }

}

React Native 代码

import React, {useState} from 'react';
import {ActivityIndicator, NativeModules, View, useWindowDimensions} from 'react-native';
import Text from '../../Components/Text';
import s from '../../assets/styles';
import {Button} from '../../Components/Buttons';
import DocumentPicker from 'react-native-document-picker';
import {useSafeAreaInsets} from 'react-native-safe-area-context';

function HomeScreen() {
  const [pdf1, setPDF1] = useState(null);
  const [pdf2, setPDF2] = useState(null);
  const [merging, setMerging] = useState(false);
  const {PDFManager} = NativeModules;
  const insets = useSafeAreaInsets();
  const {width} = useWindowDimensions();
  const buttonWidth = width / 2 - 25;

  const selectFirstPDF = async () => {
    try {
      const result = await DocumentPicker.pickSingle({
        type: DocumentPicker.types.pdf,
        copyTo: 'cachesDirectory',
      });
      if (!result?.fileCopyUri) {
        return;
      }
      setPDF1(result);
    } catch (error) {}
  };

  const selectSecondPDF = async () => {
    try {
      const result = await DocumentPicker.pickSingle({
        type: DocumentPicker.types.pdf,
        copyTo: 'cachesDirectory',
      });
      if (!result?.fileCopyUri) {
        return;
      }
      setPDF2(result);
    } catch (error) {}
  };

  const updateManuals = async () => {
    setMerging(true);
    try {
      const result = await PDFManager.compareAndMerge(
        pdf1?.fileCopyUri,
        pdf2?.fileCopyUri,
        `${pdf2?.name}_PDFCompared.pdf`,
      );
      setMerging(false);
      if (result) {
        const json = JSON.parse(result);
        console.log('json -> ', json);
      }
    } catch (error) {
      console.log('Error -> ', error);
    } finally {
      setMerging(false);
    }
  };

  return (
    <View style={[s.flex1, {paddingTop: insets.top}, s.jcac]}>
      <View style={[s.mHor10]}>
        <Text style={[s.f30, s.ls2, s.fontBlack, s.textCenter, s.as]}>
          Keep Highlights
        </Text>
        <Text style={[s.fontSemiBold, s.mVer10]}>
          Effortlessly compare your PDF manuals and keep your highlights
          updated. Files are compared locally and your data always remain
          confidential.
        </Text>
        <View style={[s.flexRow, s.aic, s.jsb, s.mVer10]}>
          <Button
            title={pdf1?.name ? pdf1?.name : 'Highlighted PDF'}
            style={[s.bgPurple, s.br30, {width: buttonWidth}, s.me10]}
            textStyle={[s.fontMedium, s.f14, s.textWhite]}
            onPress={selectFirstPDF}
          />
          <Button
            title={pdf2?.name ? pdf2?.name : 'Updated manual'}
            onPress={selectSecondPDF}
            textStyle={[s.fontMedium, s.f14, s.textWhite]}
            style={[s.br30, {width: buttonWidth}]}
          />
        </View>
        <Button
          title={'Get updated manual'}
          isDisabled={!pdf1?.fileCopyUri || !pdf2?.fileCopyUri}
          onPress={updateManuals}
        />
        {merging && <ActivityIndicator size={'large'} color={'white'}/>}
      </View>
    </View>
  );
}

export default HomeScreen;

优化方案

1. 移至后台线程处理

当前代码在主线程执行大PDF对比,既阻塞UI又加剧内存压力。修改requiresMainQueueSetup并将处理逻辑移到后台队列:

@objc
static func requiresMainQueueSetup() -> Bool {
  return false
}

@objc
func compareAndMerge(_ pdfPath1: String, pdfPath2: String, documentName: String, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
  DispatchQueue.global(qos: .userInitiated).async {
    do {
      guard let firstPdfURL = URL(string: pdfPath1),
            let secondPdfURL = URL(string: pdfPath2) else {
        DispatchQueue.main.async {
          reject("pdfInvalidPath", "无效的PDF路径", nil)
        }
        return
      }
      
      guard let pdf1 = PDFDocument(url: firstPdfURL),
            let pdf2 = PDFDocument(url: secondPdfURL) else {
        DispatchQueue.main.async {
          reject("PDFLoadError", "加载PDF失败", nil)
        }
        return
      }
      
      let summary = try self.compareAndSummarize(doc1: pdf1, doc2: pdf2)
      let json = self.summarizeToJSON(summary: summary)
      DispatchQueue.main.async {
        resolve(json)
      }
    } catch {
      DispatchQueue.main.async {
        print("对比PDF错误:", error)
        reject("pdfMergeError", "对比PDF时出错", error)
      }
    }
  }
}

2. 优化字符串拼接性能

直接用String拼接会频繁生成新对象,改用NSMutableString减少内存开销:

private func compareAndSummarize(doc1: PDFDocument, doc2: PDFDocument) throws -> (String, Int) {
  let changesSummary = NSMutableString()
  var pagesChanged = 0
  let pageCount = min(doc1.pageCount, doc2.pageCount)
  
  for pageIndex in 0..<pageCount {
    autoreleasepool {
      guard let page1 = doc1.page(at: pageIndex),
            let page2 = doc2.page(at: pageIndex) else { return }
      
      let page1Text = page1.string ?? ""
      let page2Text = page2.string ?? ""
      changesSummary.append("第\(pageIndex + 1)页:\n")
      
      let diff = diff(text1: page1Text, text2: page2Text)
      var pageChanged = false
      
      for change in diff {
        switch change {
        case .delete(let value):
          pageChanged = true
          changesSummary.append("- 删除: \(value)\n")
        case .insert(let value):
          pageChanged = true
          changesSummary.append("+ 新增: \(value)\n")
        case .equal:
          break
        }
      }
      
      if pageChanged {
        pagesChanged += 1
      }
    }
  }
  return (changesSummary as String, pagesChanged)
}

3. 修复JSON序列化错误

原代码中pagesChanged错误地赋值为字符串,修正为整数:

func summarizeToJSON(summary: (String, Int)) -> String {
  let dictionary: [String: Any] = ["changes": summary.0, "pagesChanged": summary.1]
  guard let data = try? JSONSerialization.data(withJSONObject: dictionary, options: []) else {
    return ""
  }
  return String(data: data, encoding: .utf8) ?? ""
}

(注:去掉.prettyPrinted可进一步减少JSON数据体积)

4. 加强内存释放

  • 确保autoreleasepool包裹每一页的所有处理逻辑,让每页的临时对象及时释放
  • 若PDF文档过大,可考虑分批加载处理,处理完部分页面后主动释放对应资源(例如重置PDFDocument引用)

5. 调试与监控

  • 使用Xcode的Memory Graph Debugger排查内存泄漏点
  • 用Instruments的Memory工具跟踪内存增长,定位内存占用过高的对象

内容的提问来源于stack exchange,提问作者Anwer Solangi

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 20:08:09